package main import ( "embed" "encoding/json" "encoding/xml" "fmt" "html/template" "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"` 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"` 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) ([]*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 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 views viewsFS embed.FS ) func main() { tmpl := template.Must(template.ParseFS(viewsFS, "**/*.html")) 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) ); `) 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) { podcasts, err := getPodcasts(db) if err != nil { http.Error(w, err.Error(), 500) return } tmpl.ExecuteTemplate(w, "index.html", struct { Podcasts []*Podcast }{ Podcasts: podcasts, }) }) 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("GET /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 } episodes, err := getPodcastEpisodes(db, podcast.ID) if err != nil { http.Error(w, err.Error(), 404) return } loc, err := time.LoadLocation("Europe/Moscow") if err != nil { http.Error(w, fmt.Sprintf("failed to load time location: %v", err), 500) return } for _, episode := range episodes { episode.CreatedAt = episode.CreatedAt.In(loc) } tmpl.ExecuteTemplate(w, "podcast.html", struct { Podcast *Podcast Episodes []*Episode }{ Podcast: podcast, Episodes: episodes, }) }) mux.HandleFunc("POST /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 /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) }) mux.HandleFunc("DELETE /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) }) 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) } }