547 lines
13 KiB
Go
547 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"embed"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
_ "time/tzdata"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
_ "github.com/mattn/go-sqlite3"
|
|
)
|
|
|
|
type OrderType = string
|
|
|
|
const (
|
|
OrderTypeAsc OrderType = "ASC"
|
|
OrderTypeDesc OrderType = "DESC"
|
|
)
|
|
|
|
func validateOrderType(orderType string) OrderType {
|
|
switch strings.ToLower(orderType) {
|
|
case "asc":
|
|
return OrderTypeAsc
|
|
case "desc":
|
|
return OrderTypeDesc
|
|
default:
|
|
return OrderTypeAsc
|
|
}
|
|
}
|
|
|
|
type Podcast struct {
|
|
ID int64 `json:"id" db:"id"`
|
|
Name string `json:"name" db:"name"`
|
|
Description string `json:"description" db:"description"`
|
|
Feed string `json:"feed" db:"feed"`
|
|
Language string `json:"language" db:"language"`
|
|
Link string `json:"link" db:"link"`
|
|
Image string `json:"image" db:"image"`
|
|
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
|
}
|
|
|
|
type Episode struct {
|
|
ID int64 `json:"id" db:"id"`
|
|
Title string `json:"title" db:"title"`
|
|
PubDate time.Time `json:"pubdate" db:"pubdate"`
|
|
Guid string `json:"guid" db:"guid"`
|
|
Url string `json:"url" db:"url"`
|
|
PodcastId int64 `json:"podcastId" db:"podcast_id"`
|
|
Number int `json:"number" db:"number"`
|
|
Listened bool `json:"listened" db:"listened"`
|
|
CreatedAt time.Time `json:"createdAt" db:"created_at"`
|
|
}
|
|
|
|
type RSSFeedTime struct {
|
|
time.Time
|
|
}
|
|
|
|
func (t *RSSFeedTime) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
|
var s string
|
|
if err := d.DecodeElement(&s, &start); err != nil {
|
|
return err
|
|
}
|
|
parsed, err := time.Parse(time.RFC1123Z, s)
|
|
if err != nil {
|
|
parsed, err = time.Parse(time.RFC1123, s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
t.Time = parsed
|
|
return nil
|
|
}
|
|
|
|
type RSSFeedItem struct {
|
|
Title string `xml:"title"`
|
|
PubDate RSSFeedTime `xml:"pubDate"`
|
|
Guid string `xml:"guid"`
|
|
Enclosure struct {
|
|
URL string `xml:"url,attr"`
|
|
} `xml:"enclosure"`
|
|
}
|
|
|
|
type RSSFeed struct {
|
|
RSS xml.Name `xml:"rss"`
|
|
Channel struct {
|
|
Title string `xml:"title"`
|
|
Description string `xml:"description"`
|
|
Language string `xml:"language"`
|
|
Link string `xml:"link"`
|
|
Image string `xml:"image>url"`
|
|
Items []RSSFeedItem `xml:"item"`
|
|
} `xml:"channel"`
|
|
}
|
|
|
|
func sendJSON(w http.ResponseWriter, data any, status int) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
d, err := json.Marshal(data)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
}
|
|
w.Write(d)
|
|
}
|
|
|
|
func getPodcastFeed(feedUrl string) (*RSSFeed, error) {
|
|
resp, err := http.Get(feedUrl)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch feed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response: %v", err)
|
|
}
|
|
|
|
feed := new(RSSFeed)
|
|
err = xml.Unmarshal(data, feed)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal xml feed: %v", err)
|
|
}
|
|
|
|
return feed, nil
|
|
}
|
|
|
|
func getPodcasts(db *sqlx.DB) ([]*Podcast, error) {
|
|
rows, err := db.Queryx("SELECT * FROM podcasts")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query db: %v", err)
|
|
}
|
|
|
|
podcasts := []*Podcast{}
|
|
for rows.Next() {
|
|
podcast := new(Podcast)
|
|
if err := rows.StructScan(podcast); err != nil {
|
|
return nil, fmt.Errorf("failed to scan data to struct: %v", err)
|
|
}
|
|
podcasts = append(podcasts, podcast)
|
|
}
|
|
|
|
return podcasts, nil
|
|
}
|
|
|
|
func getPodcastById(db *sqlx.DB, podcastId int64) (*Podcast, error) {
|
|
row := db.QueryRowx("SELECT * FROM podcasts WHERE id = ?", podcastId)
|
|
if row.Err() != nil {
|
|
return nil, fmt.Errorf("failed to query db: %v", row.Err())
|
|
}
|
|
|
|
podcast := new(Podcast)
|
|
if err := row.StructScan(podcast); err != nil {
|
|
return nil, fmt.Errorf("failed to scan struct podcast: %v", err)
|
|
}
|
|
|
|
return podcast, nil
|
|
}
|
|
|
|
func createPodcast(db *sqlx.DB, feed RSSFeed, feedUrl string) error {
|
|
_, err := db.NamedExec("INSERT INTO podcasts (name, description, feed, language, link, image) VALUES (:name, :description, :feed, :language, :link, :image)", map[string]any{
|
|
"name": feed.Channel.Title,
|
|
"description": feed.Channel.Description,
|
|
"feed": feedUrl,
|
|
"language": feed.Channel.Language,
|
|
"link": feed.Channel.Link,
|
|
"image": feed.Channel.Image,
|
|
})
|
|
return err
|
|
}
|
|
|
|
func getEpisodeByGuid(db *sqlx.DB, guid string) (*Episode, error) {
|
|
row := db.QueryRowx("SELECT * FROM episodes WHERE guid = ?", guid)
|
|
if row.Err() != nil {
|
|
return nil, fmt.Errorf("failed to query db: %v", row.Err())
|
|
}
|
|
|
|
episode := new(Episode)
|
|
if err := row.StructScan(episode); err != nil {
|
|
return nil, fmt.Errorf("failed to scan struct fields: %v", err)
|
|
}
|
|
|
|
return episode, nil
|
|
}
|
|
|
|
func addEpisode(db *sqlx.DB, episode RSSFeedItem, episodeNumber int, podcastId int64) error {
|
|
_, err := db.NamedExec("INSERT INTO episodes (title, pubdate, guid, url, number, podcast_id) VALUES (:title, :pubdate, :guid, :url, :number, :podcast_id)", map[string]any{
|
|
"title": episode.Title,
|
|
"pubdate": episode.PubDate.Time,
|
|
"url": episode.Enclosure.URL,
|
|
"number": episodeNumber,
|
|
"guid": episode.Guid,
|
|
"podcast_id": podcastId,
|
|
})
|
|
return err
|
|
}
|
|
|
|
func downloadEpisodeAudioFile(url string) ([]byte, error) {
|
|
resp, err := http.Get(url)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch file data: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
data, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read response: %v", err)
|
|
}
|
|
|
|
return data, nil
|
|
}
|
|
|
|
func getPodcastEpisodes(db *sqlx.DB, podcastId int64, orderBy string, orderType OrderType) ([]*Episode, error) {
|
|
allowedColumns := map[string]bool{
|
|
"title": true,
|
|
"pubdate": true,
|
|
"number": true,
|
|
"created_at": true,
|
|
}
|
|
if !allowedColumns[orderBy] {
|
|
orderBy = "pubdate"
|
|
}
|
|
|
|
orderType = validateOrderType(orderType)
|
|
|
|
query := fmt.Sprintf("SELECT * FROM episodes WHERE podcast_id = :podcast_id ORDER BY %s %s", orderBy, orderType)
|
|
rows, err := db.NamedQuery(query, map[string]any{
|
|
"podcast_id": podcastId,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to query db: %v", err)
|
|
}
|
|
|
|
episodes := []*Episode{}
|
|
for rows.Next() {
|
|
episode := new(Episode)
|
|
if err := rows.StructScan(&episode); err != nil {
|
|
return nil, fmt.Errorf("failed to scan struct: %v", err)
|
|
}
|
|
episodes = append(episodes, episode)
|
|
}
|
|
|
|
return episodes, nil
|
|
}
|
|
|
|
func deletePodcastById(db *sqlx.DB, podcastId int64) error {
|
|
if _, err := db.NamedExec("DELETE FROM episodes WHERE podcast_id = :podcastId; DELETE FROM podcasts WHERE id = :podcastId", map[string]any{
|
|
"podcastId": podcastId,
|
|
}); err != nil {
|
|
return fmt.Errorf("failed to delete podcasts and episodes: %v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
var (
|
|
//go:embed web/dist/*
|
|
webFS embed.FS
|
|
)
|
|
|
|
func main() {
|
|
podcastsDirPath := os.Getenv("PODCASTS_DIRPATH")
|
|
|
|
dbPath := os.Getenv("DB_PATH")
|
|
if dbPath == "" {
|
|
dbPath = "./db/sqlite.db"
|
|
}
|
|
|
|
db, err := sqlx.Connect("sqlite3", dbPath)
|
|
if err != nil {
|
|
log.Fatalf("failed to connect to db: %v\n", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
if err := db.Ping(); err != nil {
|
|
log.Fatalf("failed to ping db: %v\n", err)
|
|
}
|
|
|
|
db.MustExec(`
|
|
CREATE TABLE IF NOT EXISTS podcasts (
|
|
id integer primary key not null,
|
|
name varchar not null,
|
|
description varchar not null,
|
|
feed varchar not null,
|
|
language varchar not null,
|
|
link varchar not null,
|
|
image varchar not null,
|
|
created_at datetime default current_timestamp
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS episodes (
|
|
id integer primary key not null,
|
|
title varchar not null,
|
|
pubdate datetime not null,
|
|
guid varchar not null,
|
|
url varchar not null,
|
|
number integer not null,
|
|
created_at datetime default current_timestamp,
|
|
podcast_id integer not null,
|
|
FOREIGN KEY (podcast_id) REFERENCES podcasts(id)
|
|
);
|
|
`)
|
|
|
|
db.Exec(`ALTER TABLE episodes ADD COLUMN listened boolean default false;`)
|
|
|
|
go func(db *sqlx.DB) {
|
|
for {
|
|
podcasts, err := getPodcasts(db)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
for _, podcast := range podcasts {
|
|
feed, err := getPodcastFeed(podcast.Feed)
|
|
if err != nil {
|
|
log.Printf("failed to fetch podcast feed: %v\n", err)
|
|
continue
|
|
}
|
|
|
|
newestEpisode := feed.Channel.Items[0]
|
|
episode, err := getEpisodeByGuid(db, newestEpisode.Guid)
|
|
if err != nil && episode == nil {
|
|
data, err := downloadEpisodeAudioFile(newestEpisode.Enclosure.URL)
|
|
if err != nil {
|
|
log.Printf("failed to download newest episode audio file: %v\n", err)
|
|
continue
|
|
}
|
|
|
|
episodeNumber := len(feed.Channel.Items) - 1
|
|
|
|
if err := addEpisode(db, newestEpisode, episodeNumber, podcast.ID); err != nil {
|
|
log.Printf("failed to add new episode [%s]: %v\n", podcast.Name, err)
|
|
continue
|
|
}
|
|
|
|
episodeFilePath := path.Join(podcastsDirPath, podcast.Name, fmt.Sprintf("Episode %d.mp3", episodeNumber))
|
|
|
|
f, err := os.OpenFile(episodeFilePath, os.O_CREATE|os.O_RDWR, 0644)
|
|
if err != nil {
|
|
log.Printf("failed to create file for newest episode: %v\n", err)
|
|
continue
|
|
}
|
|
|
|
if _, err := f.Write(data); err != nil {
|
|
log.Printf("failed to save newest episode to a file: %v\n", err)
|
|
continue
|
|
}
|
|
|
|
if err := appendPodcastMetadata(episodeFilePath, newestEpisode, episodeNumber, *podcast); err != nil {
|
|
log.Printf("failed to append episode metadata: %v\n", err)
|
|
continue
|
|
}
|
|
|
|
f.Close()
|
|
}
|
|
}
|
|
|
|
time.Sleep(1 * time.Hour)
|
|
}
|
|
}(db)
|
|
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
|
path := r.URL.Path
|
|
if path == "/" {
|
|
path = "/index.html"
|
|
}
|
|
path = "web/dist" + path
|
|
|
|
_, err := webFS.Open(path)
|
|
if err != nil {
|
|
path = "web/dist/index.html"
|
|
}
|
|
|
|
http.ServeFileFS(w, r, webFS, path)
|
|
})
|
|
|
|
mux.HandleFunc("GET /api/podcasts", func(w http.ResponseWriter, r *http.Request) {
|
|
podcasts, err := getPodcasts(db)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
|
|
sendJSON(w, podcasts, 200)
|
|
})
|
|
|
|
mux.HandleFunc("POST /api/podcasts", func(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Feed string `json:"feed"`
|
|
}
|
|
|
|
isFormData := r.Header.Get("Content-Type") == "application/x-www-form-urlencoded"
|
|
|
|
if isFormData {
|
|
body.Feed = r.FormValue("feed")
|
|
} else {
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
}
|
|
|
|
if body.Feed == "" {
|
|
http.Error(w, "invalid request, rss feed url is required", 400)
|
|
return
|
|
}
|
|
|
|
feed, err := getPodcastFeed(body.Feed)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
|
|
if err := createPodcast(db, *feed, body.Feed); err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
|
|
if err := os.Mkdir(path.Join(podcastsDirPath, feed.Channel.Title), 0777); err != nil {
|
|
log.Printf("failed to create directory for podcast %s: %v\n", feed.Channel.Title, err)
|
|
}
|
|
|
|
if isFormData {
|
|
http.Redirect(w, r, "/", http.StatusFound)
|
|
} else {
|
|
sendJSON(w, struct {
|
|
Ok bool `json:"ok"`
|
|
}{true}, 201)
|
|
}
|
|
})
|
|
|
|
mux.HandleFunc("GET /api/podcasts/{id}", func(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.Atoi(r.PathValue("id"))
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 400)
|
|
return
|
|
}
|
|
|
|
podcast, err := getPodcastById(db, int64(id))
|
|
if err != nil {
|
|
http.Error(w, "podcast not found", 404)
|
|
return
|
|
}
|
|
|
|
sendJSON(w, podcast, 200)
|
|
})
|
|
|
|
mux.HandleFunc("GET /api/podcasts/{id}/episodes", func(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.Atoi(r.PathValue("id"))
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 404)
|
|
return
|
|
}
|
|
|
|
episodes, err := getPodcastEpisodes(db, int64(id), "pubdate", OrderTypeDesc)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 500)
|
|
return
|
|
}
|
|
|
|
sendJSON(w, episodes, 200)
|
|
})
|
|
|
|
mux.HandleFunc("DELETE /api/podcasts/{id}", func(w http.ResponseWriter, r *http.Request) {
|
|
id, err := strconv.Atoi(r.PathValue("id"))
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 404)
|
|
return
|
|
}
|
|
|
|
podcast, err := getPodcastById(db, int64(id))
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 404)
|
|
return
|
|
}
|
|
|
|
if err := os.RemoveAll(path.Join(podcastsDirPath, podcast.Name)); err != nil {
|
|
http.Error(w, err.Error(), 404)
|
|
return
|
|
}
|
|
|
|
if err := deletePodcastById(db, int64(id)); err != nil {
|
|
http.Error(w, err.Error(), 404)
|
|
return
|
|
}
|
|
|
|
sendJSON(w, struct {
|
|
Ok bool `json:"ok"`
|
|
}{true}, 200)
|
|
})
|
|
|
|
mux.HandleFunc("PATCH /api/episodes/{id}", func(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Listened *bool `json:"listened"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
http.Error(w, err.Error(), 400)
|
|
return
|
|
}
|
|
|
|
fields := map[string]any{}
|
|
|
|
if body.Listened != nil {
|
|
fields["listened"] = &body.Listened
|
|
}
|
|
|
|
if len(fields) == 0 {
|
|
sendJSON(w, struct {
|
|
Ok bool `json:"ok"`
|
|
}{true}, 200)
|
|
return
|
|
}
|
|
|
|
fields["id"] = r.PathValue("id")
|
|
|
|
f := []string{}
|
|
for field := range fields {
|
|
f = append(f, fmt.Sprintf("%s = :%s", field, field))
|
|
}
|
|
q := fmt.Sprintf("UPDATE episodes SET %s WHERE id = :id", strings.Join(f, ", "))
|
|
_, err := db.NamedExec(q, fields)
|
|
if err != nil {
|
|
http.Error(w, err.Error(), 400)
|
|
return
|
|
}
|
|
|
|
sendJSON(w, struct {
|
|
Ok bool `json:"ok"`
|
|
}{true}, 200)
|
|
})
|
|
|
|
fmt.Println("starting http server on http://localhost:5000")
|
|
if err := http.ListenAndServe(":5000", mux); err != nil {
|
|
log.Fatalf("failed to start http server: %v\n", err)
|
|
}
|
|
}
|