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 }