feat(admin): purge-archived endpoint with an archived-items banner

Bulk escape hatch for archived rows (files missing from disk for 2+
scans) so a mass external deletion never has to wait out the retention
window or be clicked away row by row:

- POST /api/media-items/purge-archived (admin only) hard-deletes all
  archived items and returns the purged count; reading history goes with
  the rows, so the call is confirmed in the UI first.
- The library admin page shows an 'Archived items: N' card (only when
  non-zero) with a Purge Archived Now button that calls the endpoint,
  toasts the result, and reloads.
- Frontend admin JS exposes window.purgeArchivedItems following the
  existing localStorage-bearer-token pattern.
This commit is contained in:
John O'Keefe
2026-09-12 17:50:24 -04:00
parent 14445a7c3f
commit cd119a74da
6 changed files with 388 additions and 277 deletions
+21
View File
@@ -1330,6 +1330,27 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
return c.JSON(http.StatusOK, item)
}
// PurgeArchivedMediaItems handles POST /api/media-items/purge-archived
// (admin only). Hard-deletes every archived item (files missing from disk for
// 2+ scans) together with its reading history. The archive retention window
// eventually does the same automatically; this is the manual bulk escape hatch.
func (mh *MediaHandler) PurgeArchivedMediaItems(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
if user.Role != "admin" {
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
}
purged, err := mh.db.PurgeAllArchivedMediaItems(c.Request().Context())
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return c.JSON(http.StatusOK, map[string]interface{}{
"purged": len(purged),
})
}
// RescanMediaItem handles POST /api/media-items/:id/rescan (admin only)
func (mh *MediaHandler) RescanMediaItem(c *echo.Context) error {
user := MustGetAuthenticatedUser(c)
+2 -1
View File
@@ -884,7 +884,8 @@ func registerFrontendRoutes(cfg *Config) {
}
var buf bytes.Buffer
err = templates.AdminLibrary(user, libData, userData).Render(c.Request().Context(), &buf)
archivedCount, _ := cfg.Queries.CountArchivedMediaItems(c.Request().Context())
err = templates.AdminLibrary(user, libData, userData, archivedCount).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
+1
View File
@@ -62,6 +62,7 @@ func registerMediaRoutes(cfg *Config) {
admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem)
admin.POST("/media-items/:id/rescan", cfg.MediaHandler.RescanMediaItem)
admin.DELETE("/media-items/:id", cfg.MediaHandler.DeleteMediaItem)
admin.POST("/media-items/purge-archived", cfg.MediaHandler.PurgeArchivedMediaItems)
// Shelf management (protected)
protected.POST("/devices/:id/shelves", cfg.MediaHandler.AddToShelf)
+18 -1
View File
@@ -1,6 +1,6 @@
package templates
templ AdminLibrary(user User, libraries []LibraryData, users []User) {
templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCount int64) {
<!DOCTYPE html>
<html lang="en">
<head>
@@ -34,6 +34,23 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
</button>
</div>
</div>
if archivedCount > 0 {
<div class="card p-4 mb-6 flex items-center justify-between gap-4 flex-wrap">
<div>
<p class="font-semibold" style="color: var(--text-primary)">
Archived items: { archivedCount }
</p>
<p class="text-sm" style="color: var(--text-secondary)">
Files missing from disk for two consecutive scans. Reading history is kept
unless purged; items return automatically if their files come back.
</p>
</div>
<button type="button" onclick="purgeArchivedItems()" class="btn btn-secondary">
@Icon("trash", "h-4 w-4")
Purge Archived Now
</button>
</div>
}
<div id="libraries-container">
@LibraryList(user, libraries, users)
</div>
File diff suppressed because it is too large Load Diff
+40
View File
@@ -249,6 +249,46 @@ function stopScanStatusPolling(): void {
}
}
// Purge all archived items (files missing from disk for 2+ scans). Exposed on
// window for the library admin page's inline button; reading history is
// deleted with the rows, so a confirm dialog guards it.
async function purgeArchivedItems(): Promise<void> {
const token = localStorage.getItem("token");
if (!token) return;
if (
!window.confirm(
"Permanently delete all archived items? Their reading progress, notes, and highlights will be lost.",
)
) {
return;
}
try {
const resp = await fetch("/api/media-items/purge-archived", {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.error || "Failed to purge archived items");
}
const data = await resp.json();
showToast(
`Purged ${data.purged} archived item${data.purged === 1 ? "" : "s"}`,
"success",
);
setTimeout(() => window.location.reload(), 700);
} catch (e) {
showToast(
e instanceof Error ? e.message : "Failed to purge archived items",
"error",
);
}
}
(window as any).purgeArchivedItems = purgeArchivedItems;
export {
hideScanProgress,
loadWatchStatus,