Add real podcast subsystem: RSS feed refresh, bounded background downloads, restart recovery

This commit is contained in:
2026-08-23 00:24:43 -05:00
parent 8d93bea384
commit fce44a2ffd
6 changed files with 809 additions and 0 deletions
+240
View File
@@ -0,0 +1,240 @@
package podcasts
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os/exec"
"path/filepath"
"testing"
"time"
"git.noroutes.net/NoRoutes/vibetunes/internal/database"
)
const testFeedXML = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
<channel>
<title>Test Podcast</title>
<description>A podcast for testing</description>
<item>
<title>Episode One</title>
<description>The first episode</description>
<guid>episode-1-guid</guid>
<pubDate>Mon, 01 Jan 2024 00:00:00 GMT</pubDate>
<itunes:duration>125</itunes:duration>
<enclosure url="%s/audio.mp3" type="audio/mpeg" length="12345"/>
</item>
<item>
<title>Episode Two</title>
<description>The second episode</description>
<guid>episode-2-guid</guid>
<pubDate>Tue, 02 Jan 2024 00:00:00 GMT</pubDate>
<itunes:duration>2:05</itunes:duration>
<enclosure url="%s/audio.mp3" type="audio/mpeg" length="12345"/>
</item>
</channel>
</rss>`
func TestParseRSS(t *testing.T) {
feed, err := parseRSS([]byte(fmt.Sprintf(testFeedXML, "http://x", "http://x")))
if err != nil {
t.Fatalf("parseRSS: %v", err)
}
if feed.Channel.Title != "Test Podcast" {
t.Errorf("Title = %q", feed.Channel.Title)
}
if len(feed.Channel.Items) != 2 {
t.Fatalf("Items = %d, want 2", len(feed.Channel.Items))
}
if feed.Channel.Items[0].episodeGUID() != "episode-1-guid" {
t.Errorf("guid = %q", feed.Channel.Items[0].episodeGUID())
}
}
func TestParseDuration(t *testing.T) {
cases := map[string]float64{
"125": 125,
"2:05": 125,
"1:02:05": 3725,
"": 0,
"bogus": 0,
}
for in, want := range cases {
if got := parseDuration(in); got != want {
t.Errorf("parseDuration(%q) = %v, want %v", in, got, want)
}
}
}
func newTestManager(t *testing.T) (*Manager, string) {
t.Helper()
dir := t.TempDir()
db, err := database.Open(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("database.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
log := slog.New(slog.NewTextHandler(io.Discard, nil))
return NewManager(db, dir, "", log), dir
}
func TestRefreshChannel(t *testing.T) {
m, _ := newTestManager(t)
var feedURL string
mux := http.NewServeMux()
mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, testFeedXML, feedURL, feedURL)
})
mux.HandleFunc("/audio.mp3", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("fake mp3 bytes"))
})
srv := httptest.NewServer(mux)
defer srv.Close()
feedURL = srv.URL
channel, err := database.CreatePodcastChannel(m.DB, srv.URL+"/feed.xml")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := m.RefreshChannel(ctx, channel.ID); err != nil {
t.Fatalf("RefreshChannel: %v", err)
}
updated, err := database.GetPodcastChannel(m.DB, channel.ID)
if err != nil {
t.Fatal(err)
}
if updated.Title != "Test Podcast" {
t.Errorf("channel title = %q", updated.Title)
}
if updated.Status != "completed" {
t.Errorf("channel status = %q, want completed", updated.Status)
}
episodes, err := database.ListPodcastEpisodes(m.DB, channel.ID)
if err != nil {
t.Fatal(err)
}
if len(episodes) != 2 {
t.Fatalf("episodes = %d, want 2", len(episodes))
}
}
func TestRefreshChannelIsIdempotent(t *testing.T) {
m, _ := newTestManager(t)
var feedURL string
mux := http.NewServeMux()
mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, testFeedXML, feedURL, feedURL)
})
srv := httptest.NewServer(mux)
defer srv.Close()
feedURL = srv.URL
channel, err := database.CreatePodcastChannel(m.DB, srv.URL+"/feed.xml")
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
if err := m.RefreshChannel(ctx, channel.ID); err != nil {
t.Fatalf("RefreshChannel (1st): %v", err)
}
episodes1, _ := database.ListPodcastEpisodes(m.DB, channel.ID)
if err := m.RefreshChannel(ctx, channel.ID); err != nil {
t.Fatalf("RefreshChannel (2nd): %v", err)
}
episodes2, _ := database.ListPodcastEpisodes(m.DB, channel.ID)
if len(episodes2) != len(episodes1) {
t.Fatalf("refresh duplicated episodes: %d -> %d", len(episodes1), len(episodes2))
}
if episodes1[0].ID != episodes2[0].ID {
t.Error("episode ID changed across refresh")
}
}
func TestDownloadEpisode(t *testing.T) {
if _, err := exec.LookPath("ffprobe"); err != nil {
t.Skip("ffprobe not installed")
}
m, _ := newTestManager(t)
var feedURL string
mux := http.NewServeMux()
mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, testFeedXML, feedURL, feedURL)
})
mux.HandleFunc("/audio.mp3", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("not really mp3 but bytes"))
})
srv := httptest.NewServer(mux)
defer srv.Close()
feedURL = srv.URL
channel, err := database.CreatePodcastChannel(m.DB, srv.URL+"/feed.xml")
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
if err := m.RefreshChannel(ctx, channel.ID); err != nil {
t.Fatalf("RefreshChannel: %v", err)
}
episodes, _ := database.ListPodcastEpisodes(m.DB, channel.ID)
if err := m.DownloadEpisode(ctx, episodes[0].ID); err != nil {
t.Fatalf("DownloadEpisode: %v", err)
}
ep, err := database.GetPodcastEpisode(m.DB, episodes[0].ID)
if err != nil {
t.Fatal(err)
}
if ep.Status != "completed" {
t.Fatalf("episode status = %q, want completed", ep.Status)
}
if !ep.MediaFileID.Valid {
t.Fatal("expected episode to have a media_file_id after download")
}
mf, err := database.GetMediaFile(m.DB, ep.MediaFileID.String)
if err != nil {
t.Fatalf("GetMediaFile: %v", err)
}
if mf.SizeBytes == 0 {
t.Error("downloaded media file has zero size")
}
}
func TestRecoverInterruptedDownloads(t *testing.T) {
m, _ := newTestManager(t)
channel, err := database.CreatePodcastChannel(m.DB, "http://example.com/feed.xml")
if err != nil {
t.Fatal(err)
}
ep, err := database.UpsertPodcastEpisode(m.DB, channel.ID, "guid1", "Ep", "", "", "http://example.com/a.mp3", 0)
if err != nil {
t.Fatal(err)
}
if err := database.SetPodcastEpisodeStatus(m.DB, ep.ID, "downloading"); err != nil {
t.Fatal(err)
}
if err := m.RecoverInterruptedDownloads(); err != nil {
t.Fatalf("RecoverInterruptedDownloads: %v", err)
}
got, err := database.GetPodcastEpisode(m.DB, ep.ID)
if err != nil {
t.Fatal(err)
}
if got.Status != "new" {
t.Fatalf("status after recovery = %q, want new", got.Status)
}
}