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 .serializers import LinkSerializer, ImageCollectionSerializer, ImageSerializer, ImageDescriptionSerializer
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 logging
import os
@@ -125,6 +125,8 @@ class ImageCollectionViewSet(viewsets.ModelViewSet):
# Persist to the files backend (FILE_UPLOADS_FOLDER + FileUpload record)
try:
upload = _save_uploaded_file(file)
# Pre-generate thumbnail so the first grid view is instant
_generate_thumbnail(upload)
except FileSaveError as exc:
logger.error(f"Storage upload failed: {exc}", exc_info=True)
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
a ``Content-Range`` header. Without this, the media element can only play
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)
@@ -110,6 +116,10 @@ def _stream_file(file_path, mime_type, disposition, range_header=None):
response['Content-Length'] = size
response['Accept-Ranges'] = 'bytes'
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
# 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['Accept-Ranges'] = 'bytes'
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.
response.close = fh.close
return response
@@ -237,7 +251,10 @@ class FileThumbnailView(View):
thumb = _generate_thumbnail(record)
if not thumb or not os.path.exists(thumb):
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):
@@ -259,6 +276,10 @@ class FileUploadView(View):
for f in uploaded:
try:
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:
logger.error("Upload failed for %s: %s", f.name, exc)
errors.append({'name': f.name, 'error': str(exc)})
@@ -399,7 +420,12 @@ class FileUploadViewSet(viewsets.ModelViewSet):
errors = []
for f in uploaded:
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:
logger.error("Upload failed for %s: %s", f.name, exc)
errors.append({'name': f.name, 'error': str(exc)})
+107 -13
View File
@@ -162,6 +162,32 @@
.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) ---------- */
.file-grid {
display: grid; gap: 0.9rem; padding: 0.6rem 1.8rem 1.6rem;
@@ -450,7 +476,7 @@
<div class="library-title-group">
<h2 id="files-heading" class="section-title">{% trans "Files" %}</h2>
<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>
</p>
</div>
@@ -491,6 +517,21 @@
</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 -->
<script id="initial-file-data" type="application/json" aria-hidden="true">
[{% for file in files %}{
@@ -927,6 +968,17 @@
function fileManager() {
return {
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: [],
hudOpen: false,
layout: 'list',
@@ -947,10 +999,18 @@ function fileManager() {
// Seed initial file list from server-rendered JSON
try {
this.visibleFiles = JSON.parse(document.getElementById('initial-file-data').textContent);
this.allFiles = JSON.parse(document.getElementById('initial-file-data').textContent);
} 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
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' });
},
// ── 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 ─────────────────────────────────────────────────
uploadFiles(fileList) {
this.hudOpen = true;
@@ -1119,10 +1215,12 @@ function fileManager() {
is_image: (rec.mime_type || guessMime(file.name)).startsWith('image/'),
thumbnail_url: rec.thumbnail_url || '',
};
// Avoid inserting duplicate (key by id)
if (!this.visibleFiles.some(x => String(x.id) === String(newFile.id))) {
this.visibleFiles.unshift(newFile);
this.fileList.unshift({ name: newFile.name, mimeType: newFile.mime_type, url: newFile.download_url });
// Avoid inserting duplicate (key by id); add to the source
// list and re-apply the active filter so the new row shows
// only when it matches the current type filter.
if (!this.allFiles.some(x => String(x.id) === String(newFile.id))) {
this.allFiles.unshift(newFile);
this.applyFilter();
this.$nextTick(() => this._applyCoverFlow());
}
});
@@ -1163,12 +1261,8 @@ function fileManager() {
},
removeFile(id) {
const idx = this.visibleFiles.findIndex(f => String(f.id) === String(id));
if (idx !== -1) this.visibleFiles.splice(idx, 1);
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());
this.allFiles = this.allFiles.filter(f => String(f.id) !== String(id));
this.applyFilter();
},
// ── Preview modal ────────────────────────────────────────────
+41
View File
@@ -252,3 +252,44 @@ class TestFilesAPI:
if os.path.exists(record.file_path):
os.remove(record.file_path)
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)