Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion app.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,40 @@ def load_media_provider():
# List all uploaded images (not album-specific)
@app.route('/api/images', methods=['GET'])
def api_list_images():
files = [f for f in os.listdir(app.config['UPLOAD_FOLDER']) if os.path.isfile(os.path.join(app.config['UPLOAD_FOLDER'], f))]
"""
List the uploaded filenames, newest first.
Pass params "?q=" to search specific filenames or "?sort=" to change the default sorting
"""
upload_folder = app.config['UPLOAD_FOLDER']
files = [
f for f in os.listdir(upload_folder)
if os.path.isfile(os.path.join(upload_folder, f))
]

query = (request.args.get('q') or '').strip().lower()
if query:
files = [f for f in files if query in f.lower()]

added_at = {
img.filename: img.created_at
for img in Image.query.filter(Image.filename.in_(files)).all()
} if files else {}

def sort_key(filename):
known = added_at.get(filename)
if known is not None:
return known.timestamp()
try:
return os.path.getmtime(os.path.join(upload_folder, filename))
except OSError:
return 0.0

sort = request.args.get('sort')
if sort == 'name':
files.sort(key=str.lower)
else:
files.sort(key=sort_key, reverse=sort != 'oldest')

return {'images': files}


Expand Down
33 changes: 30 additions & 3 deletions frontend/app/routes/gallery.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@


import React, { useEffect, useRef, useState } from "react";
import { deleteImage, fetchImages, fetchAlbums, uploadImage, createAlbum, addImagesToAlbum, fetchProviderAlbumImages, fetchProviderAlbums, getProviderImageStreamUrl } from "../utils/galleryApi";
import { deleteImage, fetchImages, fetchAlbums, uploadImage, createAlbum, addImagesToAlbum, fetchProviderAlbumImages, fetchProviderAlbums, getProviderImageStreamUrl, type ImageSort } from "../utils/galleryApi";
import ImageCard from "../components/imageCard";
import AlbumCard from "~/components/AlbumCard";
import ImageGrid from "~/components/imageGrid";
Expand Down Expand Up @@ -52,11 +52,13 @@ export default function Gallery() {
const [pendingFiles, setPendingFiles] = useState<File[] | null>(null);
const [dropAlbumId, setDropAlbumId] = useState("");
const [dropNewAlbumName, setDropNewAlbumName] = useState("");
const [search, setSearch] = useState("");
const [sort, setSort] = useState<ImageSort>("newest");

async function loadLocalGallery() {
setLoading(true);
try {
const [imgs, als] = await Promise.all([fetchImages(), fetchAlbums()]);
const [imgs, als] = await Promise.all([fetchImages({ q: search, sort }), fetchAlbums()]);
// Convert to GalleryImage objects
setImages(imgs.map((img: string) => ({
id: img,
Expand Down Expand Up @@ -86,8 +88,13 @@ export default function Gallery() {
}
}

// Re-query on search and sort, debounced so typing does not fire a request per key.
useEffect(() => {
const timer = setTimeout(loadLocalGallery, search ? 250 : 0);
return () => clearTimeout(timer);
}, [search, sort]);

useEffect(() => {
loadLocalGallery();
loadProviderGallery();
// Load TVs once and share with image cards to avoid per-card requests
(async () => {
Expand Down Expand Up @@ -415,6 +422,26 @@ export default function Gallery() {
</button>
)}
</div>
<div className="flex flex-wrap items-center gap-2 mb-2">
<input
type="search"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search by name…"
aria-label="Search images by name"
className="border px-2 py-2 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-200 flex-1 min-w-48"
/>
<select
value={sort}
onChange={e => setSort(e.target.value as ImageSort)}
aria-label="Sort images"
className="border px-2 py-2 rounded text-sm focus:outline-none focus:ring-2 focus:ring-blue-200"
>
<option value="newest">Newest first</option>
<option value="oldest">Oldest first</option>
<option value="name">By name</option>
</select>
</div>
<p className="text-sm text-muted-foreground mb-2">Tick the boxes, or shift-click, to move or delete several images at once.</p>
{loading ? (
<div>Loading...</div>
Expand Down
11 changes: 9 additions & 2 deletions frontend/app/utils/galleryApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,15 @@ export async function fetchProviderAlbumImages(albumId: string) {
export function getProviderImageStreamUrl(imageId: string, size: string = "fullsize") {
return `${API_BASE}/api/provider/images/${encodeURIComponent(imageId)}/stream?size=${encodeURIComponent(size)}`;
}
export async function fetchImages() {
const res = await fetch(`${API_BASE}/api/images`);
export type ImageSort = 'newest' | 'oldest' | 'name';

export async function fetchImages(options: { q?: string; sort?: ImageSort } = {}) {
const params = new URLSearchParams();
if (options.q) params.set('q', options.q);
if (options.sort && options.sort !== 'newest') params.set('sort', options.sort);
const query = params.toString();

const res = await fetch(`${API_BASE}/api/images${query ? `?${query}` : ''}`);
if (!res.ok) throw new Error('Failed to fetch images');
return (await res.json()).images;
}
Expand Down
72 changes: 72 additions & 0 deletions tests/test_gallery_listing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Covers the order of the gallery listing and its name filter.

Run with: pytest tests/test_gallery_listing.py
"""

import io
import os
from datetime import datetime, timedelta

import pytest
from PIL import Image as PILImage

import app as backend


@pytest.fixture
def client():
backend.app.config["TESTING"] = True
with backend.app.app_context():
backend.db.drop_all()
backend.db.create_all()
for name in os.listdir(backend.app.config["UPLOAD_FOLDER"]):
path = os.path.join(backend.app.config["UPLOAD_FOLDER"], name)
if os.path.isfile(path):
os.remove(path)
return backend.app.test_client()


def upload(client, name):
buf = io.BytesIO()
PILImage.new("RGB", (60, 40), "red").save(buf, format="PNG")
buf.seek(0)
return client.post(
"/api/upload",
data={"file": (buf, name)},
content_type="multipart/form-data",
)


def test_images_are_listed_newest_first(client):
for name in ("first.png", "second.png", "third.png"):
upload(client, name)
with backend.app.app_context():
rows = {img.filename: img for img in backend.Image.query.all()}
base = datetime(2026, 1, 1)
rows["first.png"].created_at = base
rows["second.png"].created_at = base + timedelta(days=1)
rows["third.png"].created_at = base + timedelta(days=2)
backend.db.session.commit()

assert client.get("/api/images").get_json()["images"] == ["third.png", "second.png", "first.png"]
assert client.get("/api/images?sort=oldest").get_json()["images"] == ["first.png", "second.png", "third.png"]
assert client.get("/api/images?sort=name").get_json()["images"] == ["first.png", "second.png", "third.png"]


def test_a_file_without_a_row_is_ordered_by_its_own_timestamp(client):
"""Images that predate the database still have to land somewhere sensible."""
upload(client, "known.png")
orphan = os.path.join(backend.app.config["UPLOAD_FOLDER"], "orphan.png")
PILImage.new("RGB", (60, 40), "blue").save(orphan)
os.utime(orphan, (0, 0)) # as old as it gets

listing = client.get("/api/images").get_json()["images"]
assert set(listing) == {"known.png", "orphan.png"}
assert listing[-1] == "orphan.png", "the oldest file should come last"


def test_the_listing_can_be_filtered_by_name(client):
upload(client, "sunset.png")
upload(client, "portrait.png")
assert client.get("/api/images?q=SUN").get_json()["images"] == ["sunset.png"]
assert client.get("/api/images?q=nothing").get_json()["images"] == []