summaryrefslogtreecommitdiff
path: root/server/internal/feed/feed.go
blob: 493fbddcfc3ec770af7431d351f1817306005e9f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
// Package feed parses a podcast RSS feed down to the few fields the server needs.
package feed

import (
	"encoding/xml"
	"fmt"
	"sort"
	"strconv"
	"strings"
	"time"
)

// Episode is one audio episode as its feed describes it.
type Episode struct {
	GUID  string
	Title string
	// EnclosureURL is where the audio is, and the fallback identity when a GUID moves.
	EnclosureURL  string
	EnclosureType string
	PublishedUnix int64
	// DeclaredDurationMs is the feed's claim, which is routinely wrong -- a show using dynamic
	// ad insertion declares the ad-free length. Useful for ordering, not for arithmetic.
	DeclaredDurationMs int64
	// TranscriptURL is a Podcasting 2.0 transcript in a format that carries timestamps, or
	// empty. The publisher's own words, which is exact where speech recognition is a guess --
	// but it describes the publisher's master, so whether it describes the file the server
	// mirrored is a separate question. See transcript.Fits.
	TranscriptURL  string
	TranscriptType string
}

// Channel is a show and its audio episodes, newest first.
type Channel struct {
	Title    string
	Episodes []Episode
}

type rss struct {
	Channel struct {
		Title string `xml:"title"`
		Items []struct {
			Title     string `xml:"title"`
			GUID      string `xml:"guid"`
			PubDate   string `xml:"pubDate"`
			Duration  string `xml:"http://www.itunes.com/dtds/podcast-1.0.dtd duration"`
			Enclosure struct {
				URL  string `xml:"url,attr"`
				Type string `xml:"type,attr"`
			} `xml:"enclosure"`
			// Both spellings of the Podcasting 2.0 namespace. The specification was published
			// under http:// and the feeds in the wild now declare https://; Go matches a
			// namespace by exact string, so taking only one silently drops the other's
			// transcripts rather than failing.
			Transcripts  []transcriptTag `xml:"https://podcastindex.org/namespace/1.0 transcript"`
			OldNamespace []transcriptTag `xml:"http://podcastindex.org/namespace/1.0 transcript"`
		} `xml:"item"`
	} `xml:"channel"`
}

// Parse reads a feed and returns its audio episodes, newest first.
//
// Audio only: video was cut from the app's scope, and a YouTube or Facebook episode has no
// enclosure the server could fetch regardless -- its audio only exists once yt-dlp has resolved
// it, which stays the phone's job.
func Parse(raw []byte) (*Channel, error) {
	var doc rss
	if err := xml.Unmarshal(raw, &doc); err != nil {
		return nil, fmt.Errorf("parse feed: %w", err)
	}
	if doc.Channel.Title == "" && len(doc.Channel.Items) == 0 {
		return nil, fmt.Errorf("parse feed: no channel found")
	}

	out := &Channel{Title: strings.TrimSpace(doc.Channel.Title)}
	for _, item := range doc.Channel.Items {
		if !isAudio(item.Enclosure.Type, item.Enclosure.URL) {
			continue
		}
		transcriptURL, transcriptType := bestTranscript(append(item.Transcripts, item.OldNamespace...))
		out.Episodes = append(out.Episodes, Episode{
			GUID:               strings.TrimSpace(item.GUID),
			Title:              strings.TrimSpace(item.Title),
			EnclosureURL:       strings.TrimSpace(item.Enclosure.URL),
			EnclosureType:      strings.TrimSpace(item.Enclosure.Type),
			PublishedUnix:      parseDate(item.PubDate),
			DeclaredDurationMs: parseDuration(item.Duration),
			TranscriptURL:      transcriptURL,
			TranscriptType:     transcriptType,
		})
	}

	// Newest first. Feeds are not reliably ordered, and the retention rule depends on this.
	sort.SliceStable(out.Episodes, func(i, j int) bool {
		return out.Episodes[i].PublishedUnix > out.Episodes[j].PublishedUnix
	})
	return out, nil
}

// transcriptRank orders the transcript formats a publisher may offer. An item routinely carries
// several at once, and the MIME types in the wild disagree about the same format: both
// application/srt and application/x-subrip are SRT.
//
// WebVTT first only because it is the format with a specification that says what a cue is; SRT
// is equally usable and ranks next. text/plain, text/html and application/json are absent
// deliberately -- they carry no timestamps, so a cue cannot be seeked to or placed, and a
// transcript that can do neither is worse than none at all because storing it stops the episode
// being looked at again.
var transcriptRank = map[string]int{
	"text/vtt":             1,
	"text/webvtt":          1,
	"application/srt":      2,
	"application/x-subrip": 2,
	"text/srt":             2,
}

// transcriptTag is one <podcast:transcript> element.
type transcriptTag struct {
	URL  string `xml:"url,attr"`
	Type string `xml:"type,attr"`
}

func bestTranscript(offered []transcriptTag) (string, string) {
	bestRank := 0
	var bestURL, bestType string
	for _, t := range offered {
		url := strings.TrimSpace(t.URL)
		mediaType := strings.ToLower(strings.TrimSpace(t.Type))
		if i := strings.IndexByte(mediaType, ';'); i >= 0 {
			mediaType = strings.TrimSpace(mediaType[:i])
		}
		rank, ok := transcriptRank[mediaType]
		if !ok || url == "" {
			continue
		}
		if bestRank == 0 || rank < bestRank {
			bestRank, bestURL, bestType = rank, url, mediaType
		}
	}
	return bestURL, bestType
}

func isAudio(mediaType, url string) bool {
	if url == "" {
		return false
	}
	if strings.HasPrefix(strings.ToLower(mediaType), "audio/") {
		return true
	}
	// Some feeds omit or mistype the enclosure type; fall back to the extension rather than
	// dropping a real episode over a publisher's metadata slip.
	if mediaType == "" {
		lower := strings.ToLower(url)
		for _, ext := range []string{".mp3", ".m4a", ".aac", ".ogg", ".opus", ".flac", ".wav"} {
			if strings.Contains(lower, ext) {
				return true
			}
		}
	}
	return false
}

// dateLayouts covers what podcast feeds actually emit, which is RFC 1123 with and without a
// named zone plus a few near-misses.
var dateLayouts = []string{
	time.RFC1123Z, time.RFC1123, time.RFC822Z, time.RFC822, time.RFC3339,
	"Mon, 2 Jan 2006 15:04:05 -0700",
	"Mon, 2 Jan 2006 15:04:05 MST",
	"Mon, 02 Jan 2006 15:04 -0700",
	"2 Jan 2006 15:04:05 -0700",
}

func parseDate(raw string) int64 {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return 0
	}
	for _, layout := range dateLayouts {
		if t, err := time.Parse(layout, raw); err == nil {
			return t.Unix()
		}
	}
	return 0
}

// parseDuration reads itunes:duration, which appears as bare seconds, mm:ss, or hh:mm:ss.
func parseDuration(raw string) int64 {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return 0
	}
	parts := strings.Split(raw, ":")
	var total int64
	for _, p := range parts {
		n, err := strconv.ParseInt(strings.TrimSpace(p), 10, 64)
		if err != nil {
			return 0
		}
		total = total*60 + n
	}
	return total * 1000
}