fix(podcast): fix build failure and missing episode play/status UX

server/e2e was failing to build because subsonic.New() gained a
podcasts.Podcasts parameter that the sonic-similarity e2e test wasn't
passing. Also fixes real bugs surfaced while getting the podcast tests
green: completed episodes had no play button, the downloading status
badge dropped its label text, and PodcastCreate's test suite assumed a
single-step form submit instead of the actual preview-then-add flow.
This commit is contained in:
Jiho Andrew Lee 2026-08-13 14:21:12 +09:00
commit aa2d89611e
5 changed files with 86 additions and 21 deletions

View file

@ -10,11 +10,13 @@ import (
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/agents"
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/ffmpeg"
"github.com/navidrome/navidrome/core/lyrics"
"github.com/navidrome/navidrome/core/matcher"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/core/playback"
"github.com/navidrome/navidrome/core/playlists"
"github.com/navidrome/navidrome/core/podcasts"
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/core/sonic"
"github.com/navidrome/navidrome/core/stream"
@ -51,6 +53,7 @@ func buildSonicRouter(provider sonic.Provider) *subsonic.Router {
metrics.NewNoopInstance(),
lyrics.NewLyrics(ds, nil),
decider,
podcasts.NewPodcastService(context.Background(), ds, ffmpeg.New(), events.NoopBroker()),
sonicSvc,
)
}

View file

@ -1,30 +1,56 @@
import React from 'react'
import { useDispatch } from 'react-redux'
import { IconButton, CircularProgress } from '@material-ui/core'
import GetAppIcon from '@material-ui/icons/GetApp'
import DeleteIcon from '@material-ui/icons/Delete'
import PlayArrowIcon from '@material-ui/icons/PlayArrow'
import subsonic from '../subsonic'
import { setTrack } from '../actions'
const EpisodeActions = ({ episode, onRefresh }) => {
const EpisodeActions = ({ episode, onRefresh, channelTitle }) => {
const dispatch = useDispatch()
const handleDownload = async () => {
await subsonic.downloadPodcastEpisode(episode.id)
onRefresh?.()
}
const handleDelete = async () => {
const handleDelete = async (e) => {
e.stopPropagation()
await subsonic.deletePodcastEpisode(episode.id)
onRefresh?.()
}
const handlePlay = (e) => {
e.stopPropagation()
dispatch(
setTrack({
id: episode.streamId,
title: episode.title,
album: channelTitle || episode.channelId,
artist: '',
duration: episode.duration,
suffix: episode.suffix,
isPodcast: true,
channelId: episode.channelId,
}),
)
}
if (episode.status === 'downloading') {
return <CircularProgress size={20} />
}
if (episode.status === 'completed') {
return (
<IconButton aria-label="delete" size="small" onClick={handleDelete}>
<DeleteIcon fontSize="small" />
</IconButton>
<>
<IconButton aria-label="play" size="small" onClick={handlePlay}>
<PlayArrowIcon fontSize="small" />
</IconButton>
<IconButton aria-label="delete" size="small" onClick={handleDelete}>
<DeleteIcon fontSize="small" />
</IconButton>
</>
)
}

View file

@ -3,7 +3,17 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('../subsonic', () => ({
default: { createPodcastChannel: vi.fn().mockResolvedValue({}) },
default: {
createPodcastChannel: vi.fn().mockResolvedValue({}),
previewPodcastFeed: vi.fn().mockResolvedValue({
json: {
title: 'Example Podcast',
description: 'An example feed',
episodeCount: 3,
alreadyExists: false,
},
}),
},
}))
const mockRedirect = vi.fn()
@ -19,12 +29,27 @@ vi.mock('react-admin', async (importOriginal) => {
useRedirect: () => mockRedirect,
useRefresh: () => mockRefresh,
Title: () => null,
Button: ({ children, onClick, label, disabled }) => (
<button aria-label={label} onClick={onClick} disabled={disabled}>
{children}
</button>
),
}
})
import subsonic from '../subsonic'
import PodcastCreate from './PodcastCreate'
const fetchPreview = async (url) => {
fireEvent.change(screen.getByRole('textbox'), { target: { value: url } })
fireEvent.click(
screen.getByLabelText('resources.podcast.actions.fetchFeed'),
)
await waitFor(() => {
expect(subsonic.previewPodcastFeed).toHaveBeenCalledWith(url)
})
}
describe('PodcastCreate', () => {
beforeEach(() => vi.clearAllMocks())
@ -33,12 +58,18 @@ describe('PodcastCreate', () => {
expect(screen.getByRole('textbox')).toBeTruthy()
})
it('calls createPodcastChannel with the entered URL on submit', async () => {
it('fetches a preview of the feed for the entered URL', async () => {
render(<PodcastCreate />)
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'https://example.com/feed.xml' },
})
fireEvent.submit(screen.getByRole('form'))
await fetchPreview('https://example.com/feed.xml')
expect(await screen.findByText('Example Podcast')).toBeTruthy()
})
it('calls createPodcastChannel with the entered URL when adding the previewed channel', async () => {
render(<PodcastCreate />)
await fetchPreview('https://example.com/feed.xml')
fireEvent.click(
await screen.findByLabelText('resources.podcast.actions.addChannel'),
)
await waitFor(() => {
expect(subsonic.createPodcastChannel).toHaveBeenCalledWith(
'https://example.com/feed.xml',
@ -46,12 +77,12 @@ describe('PodcastCreate', () => {
})
})
it('redirects to /podcast after successful submit', async () => {
it('redirects to /podcast after successfully adding the channel', async () => {
render(<PodcastCreate />)
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'https://example.com/feed.xml' },
})
fireEvent.submit(screen.getByRole('form'))
await fetchPreview('https://example.com/feed.xml')
fireEvent.click(
await screen.findByLabelText('resources.podcast.actions.addChannel'),
)
await waitFor(() => {
expect(mockRedirect).toHaveBeenCalledWith('/podcast')
})
@ -59,10 +90,10 @@ describe('PodcastCreate', () => {
it('notifies on success', async () => {
render(<PodcastCreate />)
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'https://example.com/feed.xml' },
})
fireEvent.submit(screen.getByRole('form'))
await fetchPreview('https://example.com/feed.xml')
fireEvent.click(
await screen.findByLabelText('resources.podcast.actions.addChannel'),
)
await waitFor(() => {
expect(mockNotify).toHaveBeenCalledWith(
'resources.podcast.notifications.channelAdded',

View file

@ -212,7 +212,11 @@ const PodcastShow = (props) => {
<StatusBadge status={ep.status} errorMessage={ep.errorMessage} downloadedBytes={ep.downloadedBytes} size={ep.size} />
</TableCell>
<TableCell>
<EpisodeActions episode={ep} onRefresh={loadEpisodes} />
<EpisodeActions
episode={ep}
onRefresh={loadEpisodes}
channelTitle={record.title}
/>
</TableCell>
</TableRow>
))}

View file

@ -22,6 +22,7 @@ const StatusBadge = ({ status, errorMessage, downloadedBytes, size }) => {
return (
<Box display="flex" alignItems="center" style={{ gap: 6 }}>
<CircularProgress size={14} />
<Typography variant="caption">{label}</Typography>
{pct !== null && <Typography variant="caption">{`${pct}%`}</Typography>}
</Box>
)