mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
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:
+3
-1
@@ -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
@@ -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)})
|
||||
|
||||
@@ -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 ────────────────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user