navidrome/persistence/persistence_suite_test.go
Jiho Andrew Lee 775747264b feat(podcast): add podcast feature with UX improvements - #5420
Backend
- Add podcast data model (PodcastChannel, PodcastEpisode) with migrations
- Implement Subsonic API endpoints: getPodcasts, getNewestPodcasts,
  createPodcastChannel, refreshPodcasts, deletePodcastChannel,
  deletePodcastEpisode, downloadPodcastEpisode, getPodcastEpisode
- Add native REST API endpoints: GET/DELETE /api/podcast,
  GET /api/podcast/preview (feed info without creating channel)
- Inject events.Broker into podcast service for SSE support
- Emit PodcastEpisodeProgress SSE events during download (every 512 KB)
  and on completion/error with status field
- Use HTTP Content-Length as fallback when RSS feed omits enclosure size
- Write ID3 tags (title, album, genre=Podcast) to downloaded files via
  ffmpeg so the library scanner reads correct metadata
- Set MediaFile fields (Title, Album, AlbumID=channelId, AlbumArtist,
  Genre) on episode registration
- Add duplicate URL check in AddChannel
- Add ExistsByURL to PodcastChannelRepository

Frontend - Podcast list
- Add grid/list view toggle (Redux podcastViewReducer) matching album list
- New PodcastGridView component with responsive column count (2-6 cols)
- Cover image 100px in table view
- Remove Feed URL column; add inline copy-to-clipboard button

Frontend - Podcast show (episode list)
- Real-time download progress (%) in Status column via SSE, no polling
  - Spinner only before first SSE event; N% once data arrives
  - Size column removed; Downloading badge replaced with progress
- Completed episodes play on row click; separate play button removed
- Play / Shuffle / Play Next / Add to Queue buttons above episode list
  (only shown when completed episodes exist)
- On download completion, reload episodes to obtain streamId for
  immediate playback without page refresh
- Clicking album name in AudioTitle navigates to podcast channel page

Frontend - Podcast create
- Full-width URL input with Fetch Feed Info button and Enter key support
- Preview card (cover image, title, episode count, description) before
  committing channel creation
- Add Channel button appears only after preview; shows already-registered
  message if channel URL exists

Frontend - Playlist
- Album link navigates to podcast channel page for podcast tracks
  (identified by genre=Podcast)
- Artist column shows '-' for podcast tracks with empty artist field

Closes #5420

Signed-off-by: ji-ho lee <search5@gmail.com>
2026-09-19 21:07:04 +09:00

395 lines
14 KiB
Go

