summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-07 15:56:35 +0300
committerPaul Buetow <paul@buetow.org>2026-05-07 15:56:35 +0300
commit7f3ca21cc42979b8f1898f5dfd81f51f04aed5e0 (patch)
tree47987d9c73f2ce760cefed045ec85eee53747ff7
parent3f70adb2c24d558be645aacb11a97d5f95f69b76 (diff)
Fix podcast browser workflows for k1
-rw-r--r--internal/api/handlers_podcast.go4
-rw-r--r--internal/api/handlers_podcast_test.go4
-rw-r--r--internal/service/podcast.go2
-rw-r--r--internal/service/service.go1
-rw-r--r--web/css/layout.css23
-rw-r--r--web/js/podcasts.js74
-rw-r--r--web/js/views/media-grid.js13
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(() => {});
}
}