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 = ` Test Podcast A podcast for testing Episode One The first episode episode-1-guid Mon, 01 Jan 2024 00:00:00 GMT 125 Episode Two The second episode episode-2-guid Tue, 02 Jan 2024 00:00:00 GMT 2:05 ` 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)) // Tests talk to httptest.Server, which listens on loopback, so // private networks must be allowed here even though production // defaults to blocking them (see internal/netguard). return NewManager(db, dir, "", log, true), 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) } }