§0 item 8 (Phase-4 item 1 pulled forward, user directive: maintenance
tooling ships WITH the features it maintains):
- /admin/podcasts (AdminMiddleware): every feed from EVERY user via the
RLS admin escape (first-admin identity, poller/fetcher contract) —
feed + host, library, subscriber count/names, downloaded/total, DISK
USAGE, last fetch. Sorted disk DESC ('what's eating the disk'),
totals header (feeds · episodes · bytes on disk).
- Per-feed actions: Refresh · Delete files (rows → pending, disk freed;
confirms spell out the re-materialization contract) · Remove feed
(rows + files for everyone — the same admin delete as the detail
page). All confirms ride the global confirm modal.
- New sqlc: ListAllPodcastFeedsAdmin (correlated aggregates),
ResetPodcastEpisodeFileDeleted / ResetPodcastFeedFilesDeleted —
downloaded rows reset to pending with download-derived facts cleared
(audio_metadata, chapter_count, cover, size, mime); file_path KEPT
(fetch-maintained layout — a re-download lands on the same path).
file deletion via the existing DeleteFeedFiles file-removal pattern
(episode files + .cover.jpg siblings).
- Poll upsert never touches download_state, so deleted files STAY
deleted — a feed refresh re-lists metadata but re-materializes
nothing (pinned by TestAdminDeleteEpisodeFiles).
- Per-episode admin delete-file: POST /feeds/:id/episodes/:eid/
delete-file (admin) — one episode's file gone, row pending, siblings
untouched; HX swaps the episode list. (Found while testing: the
handler first read c.Param("id") — the FEED's uuid — instead of
:eid; the 404 'episode not found' trail led straight to it.)
- Tests: admin sees other users' feeds + subscriber names; regular
users 403 on page AND actions; single + feed-level deletion reset
rows/clear facts/delete files; refresh leaves pending rows pending.
- Admin nav gains 'Podcasts' under Administration.
Wrong-gotcha note for future agents: parseUUID on c.Param('id') inside
a /feeds/:id/episodes/:eid route resolves the FEED, not the episode.
128 lines
5.5 KiB
Templ
128 lines
5.5 KiB
Templ
package templates
|
|
|
|
// Admin all-podcasts view (§0 item 8 / Phase-4 item 1): EVERY feed from
|
|
// EVERY user with its maintenance aggregates, sorted by disk usage.
|
|
// The maintenance surface for the podcast subsystem — files are the
|
|
// disk hogs, so the hogs surface first. Actions: Refresh (re-fetch),
|
|
// Delete files (rows → pending, disk freed), Remove feed (rows + files
|
|
// + subscriptions gone — the same admin delete the detail page offers).
|
|
|
|
// AdminPodcastFeedData is one row of the admin all-podcasts table.
|
|
type AdminPodcastFeedData struct {
|
|
ID string
|
|
Title string
|
|
Host string
|
|
LibraryName string
|
|
Subscribers int
|
|
SubscriberNames string
|
|
EpisodeTotal int
|
|
EpisodeDownloaded int
|
|
DiskBytes int64
|
|
LastFetched string
|
|
}
|
|
|
|
templ AdminPodcasts(user User, feeds []AdminPodcastFeedData, totalFeeds int, totalDownloaded int, totalEpisodes int, totalBytes int64) {
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
<title>All Podcasts - Bookhoard</title>
|
|
<link href="/static/style.css" rel="stylesheet"/>
|
|
<script type="module" src={ "/static/main.js?v=" + MainJSVersion() }></script>
|
|
</head>
|
|
<body class={ "theme-" + user.Theme }>
|
|
@Header(user, "/admin/podcasts")
|
|
<main class="container mx-auto px-4 py-8">
|
|
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
|
<h1 class="text-2xl font-bold" style="color: var(--text-primary)">All Podcasts</h1>
|
|
<div class="text-sm tabular-nums" style="color: var(--text-secondary)">
|
|
{ podcastInt(totalFeeds) } feeds · { podcastInt(totalDownloaded) }/{ podcastInt(totalEpisodes) } episodes downloaded · { FormatEpisodeSize(totalBytes) } on disk
|
|
</div>
|
|
</div>
|
|
<p class="text-sm mb-6" style="color: var(--text-secondary)">
|
|
Maintenance view: every feed from every user. "Delete files" frees the disk and
|
|
resets episodes to pending (they re-download on demand — a feed refresh will NOT
|
|
re-materialize them). "Remove feed" deletes rows, files and subscriptions for everyone.
|
|
</p>
|
|
if len(feeds) == 0 {
|
|
<div class="card p-6 text-center text-sm" style="color: var(--text-secondary)">No podcast feeds exist yet.</div>
|
|
} else {
|
|
<div class="oc-table card">
|
|
<table class="w-full text-sm">
|
|
<thead>
|
|
<tr class="text-left text-xs uppercase tracking-wide" style="color: var(--text-secondary); border-bottom: 1px solid var(--border);">
|
|
<th class="px-4 py-3">Feed</th>
|
|
<th class="px-4 py-3">Library</th>
|
|
<th class="px-4 py-3">Subscribers</th>
|
|
<th class="px-4 py-3 text-right">Downloaded</th>
|
|
<th class="px-4 py-3 text-right">Disk</th>
|
|
<th class="px-4 py-3">Last fetch</th>
|
|
<th class="px-4 py-3 text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
for _, f := range feeds {
|
|
<tr style="border-bottom: 1px solid var(--border);" id={ "admin-feed-" + f.ID }>
|
|
<td class="px-4 py-3">
|
|
<a href={ "/podcasts/" + f.ID } class="font-medium hover:underline" style="color: var(--text-primary)">{ f.Title }</a>
|
|
if f.Host != "" {
|
|
<div class="text-xs" style="color: var(--text-secondary)">{ f.Host }</div>
|
|
}
|
|
</td>
|
|
<td class="px-4 py-3" style="color: var(--text-secondary)">{ f.LibraryName }</td>
|
|
<td class="px-4 py-3">
|
|
if f.Subscribers > 0 {
|
|
<span title={ f.SubscriberNames } style="color: var(--text-primary)">{ podcastInt(f.Subscribers) }</span>
|
|
} else {
|
|
<span class="badge" style="background-color: var(--surface-secondary); color: var(--text-secondary)">orphaned</span>
|
|
}
|
|
</td>
|
|
<td class="px-4 py-3 text-right tabular-nums" style="color: var(--text-primary)">
|
|
{ podcastInt(f.EpisodeDownloaded) }/{ podcastInt(f.EpisodeTotal) }
|
|
</td>
|
|
<td class="px-4 py-3 text-right tabular-nums" style="color: var(--text-primary)">{ FormatEpisodeSize(f.DiskBytes) }</td>
|
|
<td class="px-4 py-3 text-xs" style="color: var(--text-secondary)">{ f.LastFetched }</td>
|
|
<td class="px-4 py-3">
|
|
<div class="flex justify-end gap-2">
|
|
<button
|
|
class="btn btn-secondary text-xs"
|
|
hx-post={ "/api/podcasts/feeds/" + f.ID + "/refresh" }
|
|
hx-swap="none"
|
|
title="Check the feed for new episodes now"
|
|
>
|
|
Refresh
|
|
</button>
|
|
if f.EpisodeDownloaded > 0 {
|
|
<button
|
|
class="btn btn-secondary text-xs"
|
|
hx-post={ "/api/podcasts/feeds/" + f.ID + "/delete-files" }
|
|
hx-confirm={ "Delete the " + podcastInt(f.EpisodeDownloaded) + " downloaded file(s) of \"" + f.Title + "\"? Episodes reset to pending; a feed refresh will not re-materialize them." }
|
|
hx-swap="none"
|
|
style="color: var(--status-warning, #d97706);"
|
|
>
|
|
Delete files
|
|
</button>
|
|
}
|
|
<button
|
|
class="btn btn-secondary text-xs"
|
|
hx-delete={ "/api/podcasts/feeds/" + f.ID }
|
|
hx-confirm={ "ADMIN: remove \"" + f.Title + "\" for everyone? Episode rows AND downloaded files are deleted." }
|
|
hx-swap="none"
|
|
style="color: var(--status-danger);"
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
}
|
|
</main>
|
|
</body>
|
|
</html>
|
|
}
|