From fce44a2ffd0dcf2e2af0419fa94916e46589a4d5 Mon Sep 17 00:00:00 2001 From: Owen Rummage Date: Sun, 23 Aug 2026 00:24:43 -0500 Subject: [PATCH] Add real podcast subsystem: RSS feed refresh, bounded background downloads, restart recovery --- internal/database/podcasts.go | 206 ++++++++++++ internal/podcasts/podcasts.go | 299 ++++++++++++++++++ internal/podcasts/podcasts_test.go | 240 ++++++++++++++ internal/podcasts/rss.go | 57 ++++ .../index.m3u8 | 7 + .../seg00000.ts | Bin 0 -> 19928 bytes 6 files changed, 809 insertions(+) create mode 100644 internal/database/podcasts.go create mode 100644 internal/podcasts/podcasts.go create mode 100644 internal/podcasts/podcasts_test.go create mode 100644 internal/podcasts/rss.go create mode 100644 internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist3184291526_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/index.m3u8 create mode 100644 internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist3184291526_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/seg00000.ts diff --git a/internal/database/podcasts.go b/internal/database/podcasts.go new file mode 100644 index 0000000..359562b --- /dev/null +++ b/internal/database/podcasts.go @@ -0,0 +1,206 @@ +package database + +import ( + "database/sql" + "errors" + "time" + + "git.noroutes.net/NoRoutes/vibetunes/internal/idgen" +) + +type PodcastChannel struct { + ID string + URL string + Title string + Description string + CoverArtID sql.NullString + Status string // new, downloading, completed, error + ErrorMessage string + CreatedAt time.Time + UpdatedAt time.Time +} + +const podcastChannelSelect = `SELECT id, url, title, description, cover_art_id, status, error_message, created_at, updated_at FROM podcast_channels` + +func CreatePodcastChannel(db *sql.DB, url string) (*PodcastChannel, error) { + id := idgen.New("pc") + _, err := db.Exec(`INSERT INTO podcast_channels (id, url) VALUES (?, ?)`, id, url) + if err != nil { + return nil, err + } + return GetPodcastChannel(db, id) +} + +func GetPodcastChannel(db *sql.DB, id string) (*PodcastChannel, error) { + return scanPodcastChannel(db.QueryRow(podcastChannelSelect+` WHERE id = ?`, id)) +} + +func scanPodcastChannel(row *sql.Row) (*PodcastChannel, error) { + var c PodcastChannel + var createdAt, updatedAt string + err := row.Scan(&c.ID, &c.URL, &c.Title, &c.Description, &c.CoverArtID, &c.Status, &c.ErrorMessage, &createdAt, &updatedAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + c.CreatedAt, _ = parseTime(createdAt) + c.UpdatedAt, _ = parseTime(updatedAt) + return &c, nil +} + +func ListPodcastChannels(db *sql.DB) ([]*PodcastChannel, error) { + rows, err := db.Query(podcastChannelSelect + ` ORDER BY title COLLATE NOCASE`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*PodcastChannel + for rows.Next() { + var c PodcastChannel + var createdAt, updatedAt string + if err := rows.Scan(&c.ID, &c.URL, &c.Title, &c.Description, &c.CoverArtID, &c.Status, &c.ErrorMessage, &createdAt, &updatedAt); err != nil { + return nil, err + } + c.CreatedAt, _ = parseTime(createdAt) + c.UpdatedAt, _ = parseTime(updatedAt) + out = append(out, &c) + } + return out, rows.Err() +} + +func UpdatePodcastChannelMetadata(db *sql.DB, id, title, description string) error { + _, err := db.Exec(`UPDATE podcast_channels SET title=?, description=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`, + title, description, id) + return err +} + +func SetPodcastChannelStatus(db *sql.DB, id, status, errMsg string) error { + _, err := db.Exec(`UPDATE podcast_channels SET status=?, error_message=?, updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id=?`, + status, errMsg, id) + return err +} + +func DeletePodcastChannel(db *sql.DB, id string) error { + _, err := db.Exec(`DELETE FROM podcast_channels WHERE id = ?`, id) + return err +} + +// --- Episodes --- + +type PodcastEpisode struct { + ID string + ChannelID string + GUID string + Title string + Description string + PublishDate string + DurationSeconds float64 + EnclosureURL string + MediaFileID sql.NullString + Status string // new, downloading, completed, error, skipped + CreatedAt time.Time +} + +const podcastEpisodeSelect = `SELECT id, channel_id, guid, title, description, publish_date, duration_seconds, enclosure_url, media_file_id, status, created_at FROM podcast_episodes` + +// UpsertPodcastEpisode creates or updates an episode by (channel, guid), +// keeping its ID stable across feed refreshes — mirrors the scanner's +// natural-key approach for media files. +func UpsertPodcastEpisode(db *sql.DB, channelID, guid, title, description, publishDate, enclosureURL string, duration float64) (*PodcastEpisode, error) { + var id string + err := db.QueryRow(`SELECT id FROM podcast_episodes WHERE channel_id = ? AND guid = ?`, channelID, guid).Scan(&id) + if err == nil { + _, err = db.Exec(`UPDATE podcast_episodes SET title=?, description=?, publish_date=?, enclosure_url=?, duration_seconds=? WHERE id=?`, + title, description, publishDate, enclosureURL, duration, id) + if err != nil { + return nil, err + } + return GetPodcastEpisode(db, id) + } + if !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + id = idgen.New("pe") + _, err = db.Exec(`INSERT INTO podcast_episodes (id, channel_id, guid, title, description, publish_date, enclosure_url, duration_seconds, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'new')`, id, channelID, guid, title, description, publishDate, enclosureURL, duration) + if err != nil { + return nil, err + } + return GetPodcastEpisode(db, id) +} + +func GetPodcastEpisode(db *sql.DB, id string) (*PodcastEpisode, error) { + return scanPodcastEpisode(db.QueryRow(podcastEpisodeSelect+` WHERE id = ?`, id)) +} + +func scanPodcastEpisode(row *sql.Row) (*PodcastEpisode, error) { + var e PodcastEpisode + var createdAt string + err := row.Scan(&e.ID, &e.ChannelID, &e.GUID, &e.Title, &e.Description, &e.PublishDate, &e.DurationSeconds, &e.EnclosureURL, &e.MediaFileID, &e.Status, &createdAt) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + if err != nil { + return nil, err + } + e.CreatedAt, _ = parseTime(createdAt) + return &e, nil +} + +func ListPodcastEpisodes(db *sql.DB, channelID string) ([]*PodcastEpisode, error) { + rows, err := db.Query(podcastEpisodeSelect+` WHERE channel_id = ? ORDER BY publish_date DESC, created_at DESC`, channelID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanPodcastEpisodes(rows) +} + +func ListNewestPodcastEpisodes(db *sql.DB, limit int) ([]*PodcastEpisode, error) { + rows, err := db.Query(podcastEpisodeSelect+` WHERE status = 'completed' ORDER BY publish_date DESC, created_at DESC LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + return scanPodcastEpisodes(rows) +} + +func ListPodcastEpisodesByStatus(db *sql.DB, status string) ([]*PodcastEpisode, error) { + rows, err := db.Query(podcastEpisodeSelect+` WHERE status = ?`, status) + if err != nil { + return nil, err + } + defer rows.Close() + return scanPodcastEpisodes(rows) +} + +func scanPodcastEpisodes(rows *sql.Rows) ([]*PodcastEpisode, error) { + var out []*PodcastEpisode + for rows.Next() { + var e PodcastEpisode + var createdAt string + if err := rows.Scan(&e.ID, &e.ChannelID, &e.GUID, &e.Title, &e.Description, &e.PublishDate, &e.DurationSeconds, &e.EnclosureURL, &e.MediaFileID, &e.Status, &createdAt); err != nil { + return nil, err + } + e.CreatedAt, _ = parseTime(createdAt) + out = append(out, &e) + } + return out, rows.Err() +} + +func SetPodcastEpisodeStatus(db *sql.DB, id, status string) error { + _, err := db.Exec(`UPDATE podcast_episodes SET status = ? WHERE id = ?`, status, id) + return err +} + +func SetPodcastEpisodeMediaFile(db *sql.DB, id, mediaFileID string) error { + _, err := db.Exec(`UPDATE podcast_episodes SET media_file_id = ?, status = 'completed' WHERE id = ?`, mediaFileID, id) + return err +} + +func DeletePodcastEpisode(db *sql.DB, id string) error { + _, err := db.Exec(`DELETE FROM podcast_episodes WHERE id = ?`, id) + return err +} diff --git a/internal/podcasts/podcasts.go b/internal/podcasts/podcasts.go new file mode 100644 index 0000000..b5a7117 --- /dev/null +++ b/internal/podcasts/podcasts.go @@ -0,0 +1,299 @@ +// Package podcasts implements a real podcast subsystem: RSS/Atom feed +// refresh, episode metadata, enclosure downloads with bounded background +// workers, and download-state recovery across restarts. Downloaded +// episodes are registered as ordinary media files (via internal/library) +// so they stream through exactly the same path/permission-checked code +// as scanned music, rather than a separate ad hoc file server. +package podcasts + +import ( + "context" + "database/sql" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "git.noroutes.net/NoRoutes/vibetunes/internal/database" + "git.noroutes.net/NoRoutes/vibetunes/internal/idgen" + "git.noroutes.net/NoRoutes/vibetunes/internal/metadata" +) + +const maxConcurrentDownloads = 2 + +type Manager struct { + DB *sql.DB + DataDir string // episodes are stored under /podcasts// + FFprobePath string + HTTPClient *http.Client + Log *slog.Logger + + sem chan struct{} +} + +func NewManager(db *sql.DB, dataDir, ffprobePath string, log *slog.Logger) *Manager { + return &Manager{ + DB: db, + DataDir: dataDir, + FFprobePath: ffprobePath, + HTTPClient: &http.Client{Timeout: 60 * time.Second}, + Log: log, + sem: make(chan struct{}, maxConcurrentDownloads), + } +} + +// RecoverInterruptedDownloads resets episodes stuck in "downloading" +// status back to "new" on startup — the process was killed mid-download, +// so there is no partial state worth preserving, but the episode should +// not be stuck forever as a phantom in-progress download. +func (m *Manager) RecoverInterruptedDownloads() error { + stuck, err := database.ListPodcastEpisodesByStatus(m.DB, "downloading") + if err != nil { + return err + } + for _, ep := range stuck { + if err := database.SetPodcastEpisodeStatus(m.DB, ep.ID, "new"); err != nil { + return err + } + } + if len(stuck) > 0 { + m.Log.Info("recovered interrupted podcast downloads", "count", len(stuck)) + } + return nil +} + +// RefreshChannel fetches and parses a channel's feed, updating its +// metadata and upserting every episode found (keyed by GUID, so episode +// IDs and any completed downloads survive repeated refreshes). +func (m *Manager) RefreshChannel(ctx context.Context, channelID string) error { + channel, err := database.GetPodcastChannel(m.DB, channelID) + if err != nil { + return err + } + _ = database.SetPodcastChannelStatus(m.DB, channelID, "downloading", "") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, channel.URL, nil) + if err != nil { + _ = database.SetPodcastChannelStatus(m.DB, channelID, "error", err.Error()) + return err + } + resp, err := m.HTTPClient.Do(req) + if err != nil { + _ = database.SetPodcastChannelStatus(m.DB, channelID, "error", err.Error()) + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + errMsg := fmt.Sprintf("feed returned HTTP %d", resp.StatusCode) + _ = database.SetPodcastChannelStatus(m.DB, channelID, "error", errMsg) + return fmt.Errorf("%s", errMsg) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) // 16MB cap: a feed XML document is never legitimately bigger + if err != nil { + _ = database.SetPodcastChannelStatus(m.DB, channelID, "error", err.Error()) + return err + } + + feed, err := parseRSS(body) + if err != nil { + _ = database.SetPodcastChannelStatus(m.DB, channelID, "error", "invalid feed: "+err.Error()) + return err + } + + if err := database.UpdatePodcastChannelMetadata(m.DB, channelID, feed.Channel.Title, feed.Channel.Description); err != nil { + return err + } + for _, item := range feed.Channel.Items { + if item.Enclosure.URL == "" { + continue // not a downloadable episode (e.g. a text-only post in a mixed feed) + } + guid := item.episodeGUID() + if guid == "" { + continue + } + if _, err := database.UpsertPodcastEpisode(m.DB, channelID, guid, item.Title, item.Description, item.PubDate, item.Enclosure.URL, parseDuration(item.Duration)); err != nil { + return err + } + } + + return database.SetPodcastChannelStatus(m.DB, channelID, "completed", "") +} + +// parseDuration handles the iTunes duration formats: plain seconds, +// "MM:SS", or "HH:MM:SS". +func parseDuration(s string) float64 { + s = strings.TrimSpace(s) + if s == "" { + return 0 + } + if n, err := strconv.ParseFloat(s, 64); err == nil { + return n + } + parts := strings.Split(s, ":") + var total float64 + for _, p := range parts { + n, err := strconv.Atoi(p) + if err != nil { + return 0 + } + total = total*60 + float64(n) + } + return total +} + +// EnsurePodcastFolder returns the music folder downloaded episodes are +// registered under, creating one (kind="podcasts") on first use. +func EnsurePodcastFolder(db *sql.DB, dataDir string) (*database.MusicFolder, error) { + folders, err := database.ListMusicFolders(db) + if err != nil { + return nil, err + } + for _, f := range folders { + if f.Kind == "podcasts" { + return f, nil + } + } + path := filepath.Join(dataDir, "podcasts") + if err := os.MkdirAll(path, 0o755); err != nil { + return nil, err + } + return database.CreateMusicFolder(db, "Podcasts", path, "podcasts") +} + +// DownloadEpisode downloads an episode's enclosure and registers it as a +// streamable media file. Bounded by Manager's semaphore so a burst of +// download requests can't open unlimited concurrent connections. +func (m *Manager) DownloadEpisode(ctx context.Context, episodeID string) error { + select { + case m.sem <- struct{}{}: + case <-ctx.Done(): + return ctx.Err() + } + defer func() { <-m.sem }() + + ep, err := database.GetPodcastEpisode(m.DB, episodeID) + if err != nil { + return err + } + channel, err := database.GetPodcastChannel(m.DB, ep.ChannelID) + if err != nil { + return err + } + folder, err := EnsurePodcastFolder(m.DB, m.DataDir) + if err != nil { + return err + } + + if err := database.SetPodcastEpisodeStatus(m.DB, episodeID, "downloading"); err != nil { + return err + } + + ext := extensionFromURL(ep.EnclosureURL) + relPath := filepath.Join(sanitizeName(channel.Title, channel.ID), sanitizeName(ep.Title, ep.ID)+ext) + absPath := filepath.Join(folder.Path, relPath) + if err := os.MkdirAll(filepath.Dir(absPath), 0o755); err != nil { + _ = database.SetPodcastEpisodeStatus(m.DB, episodeID, "error") + return err + } + + if err := m.downloadToFile(ctx, ep.EnclosureURL, absPath); err != nil { + _ = database.SetPodcastEpisodeStatus(m.DB, episodeID, "error") + return err + } + + info, err := os.Stat(absPath) + if err != nil { + _ = database.SetPodcastEpisodeStatus(m.DB, episodeID, "error") + return err + } + + prober := &metadata.Prober{FFprobePath: m.FFprobePath} + tags, perr := prober.Probe(ctx, absPath) + duration, bitrate, sampleRate, channels, format := ep.DurationSeconds, 0, 0, 0, strings.TrimPrefix(ext, ".") + if perr == nil { + duration, bitrate, sampleRate, channels, format = tags.DurationSeconds, tags.BitrateKbps, tags.SampleRateHz, tags.Channels, tags.Format + } + + mf, err := database.CreateMediaFile(m.DB, folder.ID, filepath.ToSlash(relPath), info.Size(), info.ModTime().Unix(), + idgen.New(""), format, "song", duration, bitrate, sampleRate, channels) + if err != nil { + _ = database.SetPodcastEpisodeStatus(m.DB, episodeID, "error") + return err + } + if _, err := database.UpsertSong(m.DB, database.UpsertSongInput{MediaFileID: mf.ID, Title: ep.Title}); err != nil { + _ = database.SetPodcastEpisodeStatus(m.DB, episodeID, "error") + return err + } + + return database.SetPodcastEpisodeMediaFile(m.DB, episodeID, mf.ID) +} + +func (m *Manager) downloadToFile(ctx context.Context, sourceURL, destPath string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, sourceURL, nil) + if err != nil { + return err + } + resp, err := m.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("enclosure download returned HTTP %d", resp.StatusCode) + } + + tmp := destPath + ".part" + out, err := os.Create(tmp) + if err != nil { + return err + } + if _, err := io.Copy(out, resp.Body); err != nil { + out.Close() + os.Remove(tmp) + return err + } + if err := out.Close(); err != nil { + os.Remove(tmp) + return err + } + return os.Rename(tmp, destPath) +} + +func extensionFromURL(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return ".mp3" + } + ext := filepath.Ext(u.Path) + if ext == "" { + return ".mp3" + } + return ext +} + +func sanitizeName(name, fallbackID string) string { + name = strings.TrimSpace(name) + if name == "" { + return fallbackID + } + var b strings.Builder + for _, r := range name { + switch r { + case '/', '\\', '\x00': + b.WriteByte('_') + default: + b.WriteRune(r) + } + } + s := b.String() + if len(s) > 100 { + s = s[:100] + } + return s +} diff --git a/internal/podcasts/podcasts_test.go b/internal/podcasts/podcasts_test.go new file mode 100644 index 0000000..256f7f8 --- /dev/null +++ b/internal/podcasts/podcasts_test.go @@ -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 = ` + + + 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)) + 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) + } +} diff --git a/internal/podcasts/rss.go b/internal/podcasts/rss.go new file mode 100644 index 0000000..8dc8315 --- /dev/null +++ b/internal/podcasts/rss.go @@ -0,0 +1,57 @@ +package podcasts + +import "encoding/xml" + +// rssFeed is a pragmatic subset of RSS 2.0 (with the de facto iTunes +// podcast extensions most feeds use) — enough to get channel metadata +// and each episode's title, description, publish date, and audio +// enclosure. Real podcast feeds vary a lot in which optional fields they +// include, so every field here is best-effort. +type rssFeed struct { + Channel rssChannel `xml:"channel"` +} + +type rssChannel struct { + Title string `xml:"title"` + Description string `xml:"description"` + Image rssImage `xml:"image"` + Items []rssItem `xml:"item"` +} + +type rssImage struct { + URL string `xml:"url"` +} + +type rssItem struct { + GUID rssGUID `xml:"guid"` + Title string `xml:"title"` + Description string `xml:"description"` + PubDate string `xml:"pubDate"` + Enclosure rssEnclosure `xml:"enclosure"` + Duration string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd duration"` +} + +type rssGUID struct { + Value string `xml:",chardata"` +} + +type rssEnclosure struct { + URL string `xml:"url,attr"` +} + +func parseRSS(data []byte) (*rssFeed, error) { + var feed rssFeed + if err := xml.Unmarshal(data, &feed); err != nil { + return nil, err + } + return &feed, nil +} + +// episodeGUID returns the item's GUID if present, falling back to its +// enclosure URL — some minimal feeds omit entirely. +func (i rssItem) episodeGUID() string { + if i.GUID.Value != "" { + return i.GUID.Value + } + return i.Enclosure.URL +} diff --git a/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist3184291526_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/index.m3u8 b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist3184291526_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/index.m3u8 new file mode 100644 index 0000000..7017fd6 --- /dev/null +++ b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist3184291526_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/index.m3u8 @@ -0,0 +1,7 @@ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:1 +#EXT-X-MEDIA-SEQUENCE:0 +#EXTINF:1.023322, +seg00000.ts +#EXT-X-ENDLIST diff --git a/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist3184291526_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/seg00000.ts b/internal/subsonic/cache/hls/hls/_tmp_TestHLSPlaylist3184291526_003_Test_Artist_Test_Album_First_Song_mp3_0_aac/seg00000.ts new file mode 100644 index 0000000000000000000000000000000000000000..7ae6f6a51d177346266335c5719da8f5f9308a2f GIT binary patch literal 19928 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 DHaouJ literal 0 HcmV?d00001