clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/ui-updates
tinyrelay / tinyclient / podcast_rss.go
podcast_rss.go 6.5K
1package tinyclient23import (4 "encoding/json"5 "encoding/xml"6 "net/http"7 "net/url"8 "strconv"9 "strings"10 "time"1112 "github.com/FelineStateMachine/tinyrelay/internal/podcasts"13)1415type podcastRSS struct {16 XMLName xml.Name `xml:"rss"`17 Version string `xml:"version,attr"`18 Atom string `xml:"xmlns:atom,attr"`19 ITunes string `xml:"xmlns:itunes,attr"`20 Content string `xml:"xmlns:content,attr"`21 Channel podcastChannel `xml:"channel"`22}2324type podcastChannel struct {25 Title string `xml:"title"`26 Link string `xml:"link"`27 Description string `xml:"description"`28 Self podcastAtomLink `xml:"atom:link"`29 Items []podcastItem `xml:"item"`30 Author string `xml:"itunes:author,omitempty"`31 Image *podcastImage `xml:"itunes:image,omitempty"`32}3334type podcastAtomLink struct {35 Href string `xml:"href,attr"`36 Rel string `xml:"rel,attr"`37 Type string `xml:"type,attr"`38}3940type podcastItem struct {41 Title string `xml:"title"`42 Link string `xml:"link"`43 Description string `xml:"description"`44 GUID podcastGUID `xml:"guid"`45 PubDate string `xml:"pubDate,omitempty"`46 Enclosure podcastEnclosure `xml:"enclosure"`47 Author string `xml:"itunes:author,omitempty"`48 Subtitle string `xml:"itunes:subtitle,omitempty"`49 Summary string `xml:"itunes:summary,omitempty"`50 Content string `xml:"content:encoded,omitempty"`51 Image *podcastImage `xml:"itunes:image,omitempty"`52}5354type podcastImage struct {55 Href string `xml:"href,attr"`56}5758type podcastGUID struct {59 Value string `xml:",chardata"`60 IsPermaLink string `xml:"isPermaLink,attr"`61}6263type podcastEnclosure struct {64 URL string `xml:"url,attr"`65 Length int64 `xml:"length,attr"`66 Type string `xml:"type,attr"`67}6869// podcastRSS renders the native podcast projection for conventional players.70func (a *App) podcastRSS(w http.ResponseWriter, r *http.Request) {71 w.Header().Set("Cache-Control", "private, no-store")72 actor, err := a.resolveActor(r)73 if err != nil {74 http.Error(w, err.Error(), socialErrorStatus(err))75 return76 }77 query, err := podcastFeedQuery(r.URL.Query())78 if err != nil {79 http.Error(w, err.Error(), http.StatusBadRequest)80 return81 }82 params := socialParameters(query)83 params["kind"], params["limit"] = "podcasts", 10084 raw, err := json.Marshal(params)85 if err != nil {86 http.Error(w, "invalid social query", http.StatusBadRequest)87 return88 }89 result, err := a.backend.Query(r.Context(), "browsepodcasts", []json.RawMessage{raw}, actor)90 if err != nil {91 http.Error(w, err.Error(), socialErrorStatus(err))92 return93 }94 base := strings.TrimSuffix(a.backend.URL(), "/")95 channel := podcastFeedChannel(base, a.backend.Slug(), query)96 podcastChannelShow(&channel, valueMap(result)["show"])97 channel.Author = channel.Title98 for _, row := range browseRows(result) {99 if item, ok := podcastFeedItem(base, valueMap(row)); ok {100 channel.Items = append(channel.Items, item)101 }102 }103 doc, err := xml.Marshal(podcastRSS{Version: "2.0", Atom: "http://www.w3.org/2005/Atom", ITunes: "http://www.itunes.com/dtds/podcast-1.0.dtd", Content: "http://purl.org/rss/1.0/modules/content/", Channel: channel})104 if err != nil {105 http.Error(w, "could not encode feed", http.StatusInternalServerError)106 return107 }108 w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")109 w.Header().Set("Content-Length", strconv.Itoa(len(xml.Header)+len(doc)))110 if r.Method != http.MethodHead {111 _, _ = w.Write(append([]byte(xml.Header), doc...))112 }113}114115func podcastChannelShow(channel *podcastChannel, value any) {116 show := valueMap(value)117 if title := plainString(show["title"]); title != "" {118 channel.Title = title119 }120 if description := plainString(show["description"]); description != "" {121 channel.Description = description122 }123 if image := socialMediaURL(plainString(show["image"])); image != "" {124 channel.Image = &podcastImage{Href: image}125 }126 for _, raw := range stringValues(show["websites"]) {127 if website := socialMediaURL(raw); website != "" {128 channel.Link = website129 break130 }131 }132}133134func podcastFeedQuery(query url.Values) (url.Values, error) {135 values := url.Values{}136 if raw := strings.TrimSpace(query.Get("author")); raw != "" {137 author, err := socialProfileAuthor(raw)138 if err != nil {139 return nil, err140 }141 values.Set("author", author)142 }143 if search := query.Get("q"); search != "" {144 values.Set("q", search)145 }146 return values, nil147}148149func podcastFeedChannel(base, slug string, query url.Values) podcastChannel {150 channel := podcastChannel{151 Title: slug + " Podcasts",152 Link: base + "/social?kind=podcasts",153 Description: "Podcast episodes shared on " + slug + ".",154 Self: podcastAtomLink{Href: base + podcastFeedURL(query), Rel: "self", Type: "application/rss+xml"},155 }156 if author := query.Get("author"); author != "" {157 channel.Title = shortID(author) + " Podcasts | " + slug158 channel.Link = base + "/social/profile?author=" + url.QueryEscape(author) + "&kind=podcasts"159 }160 return channel161}162163func podcastFeedItem(base string, row map[string]any) (podcastItem, bool) {164 episode, ok := podcasts.ParseEpisode(socialPodcastEvent(row))165 if !ok {166 return podcastItem{}, false167 }168 audio := episode.Audio[0]169 itemURL := base + socialURL(row)170 description := episode.Description171 if description == "" {172 description = episode.Content173 }174 item := podcastItem{Title: episode.Title, Link: itemURL, Description: description, GUID: podcastGUID{Value: episode.ID, IsPermaLink: "false"}, Enclosure: podcastEnclosure{URL: audio.URL, Type: audio.MIME, Length: audio.Length}, Author: plainString(valueMap(row["podcast"])["title"]), Subtitle: description, Summary: description}175 if episode.Content != "" {176 item.Content = string(renderMarkdownWith(episode.Content, nil))177 }178 if episode.Image != "" {179 item.Image = &podcastImage{Href: episode.Image}180 }181 if item.Title == "" {182 item.Title = podcastEpisodeTitle(row, "")183 }184 if item.GUID.Value == "" {185 item.GUID.Value = itemURL186 }187 if seconds := episode.CreatedAt; seconds > 0 {188 item.PubDate = time.Unix(seconds, 0).UTC().Format(time.RFC1123Z)189 }190 return item, true191}192193func podcastEpisodeTitle(row map[string]any, filename string) string {194 if title := strings.TrimSpace(plainString(row["title"])); title != "" {195 return title196 }197 content := strings.TrimSpace(plainString(row["content"]))198 line, _, _ := strings.Cut(content, "\n")199 if line != "" && socialMediaURL(line) == "" {200 return socialPreview(line)201 }202 if filename != "" {203 return filename204 }205 return "Untitled podcast"206}