feat(admin): all-podcasts view — disk-first maintenance for every feed
§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.
This commit is contained in:
1 parent
b293741eb1
commit
0741e98ea5
12 files changed
+1191
-47
No files matched your search
@@ -602,6 +602,12 @@ type Querier interface {
|
||||
// Link unlinked book to media item
|
||||
LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookParams) (UnlinkedBooks, error)
|
||||
ListAllConflictsByUserAndStatus(ctx context.Context, arg ListAllConflictsByUserAndStatusParams) ([]ListAllConflictsByUserAndStatusRow, error)
|
||||
// Admin all-podcasts view (§0 item 8 / Phase-4 item 1): EVERY feed from
|
||||
// EVERY user with its maintenance aggregates. Runs under the first-
|
||||
// admin identity (invariant 12 — same contract as the poller). Sorted
|
||||
// by disk usage DESC: the maintenance question is "what's eating the
|
||||
// disk", so the hogs surface first.
|
||||
ListAllPodcastFeedsAdmin(ctx context.Context) ([]ListAllPodcastFeedsAdminRow, error)
|
||||
ListAllSyncQueueItems(ctx context.Context, arg ListAllSyncQueueItemsParams) ([]ListAllSyncQueueItemsRow, error)
|
||||
ListConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListConflictsByUserRow, error)
|
||||
// ============================================
|
||||
@@ -721,6 +727,17 @@ type Querier interface {
|
||||
ReorderCollectionItems(ctx context.Context, arg ReorderCollectionItemsParams) error
|
||||
// Re-parent all child rows of p_source onto p_target (defined in schema.sql)
|
||||
ReparentMediaItemChildren(ctx context.Context, arg ReparentMediaItemChildrenParams) error
|
||||
// Admin file deletion (§0 item 8): ONE downloaded episode's disk file
|
||||
// was removed — reset the row to pending and clear the download-derived
|
||||
// facts. file_path is deliberately KEPT: it is the fetch-maintained
|
||||
// layout (written at fetch time, not download time), so a re-download
|
||||
// lands on the same path. The poll upsert never touches download_state,
|
||||
// so a deleted file stays deleted (invariant 2: polling never deletes;
|
||||
// admins delete knowingly).
|
||||
ResetPodcastEpisodeFileDeleted(ctx context.Context, id pgtype.UUID) error
|
||||
// Same reset for EVERY downloaded episode of a feed (per-feed
|
||||
// "Delete files" action on the admin view).
|
||||
ResetPodcastFeedFilesDeleted(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error)
|
||||
ResetSystemCollectionMetadata(ctx context.Context, arg ResetSystemCollectionMetadataParams) error
|
||||
ResolveHashConflict(ctx context.Context, arg ResolveHashConflictParams) error
|
||||
// Ledger self-heal (faceted plan Phase 11): a scan that processes the
|
||||
|
||||
@@ -12564,6 +12564,79 @@ func (q *Queries) ListAllConflictsByUserAndStatus(ctx context.Context, arg ListA
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ListAllPodcastFeedsAdmin = `-- name: ListAllPodcastFeedsAdmin :many
|
||||
SELECT f.id, f.title, f.host, f.last_fetched_at, f.library_id,
|
||||
l.name AS library_name,
|
||||
(SELECT COUNT(*) FROM media_items m
|
||||
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode') AS episode_total,
|
||||
(SELECT COUNT(*) FROM media_items m
|
||||
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode'
|
||||
AND m.download_state = 'downloaded') AS episode_downloaded,
|
||||
(SELECT COALESCE(SUM(m.file_size), 0)::BIGINT FROM media_items m
|
||||
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode'
|
||||
AND m.download_state = 'downloaded') AS disk_bytes,
|
||||
(SELECT COUNT(*) FROM podcast_subscriptions s
|
||||
WHERE s.feed_id = f.id) AS subscriber_count,
|
||||
(SELECT COALESCE(string_agg(u.username, ', ' ORDER BY u.username), '')::TEXT
|
||||
FROM podcast_subscriptions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.feed_id = f.id) AS subscriber_names
|
||||
FROM podcast_feeds f
|
||||
JOIN libraries l ON l.id = f.library_id
|
||||
ORDER BY 9 DESC, f.title ASC
|
||||
`
|
||||
|
||||
type ListAllPodcastFeedsAdminRow struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Host pgtype.Text `db:"host" json:"host"`
|
||||
LastFetchedAt pgtype.Timestamptz `db:"last_fetched_at" json:"last_fetched_at"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
LibraryName string `db:"library_name" json:"library_name"`
|
||||
EpisodeTotal int64 `db:"episode_total" json:"episode_total"`
|
||||
EpisodeDownloaded int64 `db:"episode_downloaded" json:"episode_downloaded"`
|
||||
DiskBytes int64 `db:"disk_bytes" json:"disk_bytes"`
|
||||
SubscriberCount int64 `db:"subscriber_count" json:"subscriber_count"`
|
||||
SubscriberNames string `db:"subscriber_names" json:"subscriber_names"`
|
||||
}
|
||||
|
||||
// Admin all-podcasts view (§0 item 8 / Phase-4 item 1): EVERY feed from
|
||||
// EVERY user with its maintenance aggregates. Runs under the first-
|
||||
// admin identity (invariant 12 — same contract as the poller). Sorted
|
||||
// by disk usage DESC: the maintenance question is "what's eating the
|
||||
// disk", so the hogs surface first.
|
||||
func (q *Queries) ListAllPodcastFeedsAdmin(ctx context.Context) ([]ListAllPodcastFeedsAdminRow, error) {
|
||||
rows, err := q.db.Query(ctx, ListAllPodcastFeedsAdmin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListAllPodcastFeedsAdminRow{}
|
||||
for rows.Next() {
|
||||
var i ListAllPodcastFeedsAdminRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Title,
|
||||
&i.Host,
|
||||
&i.LastFetchedAt,
|
||||
&i.LibraryID,
|
||||
&i.LibraryName,
|
||||
&i.EpisodeTotal,
|
||||
&i.EpisodeDownloaded,
|
||||
&i.DiskBytes,
|
||||
&i.SubscriberCount,
|
||||
&i.SubscriberNames,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const ListAllSyncQueueItems = `-- name: ListAllSyncQueueItems :many
|
||||
SELECT
|
||||
sq.id, sq.device_id, sq.media_item_id, sq.sync_type, sq.sync_data, sq.priority, sq.attempts, sq.max_attempts, sq.status, sq.error_message, sq.created_at, sq.processed_at,
|
||||
@@ -15924,6 +15997,57 @@ func (q *Queries) ReparentMediaItemChildren(ctx context.Context, arg ReparentMed
|
||||
return err
|
||||
}
|
||||
|
||||
const ResetPodcastEpisodeFileDeleted = `-- name: ResetPodcastEpisodeFileDeleted :exec
|
||||
UPDATE media_items
|
||||
SET download_state = 'pending',
|
||||
download_error = NULL,
|
||||
download_attempts = 0,
|
||||
file_size = NULL,
|
||||
mime_type = NULL,
|
||||
cover_image_path = NULL,
|
||||
audio_metadata = NULL,
|
||||
chapter_count = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1 AND source = 'podcast_episode' AND download_state = 'downloaded'
|
||||
`
|
||||
|
||||
// Admin file deletion (§0 item 8): ONE downloaded episode's disk file
|
||||
// was removed — reset the row to pending and clear the download-derived
|
||||
// facts. file_path is deliberately KEPT: it is the fetch-maintained
|
||||
// layout (written at fetch time, not download time), so a re-download
|
||||
// lands on the same path. The poll upsert never touches download_state,
|
||||
// so a deleted file stays deleted (invariant 2: polling never deletes;
|
||||
// admins delete knowingly).
|
||||
func (q *Queries) ResetPodcastEpisodeFileDeleted(ctx context.Context, id pgtype.UUID) error {
|
||||
_, err := q.db.Exec(ctx, ResetPodcastEpisodeFileDeleted, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const ResetPodcastFeedFilesDeleted = `-- name: ResetPodcastFeedFilesDeleted :execrows
|
||||
UPDATE media_items
|
||||
SET download_state = 'pending',
|
||||
download_error = NULL,
|
||||
download_attempts = 0,
|
||||
file_size = NULL,
|
||||
mime_type = NULL,
|
||||
cover_image_path = NULL,
|
||||
audio_metadata = NULL,
|
||||
chapter_count = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE source_feed_id = $1 AND source = 'podcast_episode'
|
||||
AND download_state = 'downloaded'
|
||||
`
|
||||
|
||||
// Same reset for EVERY downloaded episode of a feed (per-feed
|
||||
// "Delete files" action on the admin view).
|
||||
func (q *Queries) ResetPodcastFeedFilesDeleted(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error) {
|
||||
result, err := q.db.Exec(ctx, ResetPodcastFeedFilesDeleted, sourceFeedID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected(), nil
|
||||
}
|
||||
|
||||
const ResetSystemCollectionMetadata = `-- name: ResetSystemCollectionMetadata :exec
|
||||
UPDATE collections
|
||||
SET description = $3,
|
||||
|
||||
@@ -4698,6 +4698,68 @@ WHERE feed_id = $1;
|
||||
-- name: CountPodcastFeedSubscribers :one
|
||||
SELECT COUNT(*) FROM podcast_subscriptions WHERE feed_id = $1;
|
||||
|
||||
-- name: ListAllPodcastFeedsAdmin :many
|
||||
-- Admin all-podcasts view (§0 item 8 / Phase-4 item 1): EVERY feed from
|
||||
-- EVERY user with its maintenance aggregates. Runs under the first-
|
||||
-- admin identity (invariant 12 — same contract as the poller). Sorted
|
||||
-- by disk usage DESC: the maintenance question is "what's eating the
|
||||
-- disk", so the hogs surface first.
|
||||
SELECT f.id, f.title, f.host, f.last_fetched_at, f.library_id,
|
||||
l.name AS library_name,
|
||||
(SELECT COUNT(*) FROM media_items m
|
||||
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode') AS episode_total,
|
||||
(SELECT COUNT(*) FROM media_items m
|
||||
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode'
|
||||
AND m.download_state = 'downloaded') AS episode_downloaded,
|
||||
(SELECT COALESCE(SUM(m.file_size), 0)::BIGINT FROM media_items m
|
||||
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode'
|
||||
AND m.download_state = 'downloaded') AS disk_bytes,
|
||||
(SELECT COUNT(*) FROM podcast_subscriptions s
|
||||
WHERE s.feed_id = f.id) AS subscriber_count,
|
||||
(SELECT COALESCE(string_agg(u.username, ', ' ORDER BY u.username), '')::TEXT
|
||||
FROM podcast_subscriptions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.feed_id = f.id) AS subscriber_names
|
||||
FROM podcast_feeds f
|
||||
JOIN libraries l ON l.id = f.library_id
|
||||
ORDER BY 9 DESC, f.title ASC;
|
||||
|
||||
-- name: ResetPodcastEpisodeFileDeleted :exec
|
||||
-- Admin file deletion (§0 item 8): ONE downloaded episode's disk file
|
||||
-- was removed — reset the row to pending and clear the download-derived
|
||||
-- facts. file_path is deliberately KEPT: it is the fetch-maintained
|
||||
-- layout (written at fetch time, not download time), so a re-download
|
||||
-- lands on the same path. The poll upsert never touches download_state,
|
||||
-- so a deleted file stays deleted (invariant 2: polling never deletes;
|
||||
-- admins delete knowingly).
|
||||
UPDATE media_items
|
||||
SET download_state = 'pending',
|
||||
download_error = NULL,
|
||||
download_attempts = 0,
|
||||
file_size = NULL,
|
||||
mime_type = NULL,
|
||||
cover_image_path = NULL,
|
||||
audio_metadata = NULL,
|
||||
chapter_count = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1 AND source = 'podcast_episode' AND download_state = 'downloaded';
|
||||
|
||||
-- name: ResetPodcastFeedFilesDeleted :execrows
|
||||
-- Same reset for EVERY downloaded episode of a feed (per-feed
|
||||
-- "Delete files" action on the admin view).
|
||||
UPDATE media_items
|
||||
SET download_state = 'pending',
|
||||
download_error = NULL,
|
||||
download_attempts = 0,
|
||||
file_size = NULL,
|
||||
mime_type = NULL,
|
||||
cover_image_path = NULL,
|
||||
audio_metadata = NULL,
|
||||
chapter_count = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE source_feed_id = $1 AND source = 'podcast_episode'
|
||||
AND download_state = 'downloaded';
|
||||
|
||||
-- name: ListPodcastFeedsForPolling :many
|
||||
-- Poller worklist (admin identity): every feed with its validators and
|
||||
-- the back-off flag (is_complete feeds poll rarely, never disappear).
|
||||
|
||||
@@ -58,6 +58,68 @@ func (h *PodcastHandler) hxEpisodeList(c *echo.Context, feedID string, includePo
|
||||
return hxAccepted(c, "")
|
||||
}
|
||||
|
||||
// AdminDeleteFeedFiles POST /api/podcasts/feeds/:id/delete-files —
|
||||
// admin-only (§0 item 8): delete every downloaded episode file of the
|
||||
// feed; rows reset to pending. HX answers HX-Refresh (the admin table
|
||||
// re-renders with fresh aggregates).
|
||||
func (h *PodcastHandler) AdminDeleteFeedFiles(c *echo.Context) error {
|
||||
user := MustGetAuthenticatedUser(c)
|
||||
if user.Role != "admin" {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin only"})
|
||||
}
|
||||
feedID, err := parseUUID(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid feed id"})
|
||||
}
|
||||
// media_items writes run under the first-admin identity (invariant
|
||||
// 12 — same contract as the fetcher/worker).
|
||||
actx, err := h.worker.AdminCtx(c.Request().Context())
|
||||
if err != nil {
|
||||
return podcastErrorResponse(c, err)
|
||||
}
|
||||
reset, err := h.podcasts.AdminDeleteFeedFiles(actx, feedID)
|
||||
if err != nil {
|
||||
return podcastErrorResponse(c, err)
|
||||
}
|
||||
log.Printf("[PODCAST] admin: deleted files for %d episode(s) of feed %s (user %s)", reset, feedID, user.Username)
|
||||
if isHXRequest(c) {
|
||||
c.Response().Header().Set("HX-Refresh", "true")
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"reset_episodes": reset})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"reset_episodes": reset})
|
||||
}
|
||||
|
||||
// AdminDeleteEpisodeFile POST /api/podcasts/episodes/:id/delete-file —
|
||||
// admin-only (§0 item 8): ONE episode's file deleted, row reset to
|
||||
// pending. HX answers with the refreshed episode list.
|
||||
func (h *PodcastHandler) AdminDeleteEpisodeFile(c *echo.Context) error {
|
||||
user := MustGetAuthenticatedUser(c)
|
||||
if user.Role != "admin" {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin only"})
|
||||
}
|
||||
episodeID, err := parseUUID(c.Param("eid"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid episode id"})
|
||||
}
|
||||
actx, err := h.worker.AdminCtx(c.Request().Context())
|
||||
if err != nil {
|
||||
return podcastErrorResponse(c, err)
|
||||
}
|
||||
if err := h.podcasts.AdminDeleteEpisodeFile(actx, episodeID); err != nil {
|
||||
return podcastErrorResponse(c, err)
|
||||
}
|
||||
log.Printf("[PODCAST] admin: deleted file of episode %s (user %s)", episodeID, user.Username)
|
||||
if isHXRequest(c) {
|
||||
// Re-render the episode list: the row needs its fresh state. The
|
||||
// episode's feed id comes from the row we just reset.
|
||||
if ep, err := h.db.GetPodcastEpisode(actx, episodeID); err == nil && ep.SourceFeedID.Valid {
|
||||
return h.hxEpisodeList(c, ep.SourceFeedID.String(), false, false)
|
||||
}
|
||||
return hxAccepted(c, "")
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "file deleted"})
|
||||
}
|
||||
|
||||
// flipSubscriptionToAll implements issue #3: Download All (button or
|
||||
// add-feed checkbox) sets the CALLER's subscription policy to 'all' —
|
||||
// per-user, never global (files are shared D6, policies are not). The
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"bookhoard/internal/config"
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/rls"
|
||||
"bookhoard/internal/handlers"
|
||||
"bookhoard/internal/services"
|
||||
"bookhoard/internal/utils"
|
||||
@@ -1507,8 +1508,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
|
||||
// Admin hash conflicts page: content-duplicate groups flagged during hash
|
||||
// backfill or rescan, resolved by keeping all copies or merging into one.
|
||||
frontendProtected.GET("/admin/hash-conflicts", handlers.AdminMiddleware(func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
frontendProtected.GET("/admin/hash-conflicts", handlers.AdminMiddleware(func(c *echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||
}
|
||||
@@ -1562,6 +1562,62 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
}))
|
||||
|
||||
// Admin all-podcasts view (§0 item 8): every feed from every user with
|
||||
// maintenance aggregates (disk usage first). The aggregate read runs
|
||||
// under the FIRST-ADMIN identity — the RLS admin escape (invariant
|
||||
// 12, poller/fetcher contract) — so admins see feeds from libraries
|
||||
// hidden even from them.
|
||||
frontendProtected.GET("/admin/podcasts", handlers.AdminMiddleware(func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||
}
|
||||
|
||||
adminUUID, err := cfg.Queries.GetFirstAdmin(context.Background())
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading podcasts", "podcasts_load_error")
|
||||
}
|
||||
// cfg.Queries is ScopedDB-backed: the context identity is all the
|
||||
// aggregate read needs.
|
||||
actx := rls.WithUser(c.Request().Context(), adminUUID)
|
||||
|
||||
rows, err := cfg.Queries.ListAllPodcastFeedsAdmin(actx)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading podcasts", "podcasts_load_error")
|
||||
}
|
||||
|
||||
feeds := make([]templates.AdminPodcastFeedData, 0, len(rows))
|
||||
var totalBytes int64
|
||||
totalDownloaded, totalEpisodes := 0, 0
|
||||
for _, r := range rows {
|
||||
lastFetch := "never"
|
||||
if r.LastFetchedAt.Valid {
|
||||
lastFetch = r.LastFetchedAt.Time.In(utils.UserLocation(user.Timezone)).Format("Jan 2, 2006 15:04")
|
||||
}
|
||||
feeds = append(feeds, templates.AdminPodcastFeedData{
|
||||
ID: uuid.UUID(r.ID.Bytes).String(),
|
||||
Title: r.Title,
|
||||
Host: r.Host.String,
|
||||
LibraryName: r.LibraryName,
|
||||
Subscribers: int(r.SubscriberCount),
|
||||
SubscriberNames: r.SubscriberNames,
|
||||
EpisodeTotal: int(r.EpisodeTotal),
|
||||
EpisodeDownloaded: int(r.EpisodeDownloaded),
|
||||
DiskBytes: r.DiskBytes,
|
||||
LastFetched: lastFetch,
|
||||
})
|
||||
totalBytes += r.DiskBytes
|
||||
totalEpisodes += int(r.EpisodeTotal)
|
||||
totalDownloaded += int(r.EpisodeDownloaded)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := templates.AdminPodcasts(user, feeds, len(feeds), totalDownloaded, totalEpisodes, totalBytes).Render(c.Request().Context(), &buf); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
}))
|
||||
|
||||
// Admin users page
|
||||
frontendProtected.GET("/admin/users", handlers.AdminMiddleware(func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
|
||||
"bookhoard/internal/handlers"
|
||||
"bookhoard/templates"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
@@ -73,4 +74,9 @@ func registerPodcastRoutes(cfg *Config) {
|
||||
feeds.POST("/refresh", cfg.PodcastHandler.RefreshFeed)
|
||||
feeds.POST("/episodes/:eid/download", cfg.PodcastHandler.DownloadEpisodes)
|
||||
feeds.DELETE("", cfg.PodcastHandler.DeleteFeed) // admin-only (handler checks role)
|
||||
|
||||
// Admin maintenance (§0 item 8): file deletion frees the disk and
|
||||
// resets rows to pending. Handler checks admin role.
|
||||
feeds.POST("/delete-files", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteFeedFiles))
|
||||
feeds.POST("/episodes/:eid/delete-file", handlers.AdminMiddleware(cfg.PodcastHandler.AdminDeleteEpisodeFile))
|
||||
}
|
||||
@@ -542,6 +542,66 @@ func (s *PodcastService) DeleteFeedFiles(ctx context.Context, feed *database.Pod
|
||||
_ = os.Remove(filepath.Join(root, feedSlug(feed.Title, feed.ID)))
|
||||
}
|
||||
|
||||
// AdminDeleteEpisodeFile (§0 item 8): ONE downloaded episode's file is
|
||||
// removed from disk and its row reset to pending (facts cleared,
|
||||
// file_path kept — fetch-maintained layout). Runs under the first-admin
|
||||
// identity (caller supplies the admin ctx — invariant 12). The poll
|
||||
// upsert never touches download_state, so the file stays deleted.
|
||||
func (s *PodcastService) AdminDeleteEpisodeFile(ctx context.Context, episodeID pgtype.UUID) error {
|
||||
ep, err := s.db.GetPodcastEpisode(ctx, episodeID)
|
||||
if err != nil {
|
||||
return errNotFound("episode not found")
|
||||
}
|
||||
if !ep.SourceFeedID.Valid {
|
||||
return fmt.Errorf("episode has no feed reference")
|
||||
}
|
||||
if ep.DownloadState != "downloaded" {
|
||||
return nil // nothing on disk; idempotent no-op
|
||||
}
|
||||
feed, err := s.db.GetPodcastFeed(ctx, ep.SourceFeedID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("feed lookup: %w", errNotFound("feed not found"))
|
||||
}
|
||||
root, err := s.downloadRootAbs(ctx, &feed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ep.FilePath != "" {
|
||||
_ = os.Remove(filepath.Join(root, ep.FilePath))
|
||||
base := filepath.Join(root, strings.TrimSuffix(ep.FilePath, filepath.Ext(ep.FilePath)))
|
||||
_ = os.Remove(base + ".cover.jpg")
|
||||
}
|
||||
return s.db.ResetPodcastEpisodeFileDeleted(ctx, episodeID)
|
||||
}
|
||||
|
||||
// AdminDeleteFeedFiles (§0 item 8): every DOWNLOADED episode of the
|
||||
// feed loses its disk file; rows reset to pending. Returns how many
|
||||
// rows were reset. Poll upsert never touches download_state → a
|
||||
// refresh re-lists metadata but does NOT re-materialize files.
|
||||
func (s *PodcastService) AdminDeleteFeedFiles(ctx context.Context, feedID pgtype.UUID) (int64, error) {
|
||||
feed, err := s.db.GetPodcastFeed(ctx, feedID)
|
||||
if err != nil {
|
||||
return 0, errNotFound("feed not found")
|
||||
}
|
||||
eps, err := s.db.ListPodcastEpisodes(ctx, feedID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
root, err := s.downloadRootAbs(ctx, &feed)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, ep := range eps {
|
||||
if ep.DownloadState != "downloaded" || ep.FilePath == "" {
|
||||
continue
|
||||
}
|
||||
_ = os.Remove(filepath.Join(root, ep.FilePath))
|
||||
base := filepath.Join(root, strings.TrimSuffix(ep.FilePath, filepath.Ext(ep.FilePath)))
|
||||
_ = os.Remove(base + ".cover.jpg")
|
||||
}
|
||||
return s.db.ResetPodcastFeedFilesDeleted(ctx, feedID)
|
||||
}
|
||||
|
||||
// downloadRootAbs resolves the podcasts library's single download root
|
||||
// (purpose='podcast') as an absolute filesystem path.
|
||||
func (s *PodcastService) downloadRootAbs(ctx context.Context, feed *database.PodcastFeeds) (string, error) {
|
||||
|
||||
Reference in new issue
Block a user