This commit is contained in:
2026-02-13 11:30:39 +03:00
commit 8cf2e69df9
3 changed files with 362 additions and 0 deletions

8
go.mod Normal file
View File

@@ -0,0 +1,8 @@
module podcaster
go 1.25.6
require (
github.com/jmoiron/sqlx v1.4.0
github.com/mattn/go-sqlite3 v1.14.34
)

11
go.sum Normal file
View File

@@ -0,0 +1,11 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=

343
main.go Normal file
View File

@@ -0,0 +1,343 @@
package main
import (
"encoding/json"
"encoding/xml"
"fmt"
"io"
"log"
"net/http"
"os"
"path"
"strconv"
"time"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
)
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"`
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"`
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 {
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"`
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 createPodcast(db *sqlx.DB, feed RSSFeed, feedUrl string) error {
_, err := db.NamedExec("INSERT INTO podcasts (name, description, feed, language, link) VALUES (:name, :description, :feed, :language, :link)", map[string]any{
"name": feed.Channel.Title,
"description": feed.Channel.Description,
"feed": feedUrl,
"language": feed.Channel.Language,
"link": feed.Channel.Link,
})
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, podcastId int64) error {
_, err := db.NamedExec("INSERT INTO episodes (title, pubdate, guid, url, podcast_id) VALUES (:title, :pubdate, :guid, :url, :podcast_id)", map[string]any{
"title": episode.Title,
"pubdate": episode.PubDate.Time,
"url": episode.Enclosure.URL,
"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) ([]*Episode, error) {
rows, err := db.Queryx("SELECT * FROM episodes WHERE 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 main() {
podcastsDirPath := os.Getenv("PODCASTS_DIRPATH")
db, err := sqlx.Connect("sqlite3", "./db/sqlite.db")
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,
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,
created_at datetime default current_timestamp,
podcast_id integer not null,
FOREIGN KEY (podcast_id) REFERENCES podcasts(id)
);
`)
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 {
if err := addEpisode(db, newestEpisode, podcast.ID); err != nil {
log.Printf("failed to add new episode [%s]: %v\n", podcast.Name, err)
continue
}
data, err := downloadEpisodeAudioFile(newestEpisode.Enclosure.URL)
if err != nil {
log.Printf("failed to download newest episode audio file: %v\n", err)
continue
}
f, err := os.OpenFile(path.Join(podcastsDirPath, podcast.Name, newestEpisode.Title+".mp3"), 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
}
f.Close()
}
}
time.Sleep(5 * time.Second)
}
}(db)
mux := http.NewServeMux()
mux.HandleFunc("GET /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 /podcasts", func(w http.ResponseWriter, r *http.Request) {
var body struct {
Feed string `json:"feed"`
}
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)
}
sendJSON(w, struct {
Ok bool `json:"ok"`
}{true}, 201)
})
mux.HandleFunc("GET /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))
if err != nil {
http.Error(w, err.Error(), 500)
return
}
sendJSON(w, episodes, 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)
}
}