From bd4e9bc4b8fc1207fe701a41e6931bef90abb3f6 Mon Sep 17 00:00:00 2001 From: Tobias Macey Date: Tue, 28 Jul 2026 15:17:45 -0400 Subject: [PATCH 01/12] Strip trailing slash from channel_url (#3691) Next.js's default trailingSlash: false means the canonical channel route has no trailing slash, so every sitemap/API-driven request using channel_url paid for a wasted 308 redirect round-trip. Co-authored-by: Claude Sonnet 5 --- channels/models.py | 3 +-- channels/models_test.py | 2 +- channels/serializers_test.py | 2 +- learning_resources/serializers_test.py | 8 ++++---- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/channels/models.py b/channels/models.py index eea800a0b6..7ae3121164 100644 --- a/channels/models.py +++ b/channels/models.py @@ -40,7 +40,6 @@ def annotate_channel_url(self): "channel_type", models.Value("/"), "name", - models.Value("/"), ), ), default=None, @@ -163,7 +162,7 @@ def __str__(self): def channel_url(self) -> str | None: """Return the channel url""" if self.published: - return frontend_absolute_url(f"/c/{self.channel_type}/{self.name}/") + return frontend_absolute_url(f"/c/{self.channel_type}/{self.name}") return None @property diff --git a/channels/models_test.py b/channels/models_test.py index acda047d07..8f5cc57f1b 100644 --- a/channels/models_test.py +++ b/channels/models_test.py @@ -37,7 +37,7 @@ def test_channel_url_for_departments(published, channel_type, detail_factory): if published: assert ( urlparse(channel.channel_url).path - == f"/c/{channel_type.name}/{channel.name}/" + == f"/c/{channel_type.name}/{channel.name}" ) else: assert channel.channel_url is None diff --git a/channels/serializers_test.py b/channels/serializers_test.py index c732473dfd..27e68bd094 100644 --- a/channels/serializers_test.py +++ b/channels/serializers_test.py @@ -105,7 +105,7 @@ def test_serialize_channel( # pylint: disable=too-many-arguments "created_on": mocker.ANY, "id": channel.id, "channel_url": frontend_absolute_url( - f"/c/{channel.channel_type}/{channel.name}/" + f"/c/{channel.channel_type}/{channel.name}" ), "lists": [ LearningPathPreviewSerializer(channel_list.channel_list).data diff --git a/learning_resources/serializers_test.py b/learning_resources/serializers_test.py index 98441468fe..c92ea24164 100644 --- a/learning_resources/serializers_test.py +++ b/learning_resources/serializers_test.py @@ -395,7 +395,7 @@ def test_learning_resource_serializer( # noqa: PLR0913 "department_id": dept.department_id, "name": dept.name, "channel_url": frontend_absolute_url( - f"/c/department/{Channel.objects.get(department_detail__department=dept).name}/", + f"/c/department/{Channel.objects.get(department_detail__department=dept).name}", ), "school": { "id": dept.school.id, @@ -705,7 +705,7 @@ def test_content_file_serializer(settings, expected_types, has_channels): "code": content_file.run.learning_resource.offered_by.code, "display_facet": True, "channel_url": frontend_absolute_url( - f"/c/unit/{Channel.objects.get(unit_detail__unit=content_file.run.learning_resource.offered_by).name}/" + f"/c/unit/{Channel.objects.get(unit_detail__unit=content_file.run.learning_resource.offered_by).name}" ) if has_channels else None, @@ -717,7 +717,7 @@ def test_content_file_serializer(settings, expected_types, has_channels): "name": dept.name, "department_id": dept.department_id, "channel_url": frontend_absolute_url( - f"/c/department/{Channel.objects.get(department_detail__department=dept).name}/" + f"/c/department/{Channel.objects.get(department_detail__department=dept).name}" ) if has_channels else None, @@ -740,7 +740,7 @@ def test_content_file_serializer(settings, expected_types, has_channels): "icon": topic.icon, "parent": topic.parent, "channel_url": frontend_absolute_url( - f"/c/topic/{Channel.objects.get(topic_detail__topic=topic).name}/" + f"/c/topic/{Channel.objects.get(topic_detail__topic=topic).name}" if has_channels else None, ) From 0e31aa0a0541ccad6e0d7095de84af1145116c95 Mon Sep 17 00:00:00 2001 From: Ahtesham Quraish Date: Wed, 29 Jul 2026 13:55:30 +0500 Subject: [PATCH 02/12] fix: Show error message when a podcast episode fails to play (#3687) * fix: Show error message when a podcast episode fails to play --------- Co-authored-by: Ahtesham Quraish --- .../PodcastPage/AudioPlayer.styled.ts | 34 ++ .../PodcastPage/PodcastEmbedPlayer.test.tsx | 63 ++++ .../PodcastPage/PodcastEmbedPlayer.tsx | 80 +++-- .../PodcastPage/PodcastPlayer.test.tsx | 297 ++++++++++++++++++ .../app-pages/PodcastPage/PodcastPlayer.tsx | 85 +++-- .../app-pages/PodcastPage/useAudioPlayer.ts | 120 ++++++- .../PodcastPage/usePlaybackRecovery.ts | 90 ++++++ 7 files changed, 726 insertions(+), 43 deletions(-) create mode 100644 frontends/main/src/app-pages/PodcastPage/usePlaybackRecovery.ts diff --git a/frontends/main/src/app-pages/PodcastPage/AudioPlayer.styled.ts b/frontends/main/src/app-pages/PodcastPage/AudioPlayer.styled.ts index ee464ef660..0104f609fd 100644 --- a/frontends/main/src/app-pages/PodcastPage/AudioPlayer.styled.ts +++ b/frontends/main/src/app-pages/PodcastPage/AudioPlayer.styled.ts @@ -159,3 +159,37 @@ export const SpeedButton = styled.button(({ theme }) => ({ color: theme.custom.colors.red, }, })) + +/** + * Playback-failure message. Takes the progress row's grid area, replacing the + * seek slider — which has nothing to scrub — so the players report the failure + * without growing taller and pushing the fixed bar over the page content. + */ +export const PlaybackError = styled.div(({ theme }) => ({ + gridArea: "progress", + display: "flex", + alignItems: "center", + gap: "8px", + minWidth: 0, + color: theme.custom.colors.red, + "& > svg": { + flexShrink: 0, + width: "20px", + height: "20px", + }, +})) + +export const PlaybackErrorText = styled(Typography)({ + minWidth: 0, + // Bound the message at two lines: one fits the desktop bar, two fit the + // taller mobile layout, and neither can push the transport controls around. + display: "-webkit-box", + WebkitLineClamp: 2, + WebkitBoxOrient: "vertical", + overflow: "hidden", +}) + +export const RetryButton = styled(SpeedButton)(({ theme }) => ({ + borderColor: theme.custom.colors.red, + color: theme.custom.colors.red, +})) diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastEmbedPlayer.test.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastEmbedPlayer.test.tsx index 9de32b0ac2..ee2ac06ea3 100644 --- a/frontends/main/src/app-pages/PodcastPage/PodcastEmbedPlayer.test.tsx +++ b/frontends/main/src/app-pages/PodcastPage/PodcastEmbedPlayer.test.tsx @@ -308,6 +308,69 @@ describe("PodcastEmbedPlayer", () => { }) }) + describe("playback errors", () => { + const simulateMediaError = (audio: HTMLAudioElement, code: number) => { + Object.defineProperty(audio, "error", { + value: { code }, + configurable: true, + }) + fireEvent.error(audio) + } + + test("shows a message when the source is rejected (e.g. HTTP 451)", async () => { + const { audio } = renderPlayer() + simulateMediaError(audio, 4) // MEDIA_ERR_SRC_NOT_SUPPORTED + + expect(await screen.findByRole("alert")).toHaveTextContent( + /unavailable in your region/i, + ) + }) + + test("replaces the seek slider with the message", async () => { + const { audio } = renderPlayer() + expect(screen.getByRole("slider", { name: /seek/i })).toBeInTheDocument() + + simulateMediaError(audio, 2) + + await screen.findByRole("alert") + expect( + screen.queryByRole("slider", { name: /seek/i }), + ).not.toBeInTheDocument() + }) + + test("reports missing audio for an episode with no audio_url", () => { + const resource = makeEpisode({ + podcast_episode: { + ...makeEpisode().podcast_episode!, + audio_url: null as unknown as string, + episode_link: null, + }, + }) + renderPlayer(resource) + + expect(screen.getByRole("alert")).toHaveTextContent( + /audio isn't available for this episode/i, + ) + expect( + screen.queryByRole("button", { name: /try again/i }), + ).not.toBeInTheDocument() + }) + + test("Try again reloads the source and clears the message", async () => { + const { audio } = renderPlayer() + simulateMediaError(audio, 2) + await screen.findByRole("alert") + + jest.clearAllMocks() + fireEvent.click(screen.getByRole("button", { name: /try again/i })) + + expect(window.HTMLMediaElement.prototype.load).toHaveBeenCalled() + await waitFor(() => + expect(screen.queryByRole("alert")).not.toBeInTheDocument(), + ) + }) + }) + describe("no close button", () => { test("does not render a close button", () => { renderPlayer() diff --git a/frontends/main/src/app-pages/PodcastPage/PodcastEmbedPlayer.tsx b/frontends/main/src/app-pages/PodcastPage/PodcastEmbedPlayer.tsx index c0d6097cfe..763dc413d2 100644 --- a/frontends/main/src/app-pages/PodcastPage/PodcastEmbedPlayer.tsx +++ b/frontends/main/src/app-pages/PodcastPage/PodcastEmbedPlayer.tsx @@ -2,15 +2,18 @@ import React from "react" import { styled } from "ol-components" +import { VisuallyHidden } from "@mitodl/smoot-design" import { RiPlayCircleLine, RiPauseCircleLine, RiReplay10Line, RiForward30Line, + RiErrorWarningLine, } from "@remixicon/react" import type { LearningResource } from "api/v1" import { getEpisodeAudioUrl } from "./PodcastsListingPage/helpers" import { useAudioPlayer, formatClockTime } from "./useAudioPlayer" +import { usePlaybackRecovery, RETRYING_STATUS } from "./usePlaybackRecovery" import { TrackInfo as TrackInfoBase, TrackTitle, @@ -23,6 +26,9 @@ import { ProgressRange, TimeLabel, SpeedButton as SpeedButtonBase, + PlaybackError, + PlaybackErrorText, + RetryButton, } from "./AudioPlayer.styled" // ─── Styled components (card layout) ──────────────────────────────────────────── @@ -129,12 +135,23 @@ const PodcastEmbedPlayer: React.FC = ({ duration, percent, speed, + error, togglePlay, skip, cycleSpeed, seek, + retry, } = useAudioPlayer(audioUrl) + const isPlayDisabled = isBuffering || isPlayPending || !hasAudioSource + const { + playButtonRef, + retryButtonRef, + onProgressFocus, + requestRetry, + isRetrying, + } = usePlaybackRecovery(error, retry, isPlayDisabled) + const Wrapper = inline ? InlineWrapper : Shell return ( @@ -142,7 +159,19 @@ const PodcastEmbedPlayer: React.FC = ({ {/* eslint-disable-next-line jsx-a11y/media-has-caption */}