diff --git a/server/subsonic/e2e/subsonic_sonic_similarity_test.go b/server/subsonic/e2e/subsonic_sonic_similarity_test.go index c0cb1d359..3c71a994c 100644 --- a/server/subsonic/e2e/subsonic_sonic_similarity_test.go +++ b/server/subsonic/e2e/subsonic_sonic_similarity_test.go @@ -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, ) } diff --git a/ui/src/podcast/EpisodeActions.jsx b/ui/src/podcast/EpisodeActions.jsx index ce55cc0c8..edea9bd34 100644 --- a/ui/src/podcast/EpisodeActions.jsx +++ b/ui/src/podcast/EpisodeActions.jsx @@ -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 } if (episode.status === 'completed') { return ( - - - + <> + + + + + + + ) } diff --git a/ui/src/podcast/PodcastCreate.test.jsx b/ui/src/podcast/PodcastCreate.test.jsx index 2a88379a4..948594439 100644 --- a/ui/src/podcast/PodcastCreate.test.jsx +++ b/ui/src/podcast/PodcastCreate.test.jsx @@ -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 }) => ( + + ), } }) 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() - 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() + 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() - 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() - 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', diff --git a/ui/src/podcast/PodcastShow.jsx b/ui/src/podcast/PodcastShow.jsx index 6f98b4d5a..2a462effc 100644 --- a/ui/src/podcast/PodcastShow.jsx +++ b/ui/src/podcast/PodcastShow.jsx @@ -212,7 +212,11 @@ const PodcastShow = (props) => { - + ))} diff --git a/ui/src/podcast/StatusBadge.jsx b/ui/src/podcast/StatusBadge.jsx index fd968e1ee..a78a6f9b5 100644 --- a/ui/src/podcast/StatusBadge.jsx +++ b/ui/src/podcast/StatusBadge.jsx @@ -22,6 +22,7 @@ const StatusBadge = ({ status, errorMessage, downloadedBytes, size }) => { return ( + {label} {pct !== null && {`${pct}%`}} )