clone

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

feat/ui-updates
tinyrelay / internal / podcasts / podcasts.go
podcasts.go 5.6K
1// Package podcasts reads the native show and episode events defined by NIP-F4.2package podcasts34import (5 "mime"6 "net/url"7 "path"8 "strconv"9 "strings"1011 "github.com/FelineStateMachine/tinyrelay/internal/event"12)1314const (15 KindEpisode = 5416 KindMetadata = 1015417 KindAuthored = 1006418 KindFavorites = 1005419)2021type Audio struct {22 URL string `json:"url"`23 MIME string `json:"mime"`24 Length int64 `json:"length"`25}2627type Episode struct {28 ID string `json:"id"`29 Pubkey string `json:"pubkey"`30 CreatedAt int64 `json:"created_at"`31 Title string `json:"title"`32 Description string `json:"description"`33 Image string `json:"image,omitempty"`34 Content string `json:"content"`35 Audio []Audio `json:"audio"`36}3738type Author struct {39 Pubkey string `json:"pubkey"`40 Role string `json:"role,omitempty"`41}4243type Show struct {44 ID string `json:"id,omitempty"`45 Pubkey string `json:"pubkey"`46 Tags [][]string `json:"tags,omitempty"`47 Title string `json:"title"`48 Description string `json:"description"`49 Image string `json:"image,omitempty"`50 Websites []string `json:"websites,omitempty"`51 Authors []Author `json:"authors,omitempty"`52}5354// ParseEpisode keeps native podcast audio separate from audio attached to notes.55// NIP-92 metadata may supplement an audio tag with a size or media type.56func ParseEpisode(e event.Event) (Episode, bool) {57 if e.Kind != KindEpisode || event.Tag(e, "h") != "" {58 return Episode{}, false59 }60 result := Episode{ID: e.ID, Pubkey: e.PubKey, CreatedAt: e.CreatedAt,61 Title: event.Tag(e, "title"), Description: event.Tag(e, "description"),62 Image: HTTPURL(event.Tag(e, "image")), Content: e.Content}63 metadata, seen := audioMetadata(e), map[string]bool{}64 for _, tag := range e.Tags {65 if len(tag) < 2 || tag[0] != "audio" {66 continue67 }68 raw := HTTPURL(tag[1])69 if raw == "" || seen[raw] {70 continue71 }72 audio := metadata[raw]73 audio.URL = raw74 if len(tag) > 2 && strings.TrimSpace(tag[2]) != "" {75 audio.MIME = tag[2]76 }77 audio.MIME = AudioMIME(raw, audio.MIME)78 if audio.MIME == "" {79 continue80 }81 seen[raw] = true82 result.Audio = append(result.Audio, audio)83 }84 return result, len(result.Audio) > 085}8687func audioMetadata(e event.Event) map[string]Audio {88 metadata := map[string]Audio{}89 for _, tag := range e.Tags {90 if len(tag) < 2 || tag[0] != "imeta" {91 continue92 }93 audio := Audio{}94 for _, field := range tag[1:] {95 name, value, _ := strings.Cut(field, " ")96 switch name {97 case "url":98 audio.URL = HTTPURL(value)99 case "m":100 audio.MIME = value101 case "size":102 if size, err := strconv.ParseInt(value, 10, 64); err == nil && size >= 0 {103 audio.Length = size104 }105 }106 }107 if audio.URL != "" {108 metadata[audio.URL] = audio109 }110 }111 return metadata112}113114// AudioMIME retains explicit audio types, otherwise inferring familiar formats.115// An opaque URL with no type remains a generic enclosure for the player to read.116func AudioMIME(raw, declared string) string {117 if strings.TrimSpace(declared) != "" {118 typ, _, err := mime.ParseMediaType(strings.TrimSpace(declared))119 if err != nil {120 return ""121 }122 if typ == "application/ogg" {123 return "audio/ogg"124 }125 if strings.HasPrefix(typ, "audio/") || typ == "application/octet-stream" {126 return typ127 }128 return ""129 }130 parsed, err := url.Parse(raw)131 if err != nil {132 return ""133 }134 switch strings.ToLower(path.Ext(parsed.Path)) {135 case ".mp3":136 return "audio/mpeg"137 case ".m4a", ".mp4":138 return "audio/mp4"139 case ".ogg", ".oga", ".opus":140 return "audio/ogg"141 case ".wav":142 return "audio/wav"143 case ".flac":144 return "audio/flac"145 case ".aac":146 return "audio/aac"147 default:148 return "application/octet-stream"149 }150}151152func HTTPURL(raw string) string {153 raw = strings.TrimSpace(raw)154 parsed, err := url.Parse(raw)155 if err != nil || parsed.User != nil || parsed.Hostname() == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") {156 return ""157 }158 return raw159}160161// Shows folds replaceable show metadata and checks the latest reciprocal author162// list. NIP-51 assigns authored podcasts kind 10064, also used in F4's example.163func Shows(events []event.Event) map[string]Show {164 metadata, authored := latest(events, KindMetadata), latest(events, KindAuthored)165 out := make(map[string]Show, len(metadata))166 for key, row := range metadata {167 show := Show{ID: row.ID, Pubkey: key, Tags: row.Tags, Title: event.Tag(row, "title"),168 Description: event.Tag(row, "description"), Image: HTTPURL(event.Tag(row, "image"))}169 seen := map[string]bool{}170 for _, tag := range row.Tags {171 if len(tag) < 2 {172 continue173 }174 if tag[0] == "website" {175 if raw := HTTPURL(tag[1]); raw != "" && !seen[raw] {176 show.Websites = append(show.Websites, raw)177 seen[raw] = true178 }179 }180 if tag[0] == "p" && !seen[tag[1]] && listsShow(authored[tag[1]], key) {181 role := ""182 if len(tag) > 2 {183 role = tag[2]184 }185 if role == "" || role == "host" || role == "cohost" || role == "editor" {186 show.Authors = append(show.Authors, Author{Pubkey: tag[1], Role: role})187 seen[tag[1]] = true188 }189 }190 }191 out[key] = show192 }193 return out194}195196func latest(events []event.Event, kind int) map[string]event.Event {197 out := map[string]event.Event{}198 for _, row := range events {199 if row.Kind != kind || event.Tag(row, "h") != "" {200 continue201 }202 old, exists := out[row.PubKey]203 if !exists || row.CreatedAt > old.CreatedAt || row.CreatedAt == old.CreatedAt && row.ID < old.ID {204 out[row.PubKey] = row205 }206 }207 return out208}209210func listsShow(list event.Event, show string) bool {211 for _, tag := range list.Tags {212 if len(tag) > 1 && tag[0] == "p" && tag[1] == show {213 return true214 }215 }216 return false217}