summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBen Sima <ben@bsima.me>2025-11-15 19:11:45 -0500
committerBen Sima <ben@bsima.me>2025-11-15 19:11:45 -0500
commit9b4afe3f7ab98f42194d12cae430b0f7345c4644 (patch)
treefbfb89f8fc309d15e136f4ab035a795df3fe4d96
parent48afd6bdb135177842af593c7f96846b9ddab7d8 (diff)
Add individual episode pages with sharing and media player
- Create new Episode.py module with episode-specific components - EpisodePlayer: HTML5 audio player - ShareButton: Clipboard copy with Bootstrap input-group pattern - SignupBanner: Promotional banner for non-authenticated users - EpisodeDetailPage: Full page layout - Update Web.py to add /episode/<id> route (public, no auth required) - Make episode titles clickable in EpisodeList component - Add Database.get_episode_by_id() method for efficient queries - Update RSS feed and share buttons to use Bootstrap input-group pattern - Add comprehensive test suite for episode detail pages All episode pages are publicly accessible and include: - Media player to listen to episodes - Share button with URL copying - Links to original articles - Creator attribution banner for non-logged-in users Amp-Thread-ID: https://ampcode.com/threads/T-cc5d29f0-454e-4864-8d7e-1ad69a42afa9 Co-authored-by: Amp <amp@ampcode.com>
-rw-r--r--Biz/PodcastItLater/Core.py17
-rw-r--r--Biz/PodcastItLater/Episode.py281
-rw-r--r--Biz/PodcastItLater/Web.py182
3 files changed, 459 insertions, 21 deletions
diff --git a/Biz/PodcastItLater/Core.py b/Biz/PodcastItLater/Core.py
index f32e81b..9e3f830 100644
--- a/Biz/PodcastItLater/Core.py
+++ b/Biz/PodcastItLater/Core.py
@@ -323,6 +323,23 @@ class Database: # noqa: PLR0904
return [dict(row) for row in rows]
@staticmethod
+ def get_episode_by_id(episode_id: int) -> dict[str, Any] | None:
+ """Fetch single episode by ID."""
+ with Database.get_connection() as conn:
+ cursor = conn.cursor()
+ cursor.execute(
+ """
+ SELECT id, title, audio_url, duration, created_at,
+ content_length, author, original_url, user_id
+ FROM episodes
+ WHERE id = ?
+ """,
+ (episode_id,),
+ )
+ row = cursor.fetchone()
+ return dict(row) if row is not None else None
+
+ @staticmethod
def get_all_episodes(
user_id: int | None = None,
) -> list[dict[str, Any]]:
diff --git a/Biz/PodcastItLater/Episode.py b/Biz/PodcastItLater/Episode.py
new file mode 100644
index 0000000..abcff92
--- /dev/null
+++ b/Biz/PodcastItLater/Episode.py
@@ -0,0 +1,281 @@
+"""
+PodcastItLater Episode Detail Components.
+
+Components for displaying individual episode pages with media player,
+share functionality, and signup prompts for non-authenticated users.
+"""
+
+# : out podcastitlater-episode
+# : dep ludic
+import Biz.PodcastItLater.UI as UI
+import ludic.html as html
+import typing
+from ludic.attrs import Attrs
+from ludic.components import Component
+from ludic.types import AnyChildren
+from typing import override
+
+
+class EpisodePlayerAttrs(Attrs):
+ """Attributes for EpisodePlayer component."""
+
+ audio_url: str
+ title: str
+
+
+class EpisodePlayer(Component[AnyChildren, EpisodePlayerAttrs]):
+ """HTML5 audio player for episode playback."""
+
+ @override
+ def render(self) -> html.div:
+ audio_url = self.attrs["audio_url"]
+
+ return html.div(
+ html.div(
+ html.div(
+ html.h5(
+ html.i(classes=["bi", "bi-play-circle", "me-2"]),
+ "Listen",
+ classes=["card-title", "mb-3"],
+ ),
+ html.audio(
+ html.source(src=audio_url, type="audio/mpeg"),
+ "Your browser does not support the audio element.",
+ controls=True,
+ preload="metadata",
+ classes=["w-100"],
+ style={"max-width": "100%"},
+ ),
+ classes=["card-body"],
+ ),
+ classes=["card", "mb-4"],
+ ),
+ )
+
+
+class ShareButtonAttrs(Attrs):
+ """Attributes for ShareButton component."""
+
+ share_url: str
+
+
+class ShareButton(Component[AnyChildren, ShareButtonAttrs]):
+ """Button to copy episode URL to clipboard."""
+
+ @override
+ def render(self) -> html.div:
+ share_url = self.attrs["share_url"]
+
+ return html.div(
+ html.div(
+ html.div(
+ html.h5(
+ html.i(classes=["bi", "bi-share", "me-2"]),
+ "Share Episode",
+ classes=["card-title", "mb-3"],
+ ),
+ html.div(
+ html.div(
+ html.button(
+ html.i(classes=["bi", "bi-copy", "me-1"]),
+ "Copy",
+ type="button",
+ id="share-button",
+ on_click=f"navigator.clipboard.writeText('{share_url}'); " # noqa: E501
+ "const btn = document.getElementById('share-button'); " # noqa: E501
+ "const originalHTML = btn.innerHTML; "
+ "btn.innerHTML = '<i class=\"bi bi-check me-1\"></i>Copied!'; " # noqa: E501
+ "btn.classList.remove('btn-outline-secondary'); " # noqa: E501
+ "btn.classList.add('btn-success'); "
+ "setTimeout(() => {{ "
+ "btn.innerHTML = originalHTML; "
+ "btn.classList.remove('btn-success'); "
+ "btn.classList.add('btn-outline-secondary'); "
+ "}}, 2000);",
+ classes=["btn", "btn-outline-secondary"],
+ ),
+ html.input(
+ type="text",
+ value=share_url,
+ readonly=True,
+ on_focus="this.select()",
+ classes=["form-control"],
+ ),
+ classes=["input-group"],
+ ),
+ ),
+ classes=["card-body"],
+ ),
+ classes=["card"],
+ ),
+ classes=["mb-4"],
+ )
+
+
+class SignupBannerAttrs(Attrs):
+ """Attributes for SignupBanner component."""
+
+ creator_email: str
+ base_url: str
+
+
+class SignupBanner(Component[AnyChildren, SignupBannerAttrs]):
+ """Banner prompting non-authenticated users to sign up."""
+
+ @override
+ def render(self) -> html.div:
+ creator_email = self.attrs["creator_email"]
+
+ return html.div(
+ html.div(
+ html.div(
+ html.div(
+ html.i(
+ classes=[
+ "bi",
+ "bi-info-circle-fill",
+ "me-2",
+ ],
+ ),
+ html.strong("This episode was created by "),
+ html.code(
+ creator_email,
+ classes=["text-dark"],
+ ),
+ html.strong(" using PodcastItLater."),
+ classes=["mb-3"],
+ ),
+ html.div(
+ html.p(
+ "Want to convert your own articles "
+ "to podcast episodes?",
+ classes=["mb-2"],
+ ),
+ html.form(
+ html.div(
+ html.input(
+ type="email",
+ name="email",
+ placeholder="Enter your email to start",
+ required=True,
+ classes=["form-control"],
+ ),
+ html.button(
+ html.i(
+ classes=[
+ "bi",
+ "bi-arrow-right-circle",
+ "me-2",
+ ],
+ ),
+ "Sign Up",
+ type="submit",
+ classes=["btn", "btn-primary"],
+ ),
+ classes=["input-group"],
+ ),
+ hx_post="/login",
+ hx_target="#signup-result",
+ hx_swap="innerHTML",
+ ),
+ html.div(id="signup-result", classes=["mt-2"]),
+ ),
+ classes=["card-body"],
+ ),
+ classes=["card", "border-primary"],
+ ),
+ classes=["mb-4"],
+ )
+
+
+class EpisodeDetailPageAttrs(Attrs):
+ """Attributes for EpisodeDetailPage component."""
+
+ episode: dict[str, typing.Any]
+ creator_email: str | None
+ user: dict[str, typing.Any] | None
+ base_url: str
+
+
+class EpisodeDetailPage(Component[AnyChildren, EpisodeDetailPageAttrs]):
+ """Full page view for a single episode."""
+
+ @override
+ def render(self) -> UI.PageLayout:
+ episode = self.attrs["episode"]
+ creator_email = self.attrs.get("creator_email")
+ user = self.attrs.get("user")
+ base_url = self.attrs["base_url"]
+
+ share_url = f"{base_url}/episode/{episode['id']}"
+ duration_str = UI.format_duration(episode.get("duration"))
+
+ return UI.PageLayout(
+ # Show signup banner if user is not logged in
+ SignupBanner(
+ creator_email=creator_email or "a user",
+ base_url=base_url,
+ )
+ if not user and creator_email
+ else html.div(),
+ # Episode title and metadata
+ html.div(
+ html.h2(
+ episode["title"],
+ classes=["display-6", "mb-3"],
+ ),
+ html.div(
+ html.span(
+ html.i(classes=["bi", "bi-person", "me-1"]),
+ f"by {episode['author']}",
+ classes=["text-muted", "me-3"],
+ )
+ if episode.get("author")
+ else html.span(),
+ html.span(
+ html.i(classes=["bi", "bi-clock", "me-1"]),
+ f"Duration: {duration_str}",
+ classes=["text-muted", "me-3"],
+ ),
+ html.span(
+ html.i(classes=["bi", "bi-calendar", "me-1"]),
+ f"Created: {episode['created_at']}",
+ classes=["text-muted"],
+ ),
+ classes=["mb-3"],
+ ),
+ html.div(
+ html.a(
+ html.i(classes=["bi", "bi-link-45deg", "me-1"]),
+ "View original article",
+ href=episode["original_url"],
+ target="_blank",
+ rel="noopener",
+ classes=["btn", "btn-sm", "btn-outline-secondary"],
+ ),
+ )
+ if episode.get("original_url")
+ else html.div(),
+ classes=["mb-4"],
+ ),
+ # Audio player
+ EpisodePlayer(
+ audio_url=episode["audio_url"],
+ title=episode["title"],
+ ),
+ # Share button
+ ShareButton(share_url=share_url),
+ # Back to home link
+ html.div(
+ html.a(
+ html.i(classes=["bi", "bi-arrow-left", "me-1"]),
+ "Back to Home",
+ href="/",
+ classes=["btn", "btn-link"],
+ ),
+ classes=["mt-4"],
+ ),
+ user=user,
+ current_page="",
+ error=None,
+ )
diff --git a/Biz/PodcastItLater/Web.py b/Biz/PodcastItLater/Web.py
index c20675f..f63046c 100644
--- a/Biz/PodcastItLater/Web.py
+++ b/Biz/PodcastItLater/Web.py
@@ -20,6 +20,7 @@ import Biz.EmailAgent
import Biz.PodcastItLater.Admin as Admin
import Biz.PodcastItLater.Billing as Billing
import Biz.PodcastItLater.Core as Core
+import Biz.PodcastItLater.Episode as Episode
import Biz.PodcastItLater.UI as UI
import html as html_module
import httpx
@@ -521,7 +522,11 @@ class EpisodeList(Component[AnyChildren, EpisodeListAttrs]):
html.div(
html.div(
html.h5(
- episode["title"],
+ html.a(
+ episode["title"],
+ href=f"/episode/{episode['id']}",
+ classes=["text-decoration-none"],
+ ),
classes=["card-title", "mb-2"],
),
# Show author if available
@@ -576,28 +581,39 @@ class EpisodeList(Component[AnyChildren, EpisodeListAttrs]):
# RSS feed link with copy-to-clipboard
html.div(
html.div(
- html.i(classes=["bi", "bi-rss-fill", "me-2"]),
- html.strong("Subscribe in your podcast app: "),
- html.a(
- rss_url or "",
- href="#",
- id="rss-link",
- on_click=f"navigator.clipboard.writeText('{rss_url}'); "
- "document.getElementById('copy-feedback').classList.remove('d-none'); " # noqa: E501
- "setTimeout(() => document.getElementById('copy-feedback').classList.add('d-none'), 2000); " # noqa: E501
- "return false;",
- classes=[
- "text-decoration-none",
- "text-truncate",
- "d-inline-block",
- ],
+ html.label(
+ html.i(classes=["bi", "bi-rss-fill", "me-2"]),
+ "Subscribe in your podcast app:",
+ classes=["form-label", "fw-bold"],
),
- html.span(
- " ✓ Copied!",
- id="copy-feedback",
- classes=["text-success", "small", "d-none"],
+ html.div(
+ html.button(
+ html.i(classes=["bi", "bi-copy", "me-1"]),
+ "Copy",
+ type="button",
+ id="rss-copy-button",
+ on_click=f"navigator.clipboard.writeText('{rss_url}'); " # noqa: E501
+ "const btn = document.getElementById('rss-copy-button'); " # noqa: E501
+ "const originalHTML = btn.innerHTML; "
+ "btn.innerHTML = '<i class=\"bi bi-check me-1\"></i>Copied!'; " # noqa: E501
+ "btn.classList.remove('btn-outline-secondary'); "
+ "btn.classList.add('btn-success'); "
+ "setTimeout(() => {{ "
+ "btn.innerHTML = originalHTML; "
+ "btn.classList.remove('btn-success'); "
+ "btn.classList.add('btn-outline-secondary'); "
+ "}}, 2000);",
+ classes=["btn", "btn-outline-secondary"],
+ ),
+ html.input(
+ type="text",
+ value=rss_url or "",
+ readonly=True,
+ on_focus="this.select()",
+ classes=["form-control"],
+ ),
+ classes=["input-group", "mb-3"],
),
- classes=["mb-3"],
),
)
if rss_url
@@ -1246,6 +1262,43 @@ def rss_feed(request: Request, token: str) -> Response: # noqa: ARG001
return Response(f"Error generating feed: {e}", status_code=500)
+@app.get("/episode/{episode_id}")
+def episode_detail(
+ request: Request,
+ episode_id: int,
+) -> Episode.EpisodeDetailPage | Response:
+ """Display individual episode page (public, no auth required)."""
+ try:
+ # Get episode from database
+ episode = Core.Database.get_episode_by_id(episode_id)
+
+ if not episode:
+ return Response("Episode not found", status_code=404)
+
+ # Get creator email if episode has user_id
+ creator_email = None
+ if episode.get("user_id"):
+ creator = Core.Database.get_user_by_id(episode["user_id"])
+ creator_email = creator["email"] if creator else None
+
+ # Check if current user is logged in
+ user_id = request.session.get("user_id")
+ user = None
+ if user_id:
+ user = Core.Database.get_user_by_id(user_id)
+
+ return Episode.EpisodeDetailPage(
+ episode=episode,
+ creator_email=creator_email,
+ user=user,
+ base_url=BASE_URL,
+ )
+
+ except (ValueError, KeyError) as e:
+ logger.exception("Error loading episode")
+ return Response(f"Error loading episode: {e}", status_code=500)
+
+
@app.get("/status")
def queue_status(request: Request) -> QueueStatus:
"""Return HTMX endpoint for live queue updates."""
@@ -1957,6 +2010,92 @@ class TestJobCancellation(BaseWebTest):
)
+class TestEpisodeDetailPage(BaseWebTest):
+ """Test episode detail page functionality."""
+
+ def setUp(self) -> None:
+ """Set up test client with user and episode."""
+ super().setUp()
+
+ # Create user and episode
+ self.user_id, self.token = Core.Database.create_user(
+ "creator@example.com",
+ status="active",
+ )
+ self.episode_id = Core.Database.create_episode(
+ title="Test Episode",
+ audio_url="https://example.com/audio.mp3",
+ duration=300,
+ content_length=5000,
+ user_id=self.user_id,
+ author="Test Author",
+ original_url="https://example.com/article",
+ )
+
+ def test_episode_page_loads(self) -> None:
+ """Episode page should load successfully."""
+ response = self.client.get(f"/episode/{self.episode_id}")
+
+ self.assertEqual(response.status_code, 200)
+ self.assertIn("Test Episode", response.text)
+ self.assertIn("Test Author", response.text)
+
+ def test_episode_not_found(self) -> None:
+ """Non-existent episode should return 404."""
+ response = self.client.get("/episode/99999")
+
+ self.assertEqual(response.status_code, 404)
+
+ def test_audio_player_present(self) -> None:
+ """Audio player should be present on episode page."""
+ response = self.client.get(f"/episode/{self.episode_id}")
+
+ self.assertIn("<audio", response.text)
+ self.assertIn("controls", response.text)
+ self.assertIn("https://example.com/audio.mp3", response.text)
+
+ def test_share_button_present(self) -> None:
+ """Share button should be present."""
+ response = self.client.get(f"/episode/{self.episode_id}")
+
+ self.assertIn("Share Episode", response.text)
+ self.assertIn("navigator.clipboard.writeText", response.text)
+
+ def test_original_article_link(self) -> None:
+ """Original article link should be present."""
+ response = self.client.get(f"/episode/{self.episode_id}")
+
+ self.assertIn("View original article", response.text)
+ self.assertIn("https://example.com/article", response.text)
+
+ def test_signup_banner_for_non_authenticated(self) -> None:
+ """Non-authenticated users should see signup banner."""
+ response = self.client.get(f"/episode/{self.episode_id}")
+
+ self.assertIn("This episode was created by", response.text)
+ self.assertIn("creator@example.com", response.text)
+ self.assertIn("Sign Up", response.text)
+
+ def test_no_signup_banner_for_authenticated(self) -> None:
+ """Authenticated users should not see signup banner."""
+ # Login
+ self.client.post("/login", data={"email": "creator@example.com"})
+
+ response = self.client.get(f"/episode/{self.episode_id}")
+
+ self.assertNotIn("This episode was created by", response.text)
+
+ def test_episode_links_from_home_page(self) -> None:
+ """Episode titles on home page should link to detail page."""
+ # Login to see episodes
+ self.client.post("/login", data={"email": "creator@example.com"})
+
+ response = self.client.get("/")
+
+ self.assertIn(f'href="/episode/{self.episode_id}"', response.text)
+ self.assertIn("Test Episode", response.text)
+
+
def test() -> None:
"""Run all tests for the web module."""
Test.run(
@@ -1968,6 +2107,7 @@ def test() -> None:
TestRSSFeed,
TestAdminInterface,
TestJobCancellation,
+ TestEpisodeDetailPage,
],
)