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
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
|
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
// Episode is everything known about one observation. It is stored as
// episodes/NNN.json and is the only source of truth for rendering: the audio
// file carries no metadata we rely on after ingest.
type Episode struct {
Number int `json:"number"`
// EpisodeTitle names the episode when it is not simply "the text being
// read" — a themed episode that works through several texts has a subject
// of its own, and neither source's title is honest as the episode's.
// Empty for the common case, where the sole source's title is used.
EpisodeTitle string `json:"title,omitempty"`
// Sources are the texts being read, in the order they are read. There is
// usually one; an episode built around a theme may read several, each
// carrying an optional Note saying what it is doing there. Every source
// needs its four required fields: an observation is always about
// something, and a reader must be able to find it. See observations(7).
Sources []Source `json:"sources"`
// Recorded is the day the recording was made (YYYY-MM-DD), which is also
// the publication date of the episode.
Recorded string `json:"recorded"`
// Audio describes the file as served, byte for byte as it came off the
// recorder.
Audio Audio `json:"audio"`
// Notes is optional prose shown on the episode page.
Notes string `json:"notes,omitempty"`
// Transcript is the recogniser output extracted from the recording at
// ingest time. It is stored whether or not anything renders it, because
// it exists nowhere else. See observations(7).
//
// It lives in its own file, NNN.transcript, rather than in this one: a
// transcript is hundreds of lines and the metadata around it is twenty,
// so keeping them together made every `git log -p`, diff and editor
// session on an episode unreadable. It is not serialised as part of the
// episode, and is loaded and saved alongside it.
Transcript *Document `json:"-"`
}
type Source struct {
Title string `json:"title"`
URL string `json:"url"`
Author string `json:"author"`
Published string `json:"published"`
// Site is the publication the text appeared in, when it differs from
// the author (a blog name, a magazine).
Site string `json:"site,omitempty"`
// Note says what this text is doing in the episode when that is not
// obvious — "intro only", "the main piece". Only useful with several
// sources, where the reader needs to know which is which.
Note string `json:"note,omitempty"`
}
type Audio struct {
File string `json:"file"`
Bytes int64 `json:"bytes"`
Duration int64 `json:"duration_ms"`
MIME string `json:"mime"`
}
// Slug is the stable identifier used in URLs and feed GUIDs. Numbers are
// permanent; titles are not (observations(7), IDENTIFIERS ARE PERMANENT).
func (e *Episode) Slug() string { return fmt.Sprintf("%03d", e.Number) }
// Subject is the episode's own title, without the number. For the common
// single-source episode this is the source's title — the thing people search
// for — so it need not be written out. A themed episode sets one explicitly.
func (e *Episode) Subject() string {
if e.EpisodeTitle != "" {
return e.EpisodeTitle
}
if len(e.Sources) > 0 {
return e.Sources[0].Title
}
return ""
}
// Title is what a podcast client shows: the number, which is stable and
// orders correctly, and the subject, which is what people search for.
func (e *Episode) Title() string {
return fmt.Sprintf("%s — %s", e.Slug(), e.Subject())
}
// Byline names who wrote the texts, for the episode list. A single source
// carries its publication too, which is useful context; several would turn
// into an unreadable run of alternating names and sites, so only the authors
// are listed and the sites are left to the episode page.
func (e *Episode) Byline() string {
switch len(e.Sources) {
case 0:
return ""
case 1:
s := e.Sources[0]
if s.Site != "" {
return s.Author + " · " + s.Site
}
return s.Author
default:
names := make([]string, 0, len(e.Sources))
for _, s := range e.Sources {
names = append(names, s.Author)
}
return joinAnd(names)
}
}
// MetaDescription is the one-sentence description used in <meta> tags, where
// there is no room for a list and no markup to lay one out.
func (e *Episode) MetaDescription() string {
switch len(e.Sources) {
case 0:
return ""
case 1:
return fmt.Sprintf("Reading “%s” by %s, with commentary.",
e.Sources[0].Title, e.Sources[0].Author)
default:
names := make([]string, 0, len(e.Sources))
for _, s := range e.Sources {
names = append(names, s.Author)
}
return fmt.Sprintf("Reading %s, with commentary.", joinAnd(names))
}
}
// joinAnd renders a list the way a sentence wants it: "a and b", "a, b and c".
func joinAnd(xs []string) string {
switch len(xs) {
case 0:
return ""
case 1:
return xs[0]
default:
return strings.Join(xs[:len(xs)-1], ", ") + " and " + xs[len(xs)-1]
}
}
// timecode renders an offset into the recording the way a listener reads one:
// H:MM:SS for a recording past the hour, M:SS below it. It is used both for
// the running time of an episode and for the timestamps in a rendered
// transcript, which have to agree — a transcript that said 71:16 where the
// player says 1:11:16 would be two clocks for one recording.
func timecode(ms int64) string {
d := time.Duration(ms) * time.Millisecond
h := int(d / time.Hour)
m := int(d/time.Minute) % 60
s := int(d/time.Second) % 60
if h > 0 {
return fmt.Sprintf("%d:%02d:%02d", h, m, s)
}
return fmt.Sprintf("%d:%02d", m, s)
}
// DurationHMS formats the running time the way podcast feeds want it.
func (e *Episode) DurationHMS() string { return timecode(e.Audio.Duration) }
// DurationShort is the human form used on the website ("31 min").
func (e *Episode) DurationShort() string {
m := int((time.Duration(e.Audio.Duration) * time.Millisecond) / time.Minute)
if m < 1 {
return "under a minute"
}
return fmt.Sprintf("%d min", m)
}
// RecordedTime parses Recorded, which is validated at ingest.
func (e *Episode) RecordedTime() (time.Time, error) {
return time.Parse("2006-01-02", e.Recorded)
}
func (e *Episode) validate() error {
if e.Number < 1 {
return fmt.Errorf("missing required: number")
}
if len(e.Sources) == 0 {
return fmt.Errorf("an episode needs at least one source")
}
if _, err := time.Parse("2006-01-02", e.Recorded); err != nil {
return fmt.Errorf("recorded date %q is not YYYY-MM-DD", e.Recorded)
}
for i, s := range e.Sources {
if err := s.validate(); err != nil {
// Sources are numbered from 1 in the message because that is how
// they are talked about ("the second source"), not from 0.
return fmt.Errorf("source %d: %w", i+1, err)
}
}
return nil
}
func (s Source) validate() error {
var missing []string
if s.Title == "" {
missing = append(missing, "title")
}
if s.URL == "" {
missing = append(missing, "url")
}
if s.Author == "" {
missing = append(missing, "author")
}
if s.Published == "" {
missing = append(missing, "published")
}
if len(missing) > 0 {
return fmt.Errorf("missing required: %s", strings.Join(missing, ", "))
}
if _, err := time.Parse("2006-01-02", s.Published); err != nil {
return fmt.Errorf("published date %q is not YYYY-MM-DD", s.Published)
}
if !strings.HasPrefix(s.URL, "http://") && !strings.HasPrefix(s.URL, "https://") {
return fmt.Errorf("url %q is not http(s)", s.URL)
}
return nil
}
func episodePath(dir string, number int) string {
return filepath.Join(dir, fmt.Sprintf("%03d.json", number))
}
// sourceID is the short handle a transcript's .quote lines use to name a
// source. It comes from the author's surname, which is what one would reach
// for when marking up a quotation by hand ("that bit is Spolsky"), and falls
// back to a position when that is not usable.
//
// It is derived rather than stored because a transcript is written once and
// edited often: an id that has to be kept in step with the episode JSON is an
// id that will eventually disagree with it.
func sourceID(s Source, i int) string {
fields := strings.Fields(s.Author)
if len(fields) > 0 {
last := fields[len(fields)-1]
var b strings.Builder
for _, r := range strings.ToLower(last) {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
if b.Len() > 0 {
return b.String()
}
}
return fmt.Sprintf("source%d", i+1)
}
// transcriptPath is where an episode's transcript lives. Deriving it from the
// episode's path rather than storing a filename in the episode keeps the two
// from disagreeing: there is nothing to update when an episode is renumbered,
// and no way to point at someone else's transcript.
func transcriptPath(episodeFile string) string {
return strings.TrimSuffix(episodeFile, ".json") + ".transcript"
}
func writeEpisode(dir string, e *Episode) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
if err := writeJSONFile(episodePath(dir, e.Number), e); err != nil {
return err
}
if e.Transcript != nil {
return writeTranscriptFile(transcriptPath(episodePath(dir, e.Number)), e.Transcript)
}
return nil
}
func writeJSONFile(path string, v any) error {
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
return err
}
b = append(b, '\n')
// Written via a temporary file so an interrupted ingest cannot leave a
// half-written file behind that later parses as valid JSON.
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
// readEpisodeMeta reads only episodes/NNN.json, without its transcript. It is
// for callers that are about to write the transcript themselves and so must
// not fail when there is not one yet.
func readEpisodeMeta(path string) (*Episode, error) {
var e Episode
if err := readJSONFile(path, &e); err != nil {
return nil, err
}
return &e, nil
}
func readEpisode(path string) (*Episode, error) {
var e Episode
if err := readJSONFile(path, &e); err != nil {
return nil, err
}
// The transcript is optional on disk: an episode ingested from a recording
// without a transcript track has none.
tp := transcriptPath(path)
if _, err := os.Stat(tp); err == nil {
tr, err := readTranscriptFile(tp)
if err != nil {
return nil, err
}
e.Transcript = tr
} else if !os.IsNotExist(err) {
return nil, err
}
return &e, nil
}
func readJSONFile(path string, v any) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
dec := json.NewDecoder(f)
// Unknown fields are an error: a typo in a hand-edited episode should be
// reported, not silently ignored until someone notices the missing field
// on the published page.
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
return nil
}
// loadEpisodes reads every episode, newest first.
func loadEpisodes(dir string) ([]*Episode, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var eps []*Episode
for _, ent := range entries {
name := ent.Name()
if ent.IsDir() || !strings.HasSuffix(name, ".json") {
continue
}
if _, err := strconv.Atoi(strings.TrimSuffix(name, ".json")); err != nil {
// Not an episode file; ignore rather than fail, so notes and
// scratch files can live alongside. This is also what skips the
// NNN.transcript.json companions, which readEpisode loads itself:
// "002.transcript" is not a number.
continue
}
e, err := readEpisode(filepath.Join(dir, name))
if err != nil {
return nil, err
}
eps = append(eps, e)
}
sort.Slice(eps, func(i, j int) bool { return eps[i].Number > eps[j].Number })
return eps, nil
}
// nextNumber returns the number a newly added episode should get.
func nextNumber(dir string) (int, error) {
eps, err := loadEpisodes(dir)
if err != nil {
return 0, err
}
if len(eps) == 0 {
return 1, nil
}
return eps[0].Number + 1, nil
}
|