Add real podcast subsystem: RSS feed refresh, bounded background downloads, restart recovery
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user