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 .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
@@ -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)})
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user