Files
podstalk/handler/import.go
T
jbrechtel 119d4100b5
Build and Deploy / build-and-deploy (push) Successful in 1m9s
add RSS import feature
2026-07-10 14:49:50 -04:00

336 lines
7.5 KiB
Go

package handler
import (
"encoding/xml"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"podstalk/store"
)
type ImportHandler struct {
Store *store.Store
Tpl *Templates
UploadDir string
}
type importFeed struct {
XMLName xml.Name `xml:"rss"`
Channel importChannel `xml:"channel"`
}
type importChannel struct {
Title string `xml:"title"`
Description string `xml:"description"`
Image *importImage `xml:"image"`
ItunesImage *importItunesImage `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd image"`
Items []importItem `xml:"item"`
}
type importItem struct {
Title string `xml:"title"`
Description string `xml:"description"`
Enclosure importEnclosure `xml:"enclosure"`
PubDate string `xml:"pubDate"`
GUID string `xml:"guid"`
ItunesImage *importItunesImage `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd image"`
}
type importEnclosure struct {
URL string `xml:"url,attr"`
Length string `xml:"length,attr"`
Type string `xml:"type,attr"`
}
type importImage struct {
URL string `xml:"url"`
}
type importItunesImage struct {
Href string `xml:"href,attr"`
}
type ImportEpisodeView struct {
Index int
Title string
Description string
DescriptionTruncated string
PubDate string
AudioURL string
}
func truncateString(s string, maxLen int) string {
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
return string(runes[:maxLen]) + "…"
}
func (h *ImportHandler) ServeImport(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
h.Tpl.Render(w, "import", map[string]any{
"Fetched": false,
})
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
rssURL := strings.TrimSpace(r.FormValue("rss_url"))
if rssURL == "" {
h.Tpl.Render(w, "import", map[string]any{
"Fetched": false,
"Error": "RSS URL is required.",
})
return
}
if r.FormValue("import") == "1" {
h.importSelected(w, r, rssURL)
return
}
episodes, err := h.fetchRSS(rssURL)
if err != nil {
h.Tpl.Render(w, "import", map[string]any{
"Fetched": false,
"Error": fmt.Sprintf("Failed to fetch RSS: %v", err),
"RSSURL": rssURL,
})
return
}
if len(episodes) == 0 {
h.Tpl.Render(w, "import", map[string]any{
"Fetched": false,
"Error": "No episodes found in the RSS feed.",
"RSSURL": rssURL,
})
return
}
h.Tpl.Render(w, "import", map[string]any{
"Fetched": true,
"RSSURL": rssURL,
"Episodes": episodes,
})
}
func (h *ImportHandler) fetchRSS(rssURL string) ([]ImportEpisodeView, error) {
resp, err := http.Get(rssURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
var feed importFeed
dec := xml.NewDecoder(resp.Body)
if err := dec.Decode(&feed); err != nil {
return nil, err
}
var episodes []ImportEpisodeView
for i, item := range feed.Channel.Items {
pubDate := item.PubDate
if t, err := parseRSSDate(pubDate); err == nil {
pubDate = t.Format("Jan 2, 2006")
}
episodes = append(episodes, ImportEpisodeView{
Index: i,
Title: item.Title,
Description: item.Description,
DescriptionTruncated: truncateString(item.Description, 150),
PubDate: pubDate,
AudioURL: item.Enclosure.URL,
})
}
return episodes, nil
}
func (h *ImportHandler) importSelected(w http.ResponseWriter, r *http.Request, rssURL string) {
userID := UserIDFromContext(r)
selected := r.Form["selected"]
if len(selected) == 0 {
http.Redirect(w, r, "/import?error=no+episodes+selected", http.StatusSeeOther)
return
}
selectedSet := make(map[int]bool)
for _, s := range selected {
idx, err := strconv.Atoi(s)
if err == nil {
selectedSet[idx] = true
}
}
resp, err := http.Get(rssURL)
if err != nil {
http.Redirect(w, r, "/import?error=failed+to+fetch+rss", http.StatusSeeOther)
return
}
defer resp.Body.Close()
var feed importFeed
dec := xml.NewDecoder(resp.Body)
if err := dec.Decode(&feed); err != nil {
http.Redirect(w, r, "/import?error=failed+to+parse+rss", http.StatusSeeOther)
return
}
imported := 0
var errors []string
for i, item := range feed.Channel.Items {
if !selectedSet[i] {
continue
}
audioPath, err := h.downloadFile(item.Enclosure.URL, "audio")
if err != nil {
errors = append(errors, fmt.Sprintf("%s: audio download failed: %v", item.Title, err))
continue
}
imagePath := ""
imageURL := h.episodeImageURL(&item, &feed.Channel)
if imageURL != "" {
if p, err := h.downloadFile(imageURL, "images"); err == nil {
imagePath = p
}
}
publishedAt := time.Now()
if t, err := parseRSSDate(item.PubDate); err == nil {
publishedAt = t
}
_, err = h.Store.CreateEpisode(userID, item.Title, item.Description, audioPath, imagePath, publishedAt)
if err != nil {
errors = append(errors, fmt.Sprintf("%s: database error: %v", item.Title, err))
h.removeFile(audioPath)
if imagePath != "" {
h.removeFile(imagePath)
}
continue
}
imported++
}
if imported > 0 {
http.Redirect(w, r, fmt.Sprintf("/?success=%d+episodes+imported+successfully", imported), http.StatusSeeOther)
return
}
http.Redirect(w, r, "/import?error=no+episodes+could+be+imported", http.StatusSeeOther)
}
func (h *ImportHandler) episodeImageURL(item *importItem, channel *importChannel) string {
if item.ItunesImage != nil && item.ItunesImage.Href != "" {
return item.ItunesImage.Href
}
if channel.ItunesImage != nil && channel.ItunesImage.Href != "" {
return channel.ItunesImage.Href
}
if channel.Image != nil && channel.Image.URL != "" {
return channel.Image.URL
}
return ""
}
func (h *ImportHandler) downloadFile(url, category string) (string, error) {
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
filename := filepath.Base(url)
if filename == "" || filename == "." || filename == "/" {
filename = "file"
}
base := sanitizeFilename(filename)
ext := filepath.Ext(base)
if ext == "" {
ct := resp.Header.Get("Content-Type")
switch {
case strings.HasPrefix(ct, "audio/"):
ext = ".mp3"
case strings.HasPrefix(ct, "image/png"):
ext = ".png"
case strings.HasPrefix(ct, "image/"):
ext = ".jpg"
default:
ext = ".bin"
}
} else {
base = base[:len(base)-len(ext)]
}
filename = fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), base, ext)
catDir := filepath.Join(h.UploadDir, category)
if err := os.MkdirAll(catDir, 0755); err != nil {
return "", err
}
dst, err := os.Create(filepath.Join(catDir, filename))
if err != nil {
return "", err
}
defer dst.Close()
if _, err := io.Copy(dst, resp.Body); err != nil {
return "", err
}
return category + "/" + filename, nil
}
func (h *ImportHandler) removeFile(relPath string) {
if relPath == "" {
return
}
os.Remove(filepath.Join(h.UploadDir, relPath))
}
func parseRSSDate(s string) (time.Time, error) {
formats := []string{
time.RFC1123Z,
time.RFC1123,
time.RFC822Z,
time.RFC822,
time.RFC3339,
"Mon, 02 Jan 2006 15:04:05 -0700",
"Mon, 2 Jan 2006 15:04:05 -0700",
"2006-01-02T15:04:05Z",
"2006-01-02T15:04:05-07:00",
}
for _, f := range formats {
if t, err := time.Parse(f, s); err == nil {
return t, nil
}
}
return time.Now(), nil
}