Loading debian/changelog +18 −0 Original line number Diff line number Diff line mediadb (20260423+70) unstable; urgency=medium * Cluster data consistency: tag media listings with availability flag (cluster_available) so the frontend can indicate when media data is not yet replicated to the cluster. * Increase cluster fetch/fetch_range deadline from 10s to 30s to avoid premature timeouts on congested networks. * Return HTTP 503 with Retry-After header instead of 422/500 when preview rendering or media data fetch fails due to cluster timeouts or queue pressure — signals clients to retry. * Frontend: throttle preview fetches to 6 concurrent requests, fixing NS_BINDING_ABORTED in Firefox. Add AbortController for cancellation and automatic 503 retry with 2s backoff. * Frontend: dim unavailable media items in admin table with warning indicator. -- Jan Koester <jan.koester@tuxist.de> Wed, 23 Apr 2026 00:00:00 +0200 mediadb (20260423+69) unstable; urgency=high * Fix tombstone infinite loop: load_tombstones() now REPLACES the local Loading html/admin.html +32 −9 Original line number Diff line number Diff line Loading @@ -32,6 +32,8 @@ tr:hover{background:#1e1e1e} .preview-cell{display:flex;flex-direction:column;align-items:flex-start;gap:.4rem} .preview-cell img{max-width:120px;max-height:90px;border-radius:6px;border:1px solid #333;cursor:pointer} .preview-cell img:hover{border-color:#4fc3f7} tr.media-unavailable{opacity:.5} tr.media-unavailable td:first-child::after{content:" \u26a0";color:#ff9800;font-size:.7rem} .preview-controls{display:flex;align-items:center;gap:.4rem;font-size:.75rem;color:#aaa} .preview-controls input[type=range]{width:100px;accent-color:#4fc3f7} .preview-controls select{padding:.15rem .3rem;background:#222;color:#eee;border:1px solid #444;border-radius:4px;font-size:.75rem} Loading Loading @@ -178,18 +180,38 @@ function fmtSize(b) { return (b / 1048576).toFixed(1) + ' MB'; } // ---- Preview loader (synchronous) ---- function loadPreviewAsync(img, mediaId, fmt, t, width) { const w = width || 120; const url = `/media/${mediaId}/preview?w=${w}&fmt=${encodeURIComponent(fmt)}&t=${t}`; fetch(url, {credentials:'same-origin'}).then(r => { // ---- Preview loader with throttling ---- const previewQueue = []; let previewActive = 0; const MAX_CONCURRENT_PREVIEWS = 6; function drainPreviewQueue() { while (previewActive < MAX_CONCURRENT_PREVIEWS && previewQueue.length > 0) { const job = previewQueue.shift(); previewActive++; const ctrl = new AbortController(); job.img._previewAbort = ctrl; fetch(job.url, {credentials:'same-origin', signal: ctrl.signal}).then(r => { if (r.status === 200) { return r.blob().then(blob => { img.src = URL.createObjectURL(blob); img.style.opacity = ''; job.img.src = URL.createObjectURL(blob); job.img.style.opacity = ''; }); } else if (r.status === 503) { // Server busy — re-queue with delay setTimeout(() => { previewQueue.push(job); drainPreviewQueue(); }, 2000); } }).catch(() => {}).finally(() => { previewActive--; drainPreviewQueue(); }); } } }).catch(() => {}); function loadPreviewAsync(img, mediaId, fmt, t, width) { // Cancel any previous in-flight request for this image if (img._previewAbort) { img._previewAbort.abort(); img._previewAbort = null; } const w = width || 120; const url = `/media/${mediaId}/preview?w=${w}&fmt=${encodeURIComponent(fmt)}&t=${t}`; previewQueue.push({img, url}); drainPreviewQueue(); } // ---- Stores ---- Loading Loading @@ -465,6 +487,7 @@ async function showMedia(albumName) { } tr.innerHTML = `<td style="font-family:monospace;font-size:.78rem;color:#888">${m.id}</td>${previewHtml}<td>${fname}</td><td>${m.kind}</td><td>${fmtSize(m.size_bytes)}</td><td>${m.created_at}</td> <td><button class="btn btn-danger btn-sm del-media" data-id="${m.id}">Delete</button></td>`; if (m.available === false) tr.className = 'media-unavailable'; tb.appendChild(tr); }); tb.querySelectorAll('.del-media').forEach(b => b.addEventListener('click', async () => { Loading html/gallery.html +28 −7 Original line number Diff line number Diff line Loading @@ -50,6 +50,33 @@ nav a{color:#4fc3f7;text-decoration:none;margin-right:1rem} <script> const API = ''; // ---- Preview fetch throttling ---- const galQueue = []; let galActive = 0; const GAL_MAX_CONCURRENT = 6; function galDrain() { while (galActive < GAL_MAX_CONCURRENT && galQueue.length > 0) { const job = galQueue.shift(); galActive++; const ctrl = new AbortController(); fetch(job.url, {signal: ctrl.signal}).then(r => { if (r.ok) return r.blob().then(blob => { job.img.src = URL.createObjectURL(blob); job.img.style.opacity = ''; }); if (r.status === 503) { setTimeout(() => { galQueue.push(job); galDrain(); }, 2000); } }).catch(() => {}).finally(() => { galActive--; galDrain(); }); } } function galLoadPreview(img, url) { galQueue.push({img, url}); galDrain(); } async function loadPublicAlbums() { try { const res = await fetch(API + '/public/albums'); Loading Loading @@ -98,13 +125,7 @@ async function openAlbum(albumId, albumName) { img.style.opacity = '0.3'; img.src = ''; item.appendChild(img); // load preview fetch(`/media/${m.id}/preview?w=240&fmt=webp`).then(r => { if (r.ok) return r.blob().then(blob => { img.src = URL.createObjectURL(blob); img.style.opacity = ''; }); }).catch(() => {}); galLoadPreview(img, `/media/${m.id}/preview?w=240&fmt=webp`); } else { const placeholder = document.createElement('div'); placeholder.style.cssText = 'width:100%;height:140px;display:flex;align-items:center;justify-content:center;background:#222;color:#666;font-size:2rem'; Loading src/app.cpp +22 −3 Original line number Diff line number Diff line Loading @@ -372,6 +372,7 @@ json_object* App::media_to_json(const MediaRecord& m) { json_object_object_add(j, "store_id", json_object_new_string(m.store_id.c_str())); json_object_object_add(j, "created_at", json_object_new_string(m.created_at.c_str())); json_object_object_add(j, "size_bytes", json_object_new_double(static_cast<double>(m.size_bytes))); json_object_object_add(j, "available", json_object_new_boolean(m.cluster_available)); return j; } Loading Loading @@ -1608,7 +1609,11 @@ HttpResponse App::handle_get_media_raw(const HttpRequest& req) { const std::uint64_t length = range_end - range_start + 1; auto data = db_.read_media_data_range(media_id, range_start, length); if (data.empty()) return error_json(500, "could not read media data range"); if (data.empty()) { auto resp = error_json(503, "media data temporarily unavailable"); resp.headers["Retry-After"] = "5"; return resp; } // Speculative prefetch: fire-and-forget fetch of the next 2 chunks // so sequential playback hits the cache. Loading Loading @@ -1655,7 +1660,11 @@ HttpResponse App::handle_get_media_raw(const HttpRequest& req) { for (std::uint64_t off = 0; off < total_size; off += CHUNK) { std::uint64_t len = std::min(CHUNK, total_size - off); auto chunk = db_.read_media_data_range(media_id, off, len); if (chunk.empty()) return error_json(500, "could not read media data"); if (chunk.empty()) { auto resp = error_json(503, "media data temporarily unavailable"); resp.headers["Retry-After"] = "5"; return resp; } full_data.insert(full_data.end(), chunk.begin(), chunk.end()); } Loading Loading @@ -1725,7 +1734,17 @@ HttpResponse App::handle_preview(const HttpRequest& req) { } catch (...) { return error_json(500, "preview render crashed"); } if (!preview) return error_json(422, error.empty() ? "preview failed" : error); if (!preview) { // Cluster data temporarily unavailable — signal client to retry if (error.find("timed out") != std::string::npos || error.find("queue full") != std::string::npos || error.find("in-flight") != std::string::npos) { auto resp = error_json(503, error); resp.headers["Retry-After"] = "5"; return resp; } return error_json(422, error.empty() ? "preview failed" : error); } // If-None-Match for freshly generated preview auto inm = req.headers.find("if-none-match"); Loading src/backend.cpp +16 −1 Original line number Diff line number Diff line Loading @@ -2823,7 +2823,22 @@ std::optional<MediaRecord> ClusterMediaBackend::get_media(const std::string& id) std::vector<MediaRecord> ClusterMediaBackend::list_media(const std::string& album_id) const { std::shared_lock<std::shared_mutex> cguard(cluster_op_mutex_); return local_.list_media(album_id); auto items = local_.list_media(album_id); if (!cluster_.isRunning()) return items; // Tag each media record with cluster availability using cached peer group data. auto peer_groups = cluster_.list_peer_groups(); std::unordered_set<uint64_t> known_gids; for (const auto& pg : peer_groups) { if (pg.online) { known_gids.insert(pg.groups.begin(), pg.groups.end()); } } for (auto& m : items) { uint64_t gid = cluster_group_id("media:" + m.id); m.cluster_available = (known_gids.count(gid) > 0) || !m.pending_data.empty(); } return items; } bool ClusterMediaBackend::delete_media(const std::string& id) { Loading Loading
debian/changelog +18 −0 Original line number Diff line number Diff line mediadb (20260423+70) unstable; urgency=medium * Cluster data consistency: tag media listings with availability flag (cluster_available) so the frontend can indicate when media data is not yet replicated to the cluster. * Increase cluster fetch/fetch_range deadline from 10s to 30s to avoid premature timeouts on congested networks. * Return HTTP 503 with Retry-After header instead of 422/500 when preview rendering or media data fetch fails due to cluster timeouts or queue pressure — signals clients to retry. * Frontend: throttle preview fetches to 6 concurrent requests, fixing NS_BINDING_ABORTED in Firefox. Add AbortController for cancellation and automatic 503 retry with 2s backoff. * Frontend: dim unavailable media items in admin table with warning indicator. -- Jan Koester <jan.koester@tuxist.de> Wed, 23 Apr 2026 00:00:00 +0200 mediadb (20260423+69) unstable; urgency=high * Fix tombstone infinite loop: load_tombstones() now REPLACES the local Loading
html/admin.html +32 −9 Original line number Diff line number Diff line Loading @@ -32,6 +32,8 @@ tr:hover{background:#1e1e1e} .preview-cell{display:flex;flex-direction:column;align-items:flex-start;gap:.4rem} .preview-cell img{max-width:120px;max-height:90px;border-radius:6px;border:1px solid #333;cursor:pointer} .preview-cell img:hover{border-color:#4fc3f7} tr.media-unavailable{opacity:.5} tr.media-unavailable td:first-child::after{content:" \u26a0";color:#ff9800;font-size:.7rem} .preview-controls{display:flex;align-items:center;gap:.4rem;font-size:.75rem;color:#aaa} .preview-controls input[type=range]{width:100px;accent-color:#4fc3f7} .preview-controls select{padding:.15rem .3rem;background:#222;color:#eee;border:1px solid #444;border-radius:4px;font-size:.75rem} Loading Loading @@ -178,18 +180,38 @@ function fmtSize(b) { return (b / 1048576).toFixed(1) + ' MB'; } // ---- Preview loader (synchronous) ---- function loadPreviewAsync(img, mediaId, fmt, t, width) { const w = width || 120; const url = `/media/${mediaId}/preview?w=${w}&fmt=${encodeURIComponent(fmt)}&t=${t}`; fetch(url, {credentials:'same-origin'}).then(r => { // ---- Preview loader with throttling ---- const previewQueue = []; let previewActive = 0; const MAX_CONCURRENT_PREVIEWS = 6; function drainPreviewQueue() { while (previewActive < MAX_CONCURRENT_PREVIEWS && previewQueue.length > 0) { const job = previewQueue.shift(); previewActive++; const ctrl = new AbortController(); job.img._previewAbort = ctrl; fetch(job.url, {credentials:'same-origin', signal: ctrl.signal}).then(r => { if (r.status === 200) { return r.blob().then(blob => { img.src = URL.createObjectURL(blob); img.style.opacity = ''; job.img.src = URL.createObjectURL(blob); job.img.style.opacity = ''; }); } else if (r.status === 503) { // Server busy — re-queue with delay setTimeout(() => { previewQueue.push(job); drainPreviewQueue(); }, 2000); } }).catch(() => {}).finally(() => { previewActive--; drainPreviewQueue(); }); } } }).catch(() => {}); function loadPreviewAsync(img, mediaId, fmt, t, width) { // Cancel any previous in-flight request for this image if (img._previewAbort) { img._previewAbort.abort(); img._previewAbort = null; } const w = width || 120; const url = `/media/${mediaId}/preview?w=${w}&fmt=${encodeURIComponent(fmt)}&t=${t}`; previewQueue.push({img, url}); drainPreviewQueue(); } // ---- Stores ---- Loading Loading @@ -465,6 +487,7 @@ async function showMedia(albumName) { } tr.innerHTML = `<td style="font-family:monospace;font-size:.78rem;color:#888">${m.id}</td>${previewHtml}<td>${fname}</td><td>${m.kind}</td><td>${fmtSize(m.size_bytes)}</td><td>${m.created_at}</td> <td><button class="btn btn-danger btn-sm del-media" data-id="${m.id}">Delete</button></td>`; if (m.available === false) tr.className = 'media-unavailable'; tb.appendChild(tr); }); tb.querySelectorAll('.del-media').forEach(b => b.addEventListener('click', async () => { Loading
html/gallery.html +28 −7 Original line number Diff line number Diff line Loading @@ -50,6 +50,33 @@ nav a{color:#4fc3f7;text-decoration:none;margin-right:1rem} <script> const API = ''; // ---- Preview fetch throttling ---- const galQueue = []; let galActive = 0; const GAL_MAX_CONCURRENT = 6; function galDrain() { while (galActive < GAL_MAX_CONCURRENT && galQueue.length > 0) { const job = galQueue.shift(); galActive++; const ctrl = new AbortController(); fetch(job.url, {signal: ctrl.signal}).then(r => { if (r.ok) return r.blob().then(blob => { job.img.src = URL.createObjectURL(blob); job.img.style.opacity = ''; }); if (r.status === 503) { setTimeout(() => { galQueue.push(job); galDrain(); }, 2000); } }).catch(() => {}).finally(() => { galActive--; galDrain(); }); } } function galLoadPreview(img, url) { galQueue.push({img, url}); galDrain(); } async function loadPublicAlbums() { try { const res = await fetch(API + '/public/albums'); Loading Loading @@ -98,13 +125,7 @@ async function openAlbum(albumId, albumName) { img.style.opacity = '0.3'; img.src = ''; item.appendChild(img); // load preview fetch(`/media/${m.id}/preview?w=240&fmt=webp`).then(r => { if (r.ok) return r.blob().then(blob => { img.src = URL.createObjectURL(blob); img.style.opacity = ''; }); }).catch(() => {}); galLoadPreview(img, `/media/${m.id}/preview?w=240&fmt=webp`); } else { const placeholder = document.createElement('div'); placeholder.style.cssText = 'width:100%;height:140px;display:flex;align-items:center;justify-content:center;background:#222;color:#666;font-size:2rem'; Loading
src/app.cpp +22 −3 Original line number Diff line number Diff line Loading @@ -372,6 +372,7 @@ json_object* App::media_to_json(const MediaRecord& m) { json_object_object_add(j, "store_id", json_object_new_string(m.store_id.c_str())); json_object_object_add(j, "created_at", json_object_new_string(m.created_at.c_str())); json_object_object_add(j, "size_bytes", json_object_new_double(static_cast<double>(m.size_bytes))); json_object_object_add(j, "available", json_object_new_boolean(m.cluster_available)); return j; } Loading Loading @@ -1608,7 +1609,11 @@ HttpResponse App::handle_get_media_raw(const HttpRequest& req) { const std::uint64_t length = range_end - range_start + 1; auto data = db_.read_media_data_range(media_id, range_start, length); if (data.empty()) return error_json(500, "could not read media data range"); if (data.empty()) { auto resp = error_json(503, "media data temporarily unavailable"); resp.headers["Retry-After"] = "5"; return resp; } // Speculative prefetch: fire-and-forget fetch of the next 2 chunks // so sequential playback hits the cache. Loading Loading @@ -1655,7 +1660,11 @@ HttpResponse App::handle_get_media_raw(const HttpRequest& req) { for (std::uint64_t off = 0; off < total_size; off += CHUNK) { std::uint64_t len = std::min(CHUNK, total_size - off); auto chunk = db_.read_media_data_range(media_id, off, len); if (chunk.empty()) return error_json(500, "could not read media data"); if (chunk.empty()) { auto resp = error_json(503, "media data temporarily unavailable"); resp.headers["Retry-After"] = "5"; return resp; } full_data.insert(full_data.end(), chunk.begin(), chunk.end()); } Loading Loading @@ -1725,7 +1734,17 @@ HttpResponse App::handle_preview(const HttpRequest& req) { } catch (...) { return error_json(500, "preview render crashed"); } if (!preview) return error_json(422, error.empty() ? "preview failed" : error); if (!preview) { // Cluster data temporarily unavailable — signal client to retry if (error.find("timed out") != std::string::npos || error.find("queue full") != std::string::npos || error.find("in-flight") != std::string::npos) { auto resp = error_json(503, error); resp.headers["Retry-After"] = "5"; return resp; } return error_json(422, error.empty() ? "preview failed" : error); } // If-None-Match for freshly generated preview auto inm = req.headers.find("if-none-match"); Loading
src/backend.cpp +16 −1 Original line number Diff line number Diff line Loading @@ -2823,7 +2823,22 @@ std::optional<MediaRecord> ClusterMediaBackend::get_media(const std::string& id) std::vector<MediaRecord> ClusterMediaBackend::list_media(const std::string& album_id) const { std::shared_lock<std::shared_mutex> cguard(cluster_op_mutex_); return local_.list_media(album_id); auto items = local_.list_media(album_id); if (!cluster_.isRunning()) return items; // Tag each media record with cluster availability using cached peer group data. auto peer_groups = cluster_.list_peer_groups(); std::unordered_set<uint64_t> known_gids; for (const auto& pg : peer_groups) { if (pg.online) { known_gids.insert(pg.groups.begin(), pg.groups.end()); } } for (auto& m : items) { uint64_t gid = cluster_group_id("media:" + m.id); m.cluster_available = (known_gids.count(gid) > 0) || !m.pending_data.empty(); } return items; } bool ClusterMediaBackend::delete_media(const std::string& id) { Loading