From 9b4afe3f7ab98f42194d12cae430b0f7345c4644 Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Sat, 15 Nov 2025 19:11:45 -0500 Subject: 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/ 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 --- Biz/PodcastItLater/Core.py | 17 +++ Biz/PodcastItLater/Episode.py | 281 ++++++++++++++++++++++++++++++++++++++++++ Biz/PodcastItLater/Web.py | 182 +++++++++++++++++++++++---- 3 files changed, 459 insertions(+), 21 deletions(-) create mode 100644 Biz/PodcastItLater/Episode.py (limited to 'Biz') 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 @@ -322,6 +322,23 @@ class Database: # noqa: PLR0904 rows = cursor.fetchall() 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, 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 = '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 = '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(" 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, ], ) -- cgit v1.2.3