From 0741e98ea55376a853ff14c9a66f038e616addca Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 30 Oct 2026 13:54:37 -0400 Subject: [PATCH] =?UTF-8?q?feat(admin):=20all-podcasts=20view=20=E2=80=94?= =?UTF-8?q?=20disk-first=20maintenance=20for=20every=20feed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §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. --- cmd/server/tests/podcast_test.go | 172 +++++++++++ internal/database/querier.go | 17 ++ internal/database/queries.sql.go | 124 ++++++++ internal/database/queries/queries.sql | 62 ++++ internal/handlers/podcast.go | 62 ++++ internal/router/frontend.go | 60 +++- internal/router/podcast.go | 6 + internal/services/podcast_service.go | 60 ++++ templates/admin_podcasts.templ | 127 ++++++++ templates/admin_podcasts_templ.go | 424 ++++++++++++++++++++++++++ templates/header.templ | 4 + templates/header_templ.go | 120 +++++--- 12 files changed, 1191 insertions(+), 47 deletions(-) create mode 100644 templates/admin_podcasts.templ create mode 100644 templates/admin_podcasts_templ.go 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 + + ") + 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, "
") + 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 + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
FeedLibrarySubscribersDownloadedDiskLast fetchActions
") + 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, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if f.EpisodeDownloaded > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + 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, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -757,20 +787,20 @@ func Header(user User, currentPath string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -795,12 +825,12 @@ func SidebarUserMenu(user User) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var45 := templ.GetChildren(ctx) - if templ_7745c5c3_Var45 == nil { - templ_7745c5c3_Var45 = templ.NopComponent + templ_7745c5c3_Var47 := templ.GetChildren(ctx) + if templ_7745c5c3_Var47 == nil { + templ_7745c5c3_Var47 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -837,7 +867,7 @@ func SidebarUserMenu(user User) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "Profile
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "Logout
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -871,12 +901,12 @@ func SidebarSignIn(currentPath string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var47 := templ.GetChildren(ctx) - if templ_7745c5c3_Var47 == nil { - templ_7745c5c3_Var47 = templ.NopComponent + templ_7745c5c3_Var49 := templ.GetChildren(ctx) + if templ_7745c5c3_Var49 == nil { + templ_7745c5c3_Var49 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if SignupVisible() { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "Create an account") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "Create an account") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err }