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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
+306
-275
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user