package persistence
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/Masterminds/squirrel"
_ "github.com/mattn/go-sqlite3"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/pocketbase/dbx"
)
func TestPersistence(t *testing.T) {
tests.Init(t, true)
//os.Remove("./test-123.db")
//conf.Server.DbPath = "./test-123.db"
conf.Server.DbPath = "file::memory:?cache=shared&_foreign_keys=on"
defer db.Init(context.Background())()
log.SetLevel(log.LevelFatal)
RegisterFailHandler(Fail)
RunSpecs(t, "Persistence Suite")
}
func mf(mf model.MediaFile) model.MediaFile {
mf.Tags = model.Tags{}
mf.LibraryID = 1
mf.LibraryPath = "music" // Default folder
mf.LibraryName = "Music Library"
mf.Participants = model.Participants{
model.RoleArtist: model.ParticipantList{
model.Participant{Artist: model.Artist{ID: mf.ArtistID, Name: mf.Artist}},
},
}
if mf.Lyrics == "" {
mf.Lyrics = "[]"
}
return mf
}
func al(al model.Album) model.Album {
al.LibraryID = 1
al.LibraryPath = "music"
al.LibraryName = "Music Library"
al.Discs = model.Discs{}
al.Tags = model.Tags{}
al.Participants = model.Participants{}
return al
}
func alWithTags(a model.Album, tags model.Tags) model.Album {
a = al(a)
a.Tags = tags
return a
}
var (
artistKraftwerk = model.Artist{ID: "2", Name: "Kraftwerk", OrderArtistName: "kraftwerk"}
artistBeatles = model.Artist{ID: "3", Name: "The Beatles", OrderArtistName: "beatles"}
artistCJK = model.Artist{ID: "4", Name: "シートベルツ", SortArtistName: "Seatbelts", OrderArtistName: "seatbelts"}
artistPunctuation = model.Artist{ID: "5", Name: "The Roots", OrderArtistName: "roots"}
testArtists = model.Artists{
artistKraftwerk,
artistBeatles,
artistCJK,
artistPunctuation,
}
)
var (
albumSgtPeppers = al(model.Album{ID: "101", Name: "Sgt Peppers", AlbumArtist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", EmbedArtPath: p("beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967})
albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969})
albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("kraft/radio/radio.mp3"), SongCount: 2})
albumMultiDisc = al(model.Album{ID: "104", Name: "Multi Disc Album", AlbumArtist: "Test Artist", OrderAlbumName: "multi disc album", AlbumArtistID: "1", EmbedArtPath: p("test/multi/disc1/track1.mp3"), SongCount: 4})
albumCJK = al(model.Album{ID: "105", Name: "COWBOY BEBOP", AlbumArtist: "シートベルツ", OrderAlbumName: "cowboy bebop", AlbumArtistID: "4", EmbedArtPath: p("seatbelts/cowboy-bebop/track1.mp3"), SongCount: 1})
albumWithVersion = alWithTags(model.Album{ID: "106", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("beatles/2/come together.mp3"), SongCount: 1, MaxYear: 2019},
model.Tags{model.TagAlbumVersion: {"Deluxe Edition"}})
albumPunctuation = al(model.Album{ID: "107", Name: "Things Fall Apart", AlbumArtist: "The Roots", OrderAlbumName: "things fall apart", AlbumArtistID: "5", EmbedArtPath: p("roots/things/track1.mp3"), SongCount: 1})
testAlbums = model.Albums{
albumSgtPeppers,
albumAbbeyRoad,
albumRadioactivity,
albumMultiDisc,
albumCJK,
albumWithVersion,
albumPunctuation,
}
)
var (
songDayInALife = mf(model.MediaFile{ID: "1001", Title: "A Day In A Life", ArtistID: "3", Artist: "The Beatles", AlbumID: "101", Album: "Sgt Peppers", Path: p("beatles/1/sgt/a day.mp3")})
songComeTogether = mf(model.MediaFile{ID: "1002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "102", Album: "Abbey Road", Path: p("beatles/1/come together.mp3")})
songRadioactivity = mf(model.MediaFile{ID: "1003", Title: "Radioactivity", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Album: "Radioactivity", Path: p("kraft/radio/radio.mp3")})
songAntenna = mf(model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk",
AlbumID: "103",
Path: p("kraft/radio/antenna.mp3"),
RGAlbumGain: new(1.0), RGAlbumPeak: new(2.0), RGTrackGain: new(3.0), RGTrackPeak: new(4.0),
})
songAntennaWithLyrics = mf(model.MediaFile{
ID: "1005",
Title: "Antenna",
ArtistID: "2",
Artist: "Kraftwerk",
AlbumID: "103",
Lyrics: `[{"lang":"xxx","line":[{"value":"This is a set of lyrics"}],"synced":false}]`,
})
songAntenna2 = mf(model.MediaFile{ID: "1006", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103"})
// Multi-disc album tracks (intentionally out of order to test sorting)
songDisc2Track11 = mf(model.MediaFile{ID: "2001", Title: "Disc 2 Track 11", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 11, Path: p("test/multi/disc2/track11.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
songDisc1Track01 = mf(model.MediaFile{ID: "2002", Title: "Disc 1 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 1, Path: p("test/multi/disc1/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
songCJK = mf(model.MediaFile{ID: "3001", Title: "プラチナ・ジェット", ArtistID: "4", Artist: "シートベルツ", AlbumID: "105", Album: "COWBOY BEBOP", Path: p("seatbelts/cowboy-bebop/track1.mp3")})
songVersioned = mf(model.MediaFile{ID: "3002", Title: "Come Together", ArtistID: "3", Artist: "The Beatles", AlbumID: "106", Album: "Abbey Road", Path: p("beatles/2/come together.mp3")})
songPunctuation = mf(model.MediaFile{ID: "3003", Title: "!!!!!!!", ArtistID: "5", Artist: "The Roots", AlbumID: "107", Album: "Things Fall Apart", Path: p("roots/things/track1.mp3")})
testSongs = model.MediaFiles{
songDayInALife,
songComeTogether,
songRadioactivity,
songAntenna,
songAntennaWithLyrics,
songAntenna2,
songDisc2Track11,
songDisc1Track01,
songDisc2Track01,
songDisc1Track02,
songCJK,
songVersioned,
songPunctuation,
}
)
var (
radioWithoutHomePage = model.Radio{ID: "1235", StreamUrl: "https://example.com:8000/1/stream.mp3", HomePageUrl: "", Name: "No Homepage"}
radioWithHomePage = model.Radio{ID: "5010", StreamUrl: "https://example.com/stream.mp3", Name: "Example Radio", HomePageUrl: "https://example.com"}
testRadios = model.Radios{radioWithoutHomePage, radioWithHomePage}
)
var (
podcastChannel1 = model.PodcastChannel{
ID: "pc-1",
URL: "https://example.com/feed1.xml",
Title: "Test Podcast",
Description: "A test podcast",
Status: model.PodcastStatusCompleted,
}
podcastChannel2 = model.PodcastChannel{
ID: "pc-2",
URL: "https://example.com/feed2.xml",
Title: "Another Podcast",
Status: model.PodcastStatusNew,
}
testPodcastChannels = model.PodcastChannels{podcastChannel1, podcastChannel2}
podcastEpisode1 = model.PodcastEpisode{
ID: "pe-1",
ChannelID: "pc-1",
GUID: "guid-001",
Title: "Episode 1",
Status: model.PodcastStatusCompleted,
PublishDate: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
}
podcastEpisode2 = model.PodcastEpisode{
ID: "pe-2",
ChannelID: "pc-1",
GUID: "guid-002",
Title: "Episode 2",
Status: model.PodcastStatusNew,
PublishDate: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
}
testPodcastEpisodes = model.PodcastEpisodes{podcastEpisode1, podcastEpisode2}
)
var (
plsBest model.Playlist
plsCool model.Playlist
testPlaylists []*model.Playlist
)
var (
adminUser = model.User{ID: "userid", UserName: "userid", Name: "admin", Email: "admin@email.com", IsAdmin: true}
regularUser = model.User{ID: "2222", UserName: "regular-user", Name: "Regular User", Email: "regular@example.com"}
thirdUser = model.User{ID: "3333", UserName: "third-user", Name: "Third User", Email: "third@example.com"}
testUsers = model.Users{adminUser, regularUser, thirdUser}
)
var (
firstScrobble = model.Scrobble{ID: 1, MediaFileID: "1001", UserID: "userid", SubmissionTime: time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC).Unix()}
secondScrobble = model.Scrobble{ID: 2, MediaFileID: "1003", UserID: "2222", SubmissionTime: time.Date(1970, 2, 1, 0, 0, 0, 0, time.UTC).Unix()}
thirdScrobble = model.Scrobble{ID: 3, MediaFileID: "1002", UserID: "userid", SubmissionTime: time.Date(1970, 3, 1, 0, 0, 0, 0, time.UTC).Unix()}
scrobbles = model.Scrobbles{firstScrobble, secondScrobble, thirdScrobble}
)
func p(path string) string {
return filepath.FromSlash(path)
}
var _ = BeforeSuite(func() {
conn := GetDBXBuilder()
ctx := log.NewContext(context.TODO())
ctx = request.WithUser(ctx, adminUser)
ur := NewUserRepository(ctx, conn)
for i := range testUsers {
err := ur.Put(&testUsers[i])
if err != nil {
panic(err)
}
}
// Associate users with library 1 (default test library)
for i := range testUsers {
err := ur.SetUserLibraries(testUsers[i].ID, []int{1})
if err != nil {
panic(err)
}
}
alr := NewAlbumRepository(ctx, conn).(*albumRepository)
for i := range testAlbums {
err := alr.Put(new(testAlbums[i]))
if err != nil {
panic(err)
}
}
arr := NewArtistRepository(ctx, conn)
for i := range testArtists {
err := arr.Put(new(testArtists[i]))
if err != nil {
panic(err)
}
}
// Associate artists with library 1 (default test library)
lr := NewLibraryRepository(ctx, conn)
for i := range testArtists {
err := lr.AddArtist(1, testArtists[i].ID)
if err != nil {
panic(err)
}
}
// Populate album_artists based on the AlbumArtistID relationships in testAlbums
artistIDs := map[string]bool{}
for _, a := range testArtists {
artistIDs[a.ID] = true
}
for i := range testAlbums {
a := testAlbums[i]
if a.AlbumArtistID == "" || !artistIDs[a.AlbumArtistID] {
continue
}
_, err := alr.executeSQL(squirrel.Insert("album_artists").SetMap(map[string]any{
"album_id": a.ID,
"artist_id": a.AlbumArtistID,
"role": "artist",
"sub_role": "",
}))
if err != nil {
panic(err)
}
}
mr := NewMediaFileRepository(ctx, conn)
for i := range testSongs {
err := mr.Put(&testSongs[i])
if err != nil {
panic(err)
}
}
rar := NewRadioRepository(ctx, conn)
for i := range testRadios {
err := rar.Put(new(testRadios[i]))
if err != nil {
panic(err)
}
}
pcr := NewPodcastChannelRepository(ctx, conn)
for i := range testPodcastChannels {
c := testPodcastChannels[i]
if err := pcr.Create(&c); err != nil {
panic(err)
}
}
per := NewPodcastEpisodeRepository(ctx, conn)
for i := range testPodcastEpisodes {
e := testPodcastEpisodes[i]
if err := per.Create(&e); err != nil {
panic(err)
}
}
plsBest = model.Playlist{
Name: "Best",
Comment: "No Comments",
OwnerID: "userid",
OwnerName: "userid",
Public: true,
SongCount: 2,
}
plsBest.AddMediaFilesByID([]string{"1001", "1003"})
plsCool = model.Playlist{Name: "Cool", OwnerID: "userid", OwnerName: "userid"}
plsCool.AddMediaFilesByID([]string{"1004"})
testPlaylists = []*model.Playlist{&plsBest, &plsCool}
pr := NewPlaylistRepository(ctx, conn)
for i := range testPlaylists {
err := pr.Put(testPlaylists[i])
if err != nil {
panic(err)
}
}
// Prepare annotations
if err := arr.SetStar(true, artistBeatles.ID); err != nil {
panic(err)
}
ar, err := arr.Get(artistBeatles.ID)
if err != nil {
panic(err)
}
if ar == nil {
panic("artist not found after SetStar")
}
artistBeatles.Starred = true
artistBeatles.StarredAt = ar.StarredAt
testArtists[1] = artistBeatles
if err := alr.SetStar(true, albumRadioactivity.ID); err != nil {
panic(err)
}
al, err := alr.Get(albumRadioactivity.ID)
if err != nil {
panic(err)
}
if al == nil {
panic("album not found after SetStar")
}
albumRadioactivity.Starred = true
albumRadioactivity.StarredAt = al.StarredAt
testAlbums[2] = albumRadioactivity
if err := mr.SetStar(true, songComeTogether.ID); err != nil {
panic(err)
}
mf, err := mr.Get(songComeTogether.ID)
if err != nil {
panic(err)
}
songComeTogether.Starred = true
songComeTogether.StarredAt = mf.StarredAt
testSongs[1] = songComeTogether
scrobbleRepo := NewScrobbleRepository(ctx, conn).(*scrobbleRepository)
for _, s := range scrobbles {
_, err := scrobbleRepo.executeSQL(squirrel.Insert("scrobbles").SetMap(map[string]any{
"media_file_id": s.MediaFileID,
"user_id": s.UserID,
"submission_time": s.SubmissionTime,
}))
if err != nil {
panic(err)
}
}
})
func GetDBXBuilder() *dbx.DB {
return dbx.NewFromDB(db.Db(), db.Dialect)
}
// collectCursor takes the cursor's underlying func type so the named cursor types
// (model.AlbumCursor, ...) infer T.
func collectCursor[T any](cursor func(func(T, error) bool), err error) []T {
GinkgoHelper()
Expect(err).ToNot(HaveOccurred())
var out []T
for item, err := range cursor {
Expect(err).ToNot(HaveOccurred())
out = append(out, item)
}
return out
}