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