207 lines
6.7 KiB
Go
207 lines
6.7 KiB
Go
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
|
|
}
|