diff --git a/cmd/server/tests/podcast_test.go b/cmd/server/tests/podcast_test.go
index 7cce408a..42a472fd 100644
--- a/cmd/server/tests/podcast_test.go
+++ b/cmd/server/tests/podcast_test.go
@@ -1240,3 +1240,175 @@ func TestPodcastAddModalDownloadOrder(t *testing.T) {
assert.Equal(t, int32(3), byID[tickets[0].ID], "PIN: reverse download order — position 3 claims first")
assert.Equal(t, int32(1), byID[tickets[2].ID])
}
+
+// ---- §0 item 8: admin all-podcasts view + file maintenance ----
+
+func TestAdminPodcastsView(t *testing.T) {
+ setup := setupTestServer(t)
+ lib, _ := createPodcastsLibrary(t, setup, "test AdminView Podcasts")
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/rss+xml")
+ _, _ = w.Write([]byte(podcastFixture("AdminView Feed", map[string]string{"av-1": "AdminView One"})))
+ })
+ srv := swapFeedServer(t, mux.ServeHTTP)
+
+ // Subscribe the REGULAR user: the admin view must show their feed +
+ // their name even though the library was created by/for the test.
+ form := url.Values{}
+ form.Set("library_id", uuid.UUID(lib.ID.Bytes).String())
+ form.Set("url", srv.URL+"/feed.xml")
+ resp, body := podcastAuthedForm(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds", form)
+ require.Equal(t, http.StatusCreated, resp, string(body))
+
+ // Admin sees the page with the regular user's feed + their name.
+ code, page := podcastAuthed(t, setup, setup.Token, "GET", "/admin/podcasts", "")
+ require.Equal(t, http.StatusOK, code, string(page))
+ assert.Contains(t, string(page), "AdminView Feed",
+ "PIN: the admin view lists feeds the admin does not subscribe to (RLS admin escape)")
+ assert.Contains(t, string(page), "All Podcasts")
+ regularName := "testregularuser"
+ assert.Contains(t, string(page), regularName, "subscriber names render")
+
+ // Regular users are locked out of the page AND the maintenance actions.
+ code, _ = podcastAuthed(t, setup, setup.RegularToken, "GET", "/admin/podcasts", "")
+ assert.Equal(t, http.StatusForbidden, code, "PIN: non-admins never see the all-podcasts view")
+
+ feeds, _ := setup.DB.ListPodcastFeedsForPolling(adminSeedCtx(t, setup))
+ var feedIDStr string
+ for _, f := range feeds {
+ if f.Title == "AdminView Feed" {
+ feedIDStr = uuid.UUID(f.ID.Bytes).String()
+ }
+ }
+ require.NotEmpty(t, feedIDStr)
+ code, _ = podcastAuthed(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds/"+feedIDStr+"/delete-files", "")
+ assert.Equal(t, http.StatusForbidden, code, "PIN: non-admins cannot delete files")
+}
+
+func TestAdminDeleteEpisodeFiles(t *testing.T) {
+ setup := setupTestServer(t)
+ if _, err := exec.LookPath("ffmpeg"); err != nil {
+ if _, err := os.Stat("/usr/bin/ffmpeg"); err != nil {
+ t.Skip("ffmpeg unavailable: worker pipeline test needs runtime-generated audio")
+ }
+ }
+
+ lib, root := createPodcastsLibrary(t, setup, "test AdminFiles Podcasts")
+
+ mp3Path := filepath.Join(t.TempDir(), "tone.mp3")
+ gen := exec.Command("ffmpeg", "-y", "-v", "error", "-f", "lavfi",
+ "-i", "sine=frequency=440:duration=1", "-b:a", "32k", mp3Path)
+ out, err := gen.CombinedOutput()
+ require.NoError(t, err, "ffmpeg generate: %s", string(out))
+ audio, err := os.ReadFile(mp3Path)
+ require.NoError(t, err)
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/audio/", func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "audio/mpeg")
+ _, _ = w.Write(audio)
+ })
+ mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/rss+xml")
+ _, _ = w.Write([]byte(podcastFixture("AdminFiles Feed", map[string]string{
+ "af-1": "AdminFiles One", "af-2": "AdminFiles Two",
+ })))
+ })
+ srv := swapFeedServer(t, mux.ServeHTTP)
+
+ form := url.Values{}
+ form.Set("library_id", uuid.UUID(lib.ID.Bytes).String())
+ form.Set("url", srv.URL+"/feed.xml")
+ resp, body := podcastAuthedForm(t, setup, setup.Token, "POST", "/api/podcasts/feeds", form)
+ require.Equal(t, http.StatusCreated, resp, string(body))
+
+ adminCtx := adminSeedCtx(t, setup)
+ feeds, err := setup.DB.ListPodcastFeedsForPolling(adminCtx)
+ require.NoError(t, err)
+ var feedRow database.PodcastFeeds
+ for _, f := range feeds {
+ if f.Title == "AdminFiles Feed" {
+ feedRow = f
+ }
+ }
+ require.NotZero(t, feedRow.ID, "feed must exist")
+ feedIDStr := uuid.UUID(feedRow.ID.Bytes).String()
+
+ // Point enclosures at the fixture server, THEN queue + drain
+ // immediately: queueing after re-pointing keeps the dev server's
+ // worker (which can't reach the fixture URL and would fail the
+ // rows) out of the race.
+ adminScope := database.NewScopedDB(setup.DBPool)
+ _, err = adminScope.Exec(rls.WithUser(context.Background(), adminCtxValue(t, setup)),
+ `UPDATE media_items SET enclosure_url = $2 WHERE source_feed_id = $1`,
+ feedRow.ID, srv.URL+"/audio/ep.mp3")
+ require.NoError(t, err)
+ worker := services.NewPodcastDownloadWorker(setup.DB, services.NewPodcastService(setup.DB))
+ eps, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID)
+ require.NoError(t, err)
+ for _, ep := range eps {
+ _, err := setup.DB.SetPodcastEpisodeDownloadQueued(adminCtx, ep.ID)
+ require.NoError(t, err)
+ }
+ worker.DrainNow(context.Background())
+ require.Eventually(t, func() bool {
+ eps, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID)
+ if err != nil || len(eps) != 2 {
+ return false
+ }
+ for _, ep := range eps {
+ if ep.DownloadState != "downloaded" {
+ return false
+ }
+ }
+ return true
+ }, 30*time.Second, 250*time.Millisecond, "both episodes downloaded")
+
+ eps, err = setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID)
+ require.NoError(t, err)
+ ep1, ep2 := eps[0], eps[1]
+ pathFor := func(ep database.MediaItems) string { return filepath.Join(root, ep.FilePath) }
+ require.FileExists(t, pathFor(ep1))
+ require.FileExists(t, pathFor(ep2))
+
+ // --- single-episode delete-file (admin) ---
+ ep1Str := uuid.UUID(ep1.ID.Bytes).String()
+ code, rbody := podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/episodes/"+ep1Str+"/delete-file", "")
+ require.Equal(t, http.StatusOK, code, string(rbody))
+
+ after1, err := setup.DB.GetPodcastEpisode(adminCtx, ep1.ID)
+ require.NoError(t, err)
+ assert.Equal(t, "pending", after1.DownloadState, "PIN: deleted-file episode resets to pending")
+ assert.False(t, after1.FileSize.Valid, "file facts cleared")
+ assert.Nil(t, after1.AudioMetadata, "audio_metadata cleared (it described the deleted file)")
+ after2, err := setup.DB.GetPodcastEpisode(adminCtx, ep2.ID)
+ require.NoError(t, err)
+ assert.Equal(t, "downloaded", after2.DownloadState, "the sibling episode is untouched")
+ assert.NoFileExists(t, pathFor(after1), "disk file removed")
+ assert.FileExists(t, pathFor(after2), "sibling file kept")
+
+ // --- per-feed delete-files (admin) ---
+ code, rbody = podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/delete-files", "")
+ require.Equal(t, http.StatusOK, code, string(rbody))
+ assert.Contains(t, string(rbody), "reset_episodes")
+
+ after2, err = setup.DB.GetPodcastEpisode(adminCtx, ep2.ID)
+ require.NoError(t, err)
+ assert.Equal(t, "pending", after2.DownloadState)
+ assert.NoFileExists(t, pathFor(after2), "feed-level deletion clears the remaining file")
+
+ // --- refresh must NOT re-materialize deleted files (invariant 2):
+ // the poll upsert rewrites metadata but never touches download_state.
+ code, rbody = podcastAuthed(t, setup, setup.Token, "POST", "/api/podcasts/feeds/"+feedIDStr+"/refresh", "")
+ require.Equal(t, http.StatusOK, code, string(rbody))
+ refreshed, err := setup.DB.ListPodcastEpisodes(adminCtx, feedRow.ID)
+ require.NoError(t, err)
+ require.Len(t, refreshed, 2)
+ for _, ep := range refreshed {
+ assert.Equal(t, "pending", ep.DownloadState,
+ "PIN: a feed refresh does not resurrect deleted downloads (%s)", ep.Title)
+ assert.NoFileExists(t, pathFor(ep), "no file re-materialized without an explicit download")
+ }
+}
diff --git a/internal/database/querier.go b/internal/database/querier.go
index 25a6e331..c0709474 100644
--- a/internal/database/querier.go
+++ b/internal/database/querier.go
@@ -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
diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go
index 45c33b97..56c6e99d 100644
--- a/internal/database/queries.sql.go
+++ b/internal/database/queries.sql.go
@@ -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,
diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql
index 0228f3c7..8b08e8b7 100644
--- a/internal/database/queries/queries.sql
+++ b/internal/database/queries/queries.sql
@@ -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).
diff --git a/internal/handlers/podcast.go b/internal/handlers/podcast.go
index 8308c964..e8d31340 100644
--- a/internal/handlers/podcast.go
+++ b/internal/handlers/podcast.go
@@ -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
diff --git a/internal/router/frontend.go b/internal/router/frontend.go
index 3f1158d4..bc59bb48 100644
--- a/internal/router/frontend.go
+++ b/internal/router/frontend.go
@@ -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)
diff --git a/internal/router/podcast.go b/internal/router/podcast.go
index ce975550..b48a41c8 100644
--- a/internal/router/podcast.go
+++ b/internal/router/podcast.go
@@ -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))
}
diff --git a/internal/services/podcast_service.go b/internal/services/podcast_service.go
index 5c366d4e..a9f828fc 100644
--- a/internal/services/podcast_service.go
+++ b/internal/services/podcast_service.go
@@ -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) {
diff --git a/templates/admin_podcasts.templ b/templates/admin_podcasts.templ
new file mode 100644
index 00000000..9578e15d
--- /dev/null
+++ b/templates/admin_podcasts.templ
@@ -0,0 +1,127 @@
+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) {
+
+
+
+
+
+ All Podcasts - Bookhoard
+
+
+
+
+ @Header(user, "/admin/podcasts")
+
+
+
All Podcasts
+
+ { podcastInt(totalFeeds) } feeds · { podcastInt(totalDownloaded) }/{ podcastInt(totalEpisodes) } episodes downloaded · { FormatEpisodeSize(totalBytes) } on disk
+
+
+
+ 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.
+
+ if len(feeds) == 0 {
+ No podcast feeds exist yet.
+ } else {
+
+
+
+
+ Feed
+ Library
+ Subscribers
+ Downloaded
+ Disk
+ Last fetch
+ Actions
+
+
+
+ for _, f := range feeds {
+
+
+ { f.Title }
+ if f.Host != "" {
+ { f.Host }
+ }
+
+ { f.LibraryName }
+
+ if f.Subscribers > 0 {
+ { podcastInt(f.Subscribers) }
+ } else {
+ orphaned
+ }
+
+
+ { podcastInt(f.EpisodeDownloaded) }/{ podcastInt(f.EpisodeTotal) }
+
+ { FormatEpisodeSize(f.DiskBytes) }
+ { f.LastFetched }
+
+
+
+ Refresh
+
+ if f.EpisodeDownloaded > 0 {
+
+ Delete files
+
+ }
+
+ Remove
+
+
+
+
+ }
+
+
+
+ }
+
+
+
+}
diff --git a/templates/admin_podcasts_templ.go b/templates/admin_podcasts_templ.go
new file mode 100644
index 00000000..aa1b7b3f
--- /dev/null
+++ b/templates/admin_podcasts_templ.go
@@ -0,0 +1,424 @@
+// Code generated by templ - DO NOT EDIT.
+
+// templ: version: v0.3.1020
+package templates
+
+//lint:file-ignore SA4006 This context is only used if a nested component is present.
+
+import "github.com/a-h/templ"
+import templruntime "github.com/a-h/templ/runtime"
+
+// 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
+}
+
+func AdminPodcasts(user User, feeds []AdminPodcastFeedData, totalFeeds int, totalDownloaded int, totalEpisodes int, totalBytes int64) templ.Component {
+ return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
+ templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
+ if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
+ return templ_7745c5c3_CtxErr
+ }
+ templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
+ if !templ_7745c5c3_IsBuffer {
+ defer func() {
+ templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err == nil {
+ templ_7745c5c3_Err = templ_7745c5c3_BufErr
+ }
+ }()
+ }
+ ctx = templ.InitializeContext(ctx)
+ templ_7745c5c3_Var1 := templ.GetChildren(ctx)
+ if templ_7745c5c3_Var1 == nil {
+ templ_7745c5c3_Var1 = templ.NopComponent
+ }
+ ctx = templ.ClearChildren(ctx)
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "All Podcasts - Bookhoard ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var3 = []any{"theme-" + user.Theme}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var3...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = Header(user, "/admin/podcasts").Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "All Podcasts ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var5 string
+ templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(totalFeeds))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 40, Col: 30}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " feeds · ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var6 string
+ templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(totalDownloaded))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 40, Col: 71}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "/")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var7 string
+ templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(totalEpisodes))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 40, Col: 101}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " episodes downloaded · ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var8 string
+ templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatEpisodeSize(totalBytes))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 40, Col: 158}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " on disk
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.
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if len(feeds) == 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "No podcast feeds exist yet.
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "Feed Library Subscribers Downloaded Disk Last fetch Actions ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ for _, f := range feeds {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var11 string
+ templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(f.Title)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 68, Col: 123}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if f.Host != "" {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var12 string
+ templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(f.Host)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 70, Col: 78}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var13 string
+ templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(f.LibraryName)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 73, Col: 84}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if f.Subscribers > 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var15 string
+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(f.Subscribers))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 76, Col: 108}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ } else {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "orphaned ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var16 string
+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(f.EpisodeDownloaded))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 82, Col: 44}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "/")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var17 string
+ templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(podcastInt(f.EpisodeTotal))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 82, Col: 75}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var18 string
+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(FormatEpisodeSize(f.DiskBytes))
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 84, Col: 123}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var19 string
+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(f.LastFetched)
+ if templ_7745c5c3_Err != nil {
+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_podcasts.templ`, Line: 85, Col: 92}
+ }
+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " Refresh ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ if f.EpisodeDownloaded > 0 {
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "Delete files ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "Remove
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ return nil
+ })
+}
+
+var _ = templruntime.GeneratedTemplate
diff --git a/templates/header.templ b/templates/header.templ
index da36d98b..290640e9 100644
--- a/templates/header.templ
+++ b/templates/header.templ
@@ -208,6 +208,10 @@ templ Header(user User, currentPath string) {
@Icon("library", "h-5 w-5 shrink-0")
Libraries
+
+ @Icon("podcast", "h-5 w-5 shrink-0")
+ Podcasts
+
@Icon("copy", "h-5 w-5 shrink-0")
Hash Conflicts
diff --git a/templates/header_templ.go b/templates/header_templ.go
index 6f053f05..fe2b0c69 100644
--- a/templates/header_templ.go
+++ b/templates/header_templ.go
@@ -650,12 +650,12 @@ func Header(user User, currentPath string) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var38 = []any{activeClass(currentPath, "/admin/hash-conflicts")}
+ var templ_7745c5c3_Var38 = []any{activeClass(currentPath, "/admin/podcasts")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var38...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " Hash Conflicts ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "Podcasts ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var40 = []any{activeClass(currentPath, "/admin/users")}
+ var templ_7745c5c3_Var40 = []any{activeClass(currentPath, "/admin/hash-conflicts")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var40...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "Users ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "Hash Conflicts ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- var templ_7745c5c3_Var42 = []any{activeClass(currentPath, "/admin/settings")}
+ var templ_7745c5c3_Var42 = []any{activeClass(currentPath, "/admin/users")}
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var42...)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "Users ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ var templ_7745c5c3_Var44 = []any{activeClass(currentPath, "/admin/settings")}
+ templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var44...)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
templ_7745c5c3_Err = Icon("gear", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "Settings ")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "Settings ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "