From 8cf2e69df93daf1a62c6659e86ecd88109b7ac28 Mon Sep 17 00:00:00 2001 From: Daniil Tsivinsky Date: Fri, 13 Feb 2026 11:30:39 +0300 Subject: [PATCH] init --- go.mod | 8 ++ go.sum | 11 ++ main.go | 343 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 362 insertions(+) create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..18e5e86 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..53de804 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..22d5c7f --- /dev/null +++ b/main.go @@ -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) + } +}