diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-07 15:56:35 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-07 15:56:35 +0300 |
| commit | 7f3ca21cc42979b8f1898f5dfd81f51f04aed5e0 (patch) | |
| tree | 47987d9c73f2ce760cefed045ec85eee53747ff7 | |
| parent | 3f70adb2c24d558be645aacb11a97d5f95f69b76 (diff) | |
Fix podcast browser workflows for k1
| -rw-r--r-- | internal/api/handlers_podcast.go | 4 | ||||
| -rw-r--r-- | internal/api/handlers_podcast_test.go | 4 | ||||
| -rw-r--r-- | internal/service/podcast.go | 2 | ||||
| -rw-r--r-- | internal/service/service.go | 1 | ||||
| -rw-r--r-- | web/css/layout.css | 23 | ||||
| -rw-r--r-- | web/js/podcasts.js | 74 | ||||
| -rw-r--r-- | web/js/views/media-grid.js | 13 |
7 files changed, 100 insertions, 21 deletions
diff --git a/internal/api/handlers_podcast.go b/internal/api/handlers_podcast.go index 4568022..f93b15c 100644 --- a/internal/api/handlers_podcast.go +++ b/internal/api/handlers_podcast.go @@ -58,6 +58,10 @@ func (s *Server) handleSubscribePodcast(w http.ResponseWriter, r *http.Request) forbidden(w, "access denied") return } + if errors.Is(err, service.ErrInvalidFeed) { + badRequest(w, "invalid feed") + return + } s.logger.Error("subscribe podcast", "err", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to subscribe"}) return diff --git a/internal/api/handlers_podcast_test.go b/internal/api/handlers_podcast_test.go index 33a129c..963c2a0 100644 --- a/internal/api/handlers_podcast_test.go +++ b/internal/api/handlers_podcast_test.go @@ -303,8 +303,8 @@ func TestPodcastE2E_FullFlow(t *testing.T) { rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) - if rr.Code != http.StatusInternalServerError && rr.Code != http.StatusBadRequest { - t.Fatalf("expected 400 or 500, got %d", rr.Code) + if rr.Code != http.StatusBadRequest { + t.Fatalf("expected %d, got %d", http.StatusBadRequest, rr.Code) } }) diff --git a/internal/service/podcast.go b/internal/service/podcast.go index 77c6118..4425bfc 100644 --- a/internal/service/podcast.go +++ b/internal/service/podcast.go @@ -129,7 +129,7 @@ func (s *podcastService) SubscribeFeed(ctx context.Context, feedURL, setName str parsed, err := s.parseFeed(feedURL) if err != nil { - return nil, fmt.Errorf("parse feed: %w", err) + return nil, fmt.Errorf("%w: %v", ErrInvalidFeed, err) } safeName, setPath := s.resolveSetPath(setName, parsed.Title) diff --git a/internal/service/service.go b/internal/service/service.go index f61655c..de218dc 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -20,6 +20,7 @@ var ( ErrUnsupportedExtension = errors.New("unsupported file extension") ErrAlreadyBootstrapped = errors.New("already bootstrapped") ErrInvalidCredentials = errors.New("invalid credentials") + ErrInvalidFeed = errors.New("invalid feed") ) // MediaQueryFilter defines query parameters for listing media from the API layer. diff --git a/web/css/layout.css b/web/css/layout.css index 909ecfb..d73d07f 100644 --- a/web/css/layout.css +++ b/web/css/layout.css @@ -607,3 +607,26 @@ body { .admin-list li.is-admin { background: var(--accent-soft); } + +.podcast-feed-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0; + border-bottom: 1px solid var(--border); +} + +.podcast-feed-cover { + width: 2.5rem; + height: 2.5rem; + border-radius: var(--radius-sm); + object-fit: cover; + background: var(--bg-elevated); + border: 1px solid var(--border); + flex: 0 0 auto; +} + +.btn-download-episode { + align-self: flex-start; + margin-top: 0.2rem; +} diff --git a/web/js/podcasts.js b/web/js/podcasts.js index 1883e63..4692f7b 100644 --- a/web/js/podcasts.js +++ b/web/js/podcasts.js @@ -1,6 +1,8 @@ // podcastUI.js — Podcast feed manager + episode rendering. import { API } from './api.js'; +import { state } from './state.js'; import { escapeHtml, fmtDur, toast } from './utils.js'; +import { renderSets } from './views/sets.js'; export function initPodcasts() { const modal = document.getElementById('podcast-modal'); @@ -32,6 +34,7 @@ export function initPodcasts() { urlInput.value = ''; nameInput.value = ''; refreshPodcasts(); + refreshSets(); } catch (err) { toast(err.message || 'Subscribe failed', 'error'); } @@ -46,7 +49,8 @@ export function initPodcasts() { return; } listEl.innerHTML = podcasts.map(p => - `<div class="flex gap-2 align-center py-1 border-b"> + `<div class="podcast-feed-row py-1 border-b"> + <img class="podcast-feed-cover" src="/api/sets/${p.id}/cover" alt="" loading="lazy"> <span class="flex-1">${escapeHtml(p.name)}</span> <span class="text-xs text-muted">${escapeHtml(p.root_path)}</span> </div>` @@ -55,6 +59,15 @@ export function initPodcasts() { listEl.innerHTML = `<p class="error-message">${escapeHtml(err.message)}</p>`; } } + + async function refreshSets() { + try { + state.sets = await API.sets() || []; + renderSets(); + } catch { + // The subscription succeeded; leave the existing sidebar in place if refresh fails. + } + } } export function renderPodcastEpisodes(grid, episodes) { @@ -68,21 +81,50 @@ export function renderPodcastEpisodes(grid, episodes) { const card = document.createElement('div'); card.className = 'media-card episode-card'; card.dataset.id = ep.id; - card.innerHTML = renderEpisodeHtml(ep); + renderEpisodeCard(card, ep); grid.appendChild(card); + }); +} + +function renderEpisodeCard(card, ep) { + card.innerHTML = renderEpisodeHtml(ep); + + const playBtn = card.querySelector('[data-action="play"]'); + const downloadBtn = card.querySelector('[data-action="download-episode"]'); + const completeBtn = card.querySelector('.btn-complete'); + + playBtn?.addEventListener('click', (e) => { + e.stopPropagation(); + toast(ep.is_downloaded ? 'Play from downloads' : 'Download first to play', 'info'); + }); - const playBtn = card.querySelector('[data-action="play"]'); - const completeBtn = card.querySelector('.btn-complete'); - playBtn?.addEventListener('click', () => { - toast(ep.is_downloaded ? 'Play from downloads' : 'Download first to play', 'info'); - }); - completeBtn?.addEventListener('click', async () => { - try { - const res = await API.toggleEpisodeComplete(ep.id); - completeBtn.classList.toggle('active'); - toast(res.is_completed ? 'Marked listened' : 'Marked unlistened'); - } catch (err) { toast(err.message || 'Toggle failed', 'error'); } - }); + downloadBtn?.addEventListener('click', async (e) => { + e.stopPropagation(); + downloadBtn.disabled = true; + try { + const media = await API.downloadEpisode(ep.id); + ep.is_downloaded = true; + ep.media_id = media.id; + ep.file_name = media.file_name; + toast('Episode downloaded'); + card.dispatchEvent(new CustomEvent('podcast:episode-downloaded', { bubbles: true, detail: { media } })); + renderEpisodeCard(card, ep); + } catch (err) { + downloadBtn.disabled = false; + toast(err.message || 'Download failed', 'error'); + } + }); + + completeBtn?.addEventListener('click', async (e) => { + e.stopPropagation(); + try { + await API.toggleEpisodeComplete(ep.id); + ep.is_completed = !ep.is_completed; + completeBtn.classList.toggle('active', ep.is_completed); + toast(ep.is_completed ? 'Marked listened' : 'Marked unlistened'); + } catch (err) { + toast(err.message || 'Toggle failed', 'error'); + } }); } @@ -90,6 +132,9 @@ function renderEpisodeHtml(ep) { const completed = ep.is_completed; const dateStr = ep.published_at ? new Date(ep.published_at).toLocaleDateString() : ''; const duration = ep.duration_seconds ? fmtDur(ep.duration_seconds) : ''; + const downloadButton = ep.is_downloaded + ? '<span class="text-xs text-muted">Downloaded</span>' + : '<button class="btn btn-primary btn-sm btn-download-episode" data-action="download-episode">Download</button>'; return ` <div class="thumb-wrap"> <span class="placeholder">🎙️</span> @@ -102,6 +147,7 @@ function renderEpisodeHtml(ep) { <div class="meta"> <div class="title">${escapeHtml(ep.title || 'Untitled')}</div> <div class="subtitle">${escapeHtml(ep.description || 'Podcast episode')}</div> + ${downloadButton} </div> `; } diff --git a/web/js/views/media-grid.js b/web/js/views/media-grid.js index 5c16de0..08435b8 100644 --- a/web/js/views/media-grid.js +++ b/web/js/views/media-grid.js @@ -30,6 +30,10 @@ export function initMediaGrid(options = {}) { enterFolder(folder.dataset.name); } }); + + grid?.addEventListener('podcast:episode-downloaded', () => { + loadMedia(); + }); } export async function loadMedia() { @@ -52,7 +56,7 @@ export async function loadMedia() { updateBreadcrumb(data.current_path); setMedia(mediaWithBrowsePath(data.media || [], data.current_path || '')); renderBrowse(data); - const total = (data.media?.length || 0) + (data.folders?.length || 0); + const total = (data.media?.length || 0) + (data.folders?.length || 0) + (data.episodes?.length || 0); resultCount.textContent = `${total} items`; } else { breadcrumb?.classList.add('hidden'); @@ -129,7 +133,8 @@ function renderBrowse(data) { if (!grid) return; const folders = data.folders || []; const media = mediaWithBrowsePath(data.media || [], data.current_path || ''); - if (!folders.length && !media.length) { + const episodes = data.episodes || []; + if (!folders.length && !media.length && !episodes.length) { grid.innerHTML = '<p class="text-muted text-sm grid-full">Folder is empty.</p>'; clearSelection(); return; @@ -164,9 +169,9 @@ function renderBrowse(data) { bindMediaItems(grid); - if (data.episodes && data.episodes.length) { + if (episodes.length) { import('../podcasts.js').then(m => { - m.renderPodcastEpisodes(grid, data.episodes); + m.renderPodcastEpisodes(grid, episodes); }).catch(() => {}); } } |
