feat: file type filter + NFS-friendly thumbnail caching

- Files page: type filter chips (All/Image/Video/Audio/Text/Document/Archive/
  Other) with live counts, persisted in localStorage, upload/delete re-apply
  the active filter; header shows filtered/total count
- Cache headers: thumbnails + inline media get public,max-age=31536000,immutable
  (uuid stored_name is immutable); attachment downloads no-store — repeated
  views stop hammering the NFS uploads volume
- Thumbnails pre-generated on upload (form view + /api/files ViewSet +
  collection uploads) so the first page view is instant
- tests: 4 new cache/pre-gen cases (152 total)
This commit is contained in:
OpenClaw Sub-agent
2026-08-03 16:53:00 +10:00
parent 9f3a673e47
commit e685aa6ed8
4 changed files with 179 additions and 16 deletions
+3 -1
View File
@@ -5,7 +5,7 @@ from rest_framework.pagination import PageNumberPagination
from .models import Link, ImageCollection, Image, FileUpload from .models import Link, ImageCollection, Image, FileUpload
from .serializers import LinkSerializer, ImageCollectionSerializer, ImageSerializer, ImageDescriptionSerializer from .serializers import LinkSerializer, ImageCollectionSerializer, ImageSerializer, ImageDescriptionSerializer
from .storage import R2Storage from .storage import R2Storage
from .file_views import _save_uploaded_file, FileSaveError from .file_views import _save_uploaded_file, FileSaveError, _generate_thumbnail
import uuid import uuid
import logging import logging
import os import os
@@ -125,6 +125,8 @@ class ImageCollectionViewSet(viewsets.ModelViewSet):
# Persist to the files backend (FILE_UPLOADS_FOLDER + FileUpload record) # Persist to the files backend (FILE_UPLOADS_FOLDER + FileUpload record)
try: try:
upload = _save_uploaded_file(file) upload = _save_uploaded_file(file)
# Pre-generate thumbnail so the first grid view is instant
_generate_thumbnail(upload)
except FileSaveError as exc: except FileSaveError as exc:
logger.error(f"Storage upload failed: {exc}", exc_info=True) logger.error(f"Storage upload failed: {exc}", exc_info=True)
raise ValueError(f"Storage upload failed: {exc}") raise ValueError(f"Storage upload failed: {exc}")
+28 -2
View File
@@ -101,6 +101,12 @@ def _stream_file(file_path, mime_type, disposition, range_header=None):
so we parse the header ourselves and reply with ``206 Partial Content`` and so we parse the header ourselves and reply with ``206 Partial Content`` and
a ``Content-Range`` header. Without this, the media element can only play a ``Content-Range`` header. Without this, the media element can only play
sequentially and seeking fails. sequentially and seeking fails.
Files stored via FileUpload get a random hex ``stored_name`` that never
changes for a given pk, so inline media responses are safe to cache long
term (``public, max-age=31536000, immutable``) — this keeps repeated views
off the (possibly small) NFS-backed uploads volume. Attachment downloads
are marked no-store.
""" """
size = os.path.getsize(file_path) size = os.path.getsize(file_path)
@@ -110,6 +116,10 @@ def _stream_file(file_path, mime_type, disposition, range_header=None):
response['Content-Length'] = size response['Content-Length'] = size
response['Accept-Ranges'] = 'bytes' response['Accept-Ranges'] = 'bytes'
response['Content-Disposition'] = disposition response['Content-Disposition'] = disposition
if 'inline' in disposition:
response['Cache-Control'] = 'public, max-age=31536000, immutable'
else:
response['Cache-Control'] = 'private, no-store'
return response return response
# Parse "bytes=start-end" (end optional; multiple ranges not supported here). # Parse "bytes=start-end" (end optional; multiple ranges not supported here).
@@ -156,6 +166,10 @@ def _stream_file(file_path, mime_type, disposition, range_header=None):
response['Content-Range'] = f'bytes {start}-{end}/{size}' response['Content-Range'] = f'bytes {start}-{end}/{size}'
response['Accept-Ranges'] = 'bytes' response['Accept-Ranges'] = 'bytes'
response['Content-Disposition'] = disposition response['Content-Disposition'] = disposition
if 'inline' in disposition:
response['Cache-Control'] = 'public, max-age=31536000, immutable'
else:
response['Cache-Control'] = 'private, no-store'
# Ensure the file handle is closed when the response finalises. # Ensure the file handle is closed when the response finalises.
response.close = fh.close response.close = fh.close
return response return response
@@ -237,7 +251,10 @@ class FileThumbnailView(View):
thumb = _generate_thumbnail(record) thumb = _generate_thumbnail(record)
if not thumb or not os.path.exists(thumb): if not thumb or not os.path.exists(thumb):
raise Http404('No thumbnail available for this file') raise Http404('No thumbnail available for this file')
return FileResponse(open(thumb, 'rb'), content_type='image/jpeg') response = FileResponse(open(thumb, 'rb'), content_type='image/jpeg')
# Thumbnail path is derived from the immutable stored_name → cache forever.
response['Cache-Control'] = 'public, max-age=31536000, immutable'
return response
class FileListView(View): class FileListView(View):
@@ -259,6 +276,10 @@ class FileUploadView(View):
for f in uploaded: for f in uploaded:
try: try:
record = _save_uploaded_file(f) record = _save_uploaded_file(f)
# Pre-generate the thumbnail for images so the first page view
# is instant (and the NFS volume isn't hammered by lazy gen).
if record.is_image:
_generate_thumbnail(record)
except FileSaveError as exc: except FileSaveError as exc:
logger.error("Upload failed for %s: %s", f.name, exc) logger.error("Upload failed for %s: %s", f.name, exc)
errors.append({'name': f.name, 'error': str(exc)}) errors.append({'name': f.name, 'error': str(exc)})
@@ -399,7 +420,12 @@ class FileUploadViewSet(viewsets.ModelViewSet):
errors = [] errors = []
for f in uploaded: for f in uploaded:
try: try:
created.append(_save_uploaded_file(f)) record = _save_uploaded_file(f)
# Pre-generate the thumbnail for images so the first page view
# is instant (and the NFS volume isn't hammered by lazy gen).
if record.is_image:
_generate_thumbnail(record)
created.append(record)
except FileSaveError as exc: except FileSaveError as exc:
logger.error("Upload failed for %s: %s", f.name, exc) logger.error("Upload failed for %s: %s", f.name, exc)
errors.append({'name': f.name, 'error': str(exc)}) errors.append({'name': f.name, 'error': str(exc)})
+107 -13
View File
@@ -162,6 +162,32 @@
.layout-btn { width: 38px; height: 32px; } .layout-btn { width: 38px; height: 32px; }
} }
/* ---------- Type filter bar ---------- */
.filter-bar {
display: flex; flex-wrap: wrap; align-items: center; gap: 0.45rem;
padding: 0.1rem 1.8rem 1.1rem;
}
.filter-chip {
display: inline-flex; align-items: center; gap: 0.4rem;
min-height: 32px; padding: 0.3rem 0.85rem;
border-radius: 980px; border: 1px solid rgba(0, 0, 0, 0.07);
background: #fff; color: var(--apple-gray);
font-size: 0.8rem; font-weight: 600; cursor: pointer;
transition: background 0.25s ease, color 0.25s ease, border-color 0.25s ease, transform 0.2s var(--apple-ease);
}
.filter-chip:hover { background: #f0f0f3; }
.filter-chip:active { transform: scale(0.96); }
.filter-chip.active {
background: var(--apple-blue); color: #fff; border-color: var(--apple-blue);
}
.chip-count {
display: inline-flex; align-items: center; justify-content: center;
min-width: 20px; height: 20px; padding: 0 0.35rem;
border-radius: 980px; font-size: 0.7rem; font-weight: 700;
background: rgba(0, 0, 0, 0.06); color: var(--apple-gray);
}
.filter-chip.active .chip-count { background: rgba(255, 255, 255, 0.24); color: #fff; }
/* ---------- Grid view (macOS Finder style) ---------- */ /* ---------- Grid view (macOS Finder style) ---------- */
.file-grid { .file-grid {
display: grid; gap: 0.9rem; padding: 0.6rem 1.8rem 1.6rem; display: grid; gap: 0.9rem; padding: 0.6rem 1.8rem 1.6rem;
@@ -450,7 +476,7 @@
<div class="library-title-group"> <div class="library-title-group">
<h2 id="files-heading" class="section-title">{% trans "Files" %}</h2> <h2 id="files-heading" class="section-title">{% trans "Files" %}</h2>
<p class="library-count"> <p class="library-count">
<span x-text="visibleFiles.length">{{ files.count }}</span> <span x-text="filterType !== 'all' ? visibleFiles.length + ' / ' + allFiles.length : visibleFiles.length"></span>
<span x-text="visibleFiles.length === 1 ? '{% trans "file" %}' : '{% trans "files" %}'">{% trans "files" %}</span> <span x-text="visibleFiles.length === 1 ? '{% trans "file" %}' : '{% trans "files" %}'">{% trans "files" %}</span>
</p> </p>
</div> </div>
@@ -491,6 +517,21 @@
</div> </div>
</div> </div>
<!-- Type filter bar (persisted in localStorage) -->
<div class="filter-bar" role="group" aria-label="{% trans 'Filter by file type' %}">
<button type="button" class="filter-chip" :class="filterType === 'all' ? 'active' : ''"
@click="setFilter('all')" aria-pressed="filterType === 'all'">
{% trans "All" %}<span class="chip-count" x-text="countOf('all')"></span>
</button>
<template x-for="t in filterTypes" :key="t.key">
<button type="button" class="filter-chip" :class="filterType === t.key ? 'active' : ''"
@click="setFilter(t.key)" x-show="countOf(t.key) > 0"
:aria-pressed="filterType === t.key">
<span x-text="t.label"></span><span class="chip-count" x-text="countOf(t.key)"></span>
</button>
</template>
</div>
<!-- Hidden JSON template of each initial file --> <!-- Hidden JSON template of each initial file -->
<script id="initial-file-data" type="application/json" aria-hidden="true"> <script id="initial-file-data" type="application/json" aria-hidden="true">
[{% for file in files %}{ [{% for file in files %}{
@@ -927,6 +968,17 @@
function fileManager() { function fileManager() {
return { return {
visibleFiles: [], visibleFiles: [],
allFiles: [],
filterType: 'all',
filterTypes: [
{ key: 'image', label: '{% trans "Image" %}' },
{ key: 'video', label: '{% trans "Video" %}' },
{ key: 'audio', label: '{% trans "Audio" %}' },
{ key: 'text', label: '{% trans "Text" %}' },
{ key: 'document', label: '{% trans "Document" %}' },
{ key: 'archive', label: '{% trans "Archive" %}' },
{ key: 'binary', label: '{% trans "Other" %}' },
],
uploads: [], uploads: [],
hudOpen: false, hudOpen: false,
layout: 'list', layout: 'list',
@@ -947,10 +999,18 @@ function fileManager() {
// Seed initial file list from server-rendered JSON // Seed initial file list from server-rendered JSON
try { try {
this.visibleFiles = JSON.parse(document.getElementById('initial-file-data').textContent); this.allFiles = JSON.parse(document.getElementById('initial-file-data').textContent);
} catch (e) { } catch (e) {
this.visibleFiles = []; this.allFiles = [];
} }
// Restore persisted type filter
try {
const savedFilter = localStorage.getItem('links-files-filter');
if (savedFilter && this.filterTypes.some(t => t.key === savedFilter)) this.filterType = savedFilter;
} catch (e) { /* ignore */ }
this.applyFilter();
// Build file list for preview keyboard navigation // Build file list for preview keyboard navigation
this.fileList = this.visibleFiles.map(f => ({ name: f.name, mimeType: f.mime_type, url: f.download_url })); this.fileList = this.visibleFiles.map(f => ({ name: f.name, mimeType: f.mime_type, url: f.download_url }));
@@ -1072,6 +1132,42 @@ function fileManager() {
track.scrollBy({ left: dir * step, behavior: 'smooth' }); track.scrollBy({ left: dir * step, behavior: 'smooth' });
}, },
// ── Type filtering ──────────────────────────────────────────
fileTypeOf(f) {
const m = (f.mime_type || '').toLowerCase();
if (m.startsWith('image/')) return 'image';
if (m.startsWith('video/')) return 'video';
if (m.startsWith('audio/')) return 'audio';
if (m.startsWith('text/')) return 'text';
const docs = [
'application/pdf', 'application/msword', 'application/rtf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
];
if (docs.includes(m)) return 'document';
if (/(zip|gzip|tar|rar|7z|compress|x-7z|x-zip|x-tar|x-gzip|x-rar|x-compressed)/.test(m)) return 'archive';
return 'binary';
},
countOf(t) {
if (t === 'all') return this.allFiles.length;
return this.allFiles.filter(f => this.fileTypeOf(f) === t).length;
},
setFilter(t) {
this.filterType = t;
try { localStorage.setItem('links-files-filter', t); } catch (e) { /* ignore */ }
this.applyFilter();
},
applyFilter() {
this.visibleFiles = this.filterType === 'all'
? [...this.allFiles]
: this.allFiles.filter(f => this.fileTypeOf(f) === this.filterType);
this.fileList = this.visibleFiles.map(f => ({ name: f.name, mimeType: f.mime_type, url: f.download_url }));
this.$nextTick(() => this._applyCoverFlow());
},
// ── Uploads ───────────────────────────────────────────────── // ── Uploads ─────────────────────────────────────────────────
uploadFiles(fileList) { uploadFiles(fileList) {
this.hudOpen = true; this.hudOpen = true;
@@ -1119,10 +1215,12 @@ function fileManager() {
is_image: (rec.mime_type || guessMime(file.name)).startsWith('image/'), is_image: (rec.mime_type || guessMime(file.name)).startsWith('image/'),
thumbnail_url: rec.thumbnail_url || '', thumbnail_url: rec.thumbnail_url || '',
}; };
// Avoid inserting duplicate (key by id) // Avoid inserting duplicate (key by id); add to the source
if (!this.visibleFiles.some(x => String(x.id) === String(newFile.id))) { // list and re-apply the active filter so the new row shows
this.visibleFiles.unshift(newFile); // only when it matches the current type filter.
this.fileList.unshift({ name: newFile.name, mimeType: newFile.mime_type, url: newFile.download_url }); if (!this.allFiles.some(x => String(x.id) === String(newFile.id))) {
this.allFiles.unshift(newFile);
this.applyFilter();
this.$nextTick(() => this._applyCoverFlow()); this.$nextTick(() => this._applyCoverFlow());
} }
}); });
@@ -1163,12 +1261,8 @@ function fileManager() {
}, },
removeFile(id) { removeFile(id) {
const idx = this.visibleFiles.findIndex(f => String(f.id) === String(id)); this.allFiles = this.allFiles.filter(f => String(f.id) !== String(id));
if (idx !== -1) this.visibleFiles.splice(idx, 1); this.applyFilter();
const fidx = this.fileList.findIndex(f => f.url === (idx !== -1 ? 'placeholder' : ''));
// rebuild fileList to keep preview nav in sync
this.fileList = this.visibleFiles.map(f => ({ name: f.name, mimeType: f.mime_type, url: f.download_url }));
this.$nextTick(() => this._applyCoverFlow());
}, },
// ── Preview modal ──────────────────────────────────────────── // ── Preview modal ────────────────────────────────────────────
+41
View File
@@ -252,3 +252,44 @@ class TestFilesAPI:
if os.path.exists(record.file_path): if os.path.exists(record.file_path):
os.remove(record.file_path) os.remove(record.file_path)
assert client.get(f"/ui/files/{pk}/thumb/").status_code == 404 assert client.get(f"/ui/files/{pk}/thumb/").status_code == 404
# ── Cache headers (NFS-friendly) ────────────────────────────────────
def test_thumbnail_cache_control_immutable(self, client, api_client):
"""Thumbnails are content-addressed by the immutable stored_name."""
pk = api_client.post(
f"{self.BASE}", {"files": _real_png()}, format="multipart"
).json()[0]["id"]
r = client.get(f"/ui/files/{pk}/thumb/")
assert r.status_code == 200
assert r["Cache-Control"] == "public, max-age=31536000, immutable"
def test_inline_download_cache_control(self, client, api_client):
"""Inline media (images) must be cacheable so repeated views skip NFS."""
uploaded = api_client.post(
f"{self.BASE}", {"files": _real_png()}, format="multipart"
).json()[0]
r = client.get(f"/ui/files/{uploaded['id']}-{uploaded['name']}")
assert r.status_code == 200
assert "max-age=31536000" in r["Cache-Control"]
assert "immutable" in r["Cache-Control"]
def test_attachment_download_no_store(self, client, api_client):
"""Non-media (attachment) downloads must not be cached."""
uploaded = api_client.post(
f"{self.BASE}", {"files": _pdf()}, format="multipart"
).json()[0]
r = client.get(f"/ui/files/{uploaded['id']}-{uploaded['name']}")
assert r.status_code == 200
assert (r["Content-Disposition"] or "").startswith("attachment")
assert r["Cache-Control"] == "private, no-store"
def test_upload_pre_generates_thumbnail(self, api_client):
"""Image uploads pre-generate the thumbnail so first view is instant."""
uploaded = api_client.post(
f"{self.BASE}", {"files": _real_png()}, format="multipart"
).json()[0]
from links.models import FileUpload
record = FileUpload.objects.get(pk=uploaded["id"])
assert os.path.exists(record.thumb_path)