This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -72,6 +72,11 @@ func main() {
|
|||||||
Store: st,
|
Store: st,
|
||||||
Link: baseURL,
|
Link: baseURL,
|
||||||
}
|
}
|
||||||
|
importH := &handler.ImportHandler{
|
||||||
|
Store: st,
|
||||||
|
Tpl: tpl,
|
||||||
|
UploadDir: uploadDir,
|
||||||
|
}
|
||||||
|
|
||||||
// Auth routes.
|
// Auth routes.
|
||||||
http.HandleFunc("/signup", authMid.RedirectIfAuthed(authH.ServeSignup))
|
http.HandleFunc("/signup", authMid.RedirectIfAuthed(authH.ServeSignup))
|
||||||
@@ -84,6 +89,7 @@ func main() {
|
|||||||
http.HandleFunc("/episodes/edit", authMid.RequireAuth(epH.ServeEdit))
|
http.HandleFunc("/episodes/edit", authMid.RequireAuth(epH.ServeEdit))
|
||||||
http.HandleFunc("/episodes/delete", authMid.RequireAuth(epH.ServeDelete))
|
http.HandleFunc("/episodes/delete", authMid.RequireAuth(epH.ServeDelete))
|
||||||
http.HandleFunc("/settings", authMid.RequireAuth(setH.ServeSettings))
|
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)
|
// Public RSS: /rss/my-slug or /rss?slug=my-slug or /rss?user=N (legacy)
|
||||||
http.HandleFunc("/rss", rssH.ServeRSS)
|
http.HandleFunc("/rss", rssH.ServeRSS)
|
||||||
|
|||||||
Vendored
+11
@@ -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; }
|
||||||
@@ -115,6 +115,7 @@
|
|||||||
|
|
||||||
<div class="new-ep-row">
|
<div class="new-ep-row">
|
||||||
<a href="/episodes/new" class="nb-button blue">+ New Episode</a>
|
<a href="/episodes/new" class="nb-button blue">+ New Episode</a>
|
||||||
|
<a href="/import" class="nb-button default">Import from RSS</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if .Episodes}}
|
{{if .Episodes}}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Podstalk — Import Episodes</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/neobrutalismcss@latest" />
|
||||||
|
<link rel="stylesheet" href="/static/import.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="nb-navbar" role="navigation" aria-label="Main navigation">
|
||||||
|
<a href="/" class="nb-navbar-brand" aria-label="Go to homepage">Podstalk</a>
|
||||||
|
<ul class="nb-navbar-nav" role="menubar">
|
||||||
|
<li class="nb-navbar-item" role="none">
|
||||||
|
<a href="/" class="nb-navbar-link" role="menuitem">Dashboard</a>
|
||||||
|
</li>
|
||||||
|
<li class="nb-navbar-item" role="none">
|
||||||
|
<a href="/signout" class="nb-navbar-link" role="menuitem">Sign Out</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<div class="container">
|
||||||
|
{{if .Error}}<div class="flash flash-error">{{.Error}}</div>{{end}}
|
||||||
|
|
||||||
|
<h2>Import Episodes from RSS</h2>
|
||||||
|
|
||||||
|
{{if not .Fetched}}
|
||||||
|
<form method="post" action="/import">
|
||||||
|
<div class="nb-form-group">
|
||||||
|
<label for="rss_url">RSS Feed URL</label>
|
||||||
|
<input type="url" id="rss_url" name="rss_url" class="nb-input blue" required autofocus
|
||||||
|
value="{{if .RSSURL}}{{.RSSURL}}{{end}}"
|
||||||
|
placeholder="https://example.com/podcast.xml" />
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="nb-button green">Fetch Episodes</button>
|
||||||
|
<a href="/" class="nb-button default">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Fetched}}
|
||||||
|
<form method="post" action="/import">
|
||||||
|
<input type="hidden" name="rss_url" value="{{.RSSURL}}" />
|
||||||
|
<input type="hidden" name="import" value="1" />
|
||||||
|
|
||||||
|
<div class="nb-table-container">
|
||||||
|
<div class="nb-table-header">{{len .Episodes}} Episodes Found</div>
|
||||||
|
<table class="nb-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
<input type="checkbox" id="select-all" onclick="toggleAll(this)" />
|
||||||
|
</th>
|
||||||
|
<th>Title</th>
|
||||||
|
<th>Published</th>
|
||||||
|
<th>Audio</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Episodes}}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" name="selected" value="{{.Index}}" />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="ep-title">{{.Title}}</div>
|
||||||
|
{{if .Description}}<div class="ep-desc">{{.DescriptionTruncated}}</div>{{end}}
|
||||||
|
</td>
|
||||||
|
<td class="ep-date">{{.PubDate}}</td>
|
||||||
|
<td>{{if .AudioURL}}<span class="audio-badge">✓</span>{{else}}—{{end}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="nb-button green">Import Selected</button>
|
||||||
|
<a href="/" class="nb-button default">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleAll(master) {
|
||||||
|
var checkboxes = document.querySelectorAll('input[name="selected"]');
|
||||||
|
for (var i = 0; i < checkboxes.length; i++) {
|
||||||
|
checkboxes[i].checked = master.checked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user