diff --git a/handler/import.go b/handler/import.go new file mode 100644 index 0000000..2b3cd83 --- /dev/null +++ b/handler/import.go @@ -0,0 +1,335 @@ +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 +} diff --git a/main.go b/main.go index a599225..b98d512 100644 --- a/main.go +++ b/main.go @@ -72,6 +72,11 @@ func main() { Store: st, Link: baseURL, } + importH := &handler.ImportHandler{ + Store: st, + Tpl: tpl, + UploadDir: uploadDir, + } // Auth routes. http.HandleFunc("/signup", authMid.RedirectIfAuthed(authH.ServeSignup)) @@ -84,6 +89,7 @@ func main() { http.HandleFunc("/episodes/edit", authMid.RequireAuth(epH.ServeEdit)) http.HandleFunc("/episodes/delete", authMid.RequireAuth(epH.ServeDelete)) http.HandleFunc("/settings", authMid.RequireAuth(setH.ServeSettings)) + http.HandleFunc("/import", authMid.RequireAuth(importH.ServeImport)) // Public RSS: /rss/my-slug or /rss?slug=my-slug or /rss?user=N (legacy) http.HandleFunc("/rss", rssH.ServeRSS) diff --git a/static/import.css b/static/import.css new file mode 100644 index 0000000..4453bc8 --- /dev/null +++ b/static/import.css @@ -0,0 +1,11 @@ +body { max-width: 960px; margin: 0 auto; padding: 2rem 1rem; } +.container { margin-top: 2rem; } +.flash { padding: 0.75rem 1rem; margin-bottom: 1rem; border: 2px solid #000; } +.flash-error { background: #ffcdd2; } +.form-actions { display: flex; gap: 0.5rem; margin-top: 1.5rem; } +.ep-title { font-weight: 600; } +.ep-desc { font-size: 0.85rem; color: #666; margin-top: 0.25rem; line-height: 1.4; } +.ep-date { white-space: nowrap; } +.audio-badge { color: #2e7d32; font-weight: bold; } +.nb-form-group { margin-bottom: 1rem; } +.nb-form-group label { display: block; margin-bottom: 0.25rem; font-weight: bold; } diff --git a/template/dashboard.html b/template/dashboard.html index 20ad61d..bec66d3 100644 --- a/template/dashboard.html +++ b/template/dashboard.html @@ -115,6 +115,7 @@
{{if .Episodes}} diff --git a/template/import.html b/template/import.html new file mode 100644 index 0000000..fe2b257 --- /dev/null +++ b/template/import.html @@ -0,0 +1,95 @@ + + + + + +