clone

a desktop tv and program guide for over-the-air broadcasts: what's on air, what your tuner gets, live tv, recordings

main
airwaves / internal / podcast / podcast.go
podcast.go 13.9K
1// Package podcast is the Podcasts app's library: video podcasts from2// YouTube, watched on demand. A show is a YouTube channel's tab (its3// videos, or its past live streams) or a playlist; the library keeps its4// newest episodes, what's known of each, and how far each has been5// watched, in one file. Nothing is downloaded: an episode streams from6// YouTube while it's watched, as the YouTube channels' videos do.7package podcast89import (10 "context"11 "crypto/sha1"12 "encoding/hex"13 "encoding/json"14 "errors"15 "fmt"16 "io/fs"17 "log"18 "net/url"19 "slices"20 "strings"21 "sync"22 "time"2324 "airwaves/internal/cc"25 "airwaves/internal/store"26 "airwaves/internal/tuner"27 "airwaves/internal/youtube"28)2930const (31 // keepEpisodes is how many of a show's newest episodes are kept, and32 // listLimit how far down its listing they're looked for.33 keepEpisodes = 3034 listLimit = 4035 // DefaultMinMinutes leaves out clips: shorter videos aren't episodes.36 DefaultMinMinutes = 1037 // maxHeight is the picture streamed, at most.38 maxHeight = 108039)4041var (42 // ErrBadShow is wrapped by errors about what was given as a show.43 ErrBadShow = errors.New("not a show")44 // ErrNotFound is the error for a show or an episode the library45 // doesn't have.46 ErrNotFound = errors.New("no such show or episode")47)4849// Library is the shows and their episodes, kept in Path.50type Library struct {51 Path string52 // YtDlp runs yt-dlp; yt-dlp from PATH when nil.53 YtDlp *youtube.YtDlp54 // FFmpeg is the ffmpeg that plays episodes, asked whether it fetches55 // in ranges.56 FFmpeg string5758 mu sync.Mutex59 loaded bool60 st file61 resolved map[string]resolved // where episodes stream from, by ID62 captions map[string][]cc.Cue // episodes' English captions, nil for none63 updating bool // listing a show64 kick chan struct{}65 now func() time.Time66}6768// file is the library as Path keeps it.69type file struct {70 Shows []*show `json:"shows"`71}7273// show is a show as the file keeps it.74type show struct {75 ID string `json:"id"`76 URL string `json:"url"` // its tab or playlist, as listed77 Title string `json:"title"`78 Thumbnail string `json:"thumbnail,omitempty"`79 MinMinutes float64 `json:"minMinutes"`80 Error string `json:"error,omitempty"` // the last listing's81 ChannelID string `json:"channelId,omitempty"`82 Listed time.Time `json:"listed,omitzero"`83 Checked time.Time `json:"checked,omitzero"` // its channel's feed84 Episodes []*episode `json:"episodes"` // newest first85 // Skipped are videos found not to be episodes (clips, Shorts), so86 // the feed doesn't bring them back.87 Skipped []string `json:"skipped,omitempty"`88}8990// episode is an episode as the file keeps it.91type episode struct {92 ID string `json:"id"`93 Title string `json:"title"`94 Description string `json:"description,omitempty"`95 Published time.Time `json:"published,omitzero"`96 Approx bool `json:"approx,omitempty"` // Published is a listing's rough date97 Seconds float64 `json:"seconds,omitempty"`98 Thumbnail string `json:"thumbnail,omitempty"`99 Looked time.Time `json:"looked,omitzero"` // its details, looked up100 Position float64 `json:"position,omitempty"`101 Watched bool `json:"watched,omitempty"`102}103104// State is the library for the app: its shows, as they were added,105// whether a show is being listed, and since when every show has been (the106// longest ago of their listings; zero without shows).107type State struct {108 Shows []Show `json:"shows"`109 Updating bool `json:"updating"`110 Listed time.Time `json:"listed,omitzero"`111}112113// Show is a show for the app.114type Show struct {115 ID string `json:"id"`116 Title string `json:"title"`117 URL string `json:"url"`118 Thumbnail string `json:"thumbnail"`119 MinMinutes float64 `json:"minMinutes"`120 Error string `json:"error,omitempty"`121 Episodes []Episode `json:"episodes"` // newest first122}123124// Episode is an episode for the app: Duration and Position in seconds.125type Episode struct {126 ID string `json:"id"`127 ShowID string `json:"showId"`128 Title string `json:"title"`129 Description string `json:"description"`130 Published time.Time `json:"published,omitzero"`131 Duration float64 `json:"duration"`132 Thumbnail string `json:"thumbnail"`133 Position float64 `json:"position"`134 Watched bool `json:"watched"`135}136137// Found is a YouTube channel a search found, to add as a show.138type Found struct {139 ID string `json:"id"`140 Title string `json:"title"`141 URL string `json:"url"`142 Thumbnail string `json:"thumbnail"`143 Description string `json:"description"`144 Subscribers int64 `json:"subscribers"`145}146147func (l *Library) tool() *youtube.YtDlp {148 if l.YtDlp != nil {149 return l.YtDlp150 }151 return pathYtDlp152}153154// pathYtDlp runs yt-dlp from PATH for a library given none.155var pathYtDlp = &youtube.YtDlp{}156157func (l *Library) clock() time.Time {158 if l.now != nil {159 return l.now()160 }161 return time.Now()162}163164// load reads the file once. l.mu is held.165func (l *Library) load() {166 if l.loaded {167 return168 }169 l.loaded = true170 l.resolved = map[string]resolved{}171 l.kick = make(chan struct{}, 1)172 err := store.ReadFile(l.Path, func(raw []byte) error { return json.Unmarshal(raw, &l.st) })173 if err != nil && !errors.Is(err, fs.ErrNotExist) {174 log.Printf("podcasts: %v; starting over", err)175 l.st = file{}176 }177}178179// save writes the file. l.mu is held.180func (l *Library) save() {181 raw, err := json.MarshalIndent(l.st, "", " ")182 if err == nil {183 err = store.WriteFile(l.Path, raw, 0o644)184 }185 if err != nil {186 log.Printf("podcasts: save: %v", err)187 }188}189190// State lists the shows and their episodes.191func (l *Library) State() State {192 l.mu.Lock()193 defer l.mu.Unlock()194 l.load()195 out := State{Shows: make([]Show, 0, len(l.st.Shows)), Updating: l.updating}196 for i, s := range l.st.Shows {197 out.Shows = append(out.Shows, s.view())198 if i == 0 || s.Listed.Before(out.Listed) {199 out.Listed = s.Listed200 }201 }202 return out203}204205func (s *show) view() Show {206 v := Show{ID: s.ID, Title: s.Title, URL: s.URL, Thumbnail: s.Thumbnail, MinMinutes: s.MinMinutes, Error: s.Error,207 Episodes: make([]Episode, 0, len(s.Episodes))}208 for _, e := range s.Episodes {209 v.Episodes = append(v.Episodes, Episode{ID: e.ID, ShowID: s.ID, Title: e.Title, Description: e.Description,210 Published: e.Published, Duration: e.Seconds, Thumbnail: e.thumbnail(), Position: e.Position, Watched: e.Watched})211 }212 return v213}214215func (e *episode) thumbnail() string {216 if e.Thumbnail != "" {217 return e.Thumbnail218 }219 return youtube.Thumbnail(e.ID)220}221222// Search finds YouTube channels to add as shows.223func (l *Library) Search(ctx context.Context, query string) ([]Found, error) {224 query = strings.TrimSpace(query)225 if query == "" {226 return nil, fmt.Errorf("%w: search for something", ErrBadShow)227 }228 cs, err := l.tool().SearchChannels(ctx, query, 12)229 if err != nil {230 return nil, err231 }232 out := make([]Found, 0, len(cs))233 for _, c := range cs {234 out = append(out, Found{ID: c.ID, Title: c.Name, URL: c.URL, Thumbnail: c.Image, Description: c.Description, Subscribers: c.Subscribers})235 }236 return out, nil237}238239// Add adds the show at raw (a channel, one of its tabs, or a playlist; see240// source), listing it first, at once (see list), with minMinutes the241// shortest video that's an episode (DefaultMinMinutes when 0). A show242// already there is returned as it is.243func (l *Library) Add(ctx context.Context, raw string, minMinutes float64) (Show, error) {244 src, err := source(raw)245 if err != nil {246 return Show{}, err247 }248 if minMinutes <= 0 {249 minMinutes = DefaultMinMinutes250 }251 id := showID(src.url)252 l.mu.Lock()253 l.load()254 if s := l.find(id); s != nil {255 v := s.view()256 l.mu.Unlock()257 return v, nil258 }259 l.mu.Unlock()260 s := &show{ID: id, URL: src.url, MinMinutes: minMinutes}261 lst, err := l.list(ctx, src)262 if err != nil {263 return Show{}, err264 }265 l.mu.Lock()266 defer l.mu.Unlock()267 if old := l.find(id); old != nil { // added meanwhile268 return old.view(), nil269 }270 s.merge(lst, src, l.clock())271 l.st.Shows = append(l.st.Shows, s)272 l.save()273 l.poke()274 log.Printf("podcasts: added %s (%s), %d episodes", s.Title, s.URL, len(s.Episodes))275 return s.view(), nil276}277278// Remove takes a show out of the library.279func (l *Library) Remove(id string) error {280 l.mu.Lock()281 defer l.mu.Unlock()282 l.load()283 i := slices.IndexFunc(l.st.Shows, func(s *show) bool { return s.ID == id })284 if i < 0 {285 return ErrNotFound286 }287 for _, e := range l.st.Shows[i].Episodes {288 delete(l.resolved, e.ID)289 }290 l.st.Shows = slices.Delete(l.st.Shows, i, i+1)291 l.save()292 return nil293}294295// Progress records how far an episode has been watched: Position296// seconds in, of duration (which fills in a length the library lacks).297// It's watched when the app says so, or within a minute of its end.298func (l *Library) Progress(id string, position, duration float64, watched bool) error {299 l.mu.Lock()300 defer l.mu.Unlock()301 l.load()302 _, e := l.episode(id)303 if e == nil {304 return ErrNotFound305 }306 if e.Seconds <= 0 && duration > 0 {307 e.Seconds = duration308 }309 e.Position = max(position, 0)310 e.Watched = watched || e.Seconds > 0 && e.Position >= e.Seconds-60311 l.save()312 return nil313}314315// find is the show with id. l.mu is held.316func (l *Library) find(id string) *show {317 for _, s := range l.st.Shows {318 if s.ID == id {319 return s320 }321 }322 return nil323}324325// episode is the episode with id, and its show. l.mu is held.326func (l *Library) episode(id string) (*show, *episode) {327 for _, s := range l.st.Shows {328 for _, e := range s.Episodes {329 if e.ID == id {330 return s, e331 }332 }333 }334 return nil, nil335}336337// src is what a show lists: a channel's tab ("videos" or "streams"), or338// a playlist.339type src struct {340 url string341 playlist bool342 tab string343}344345// source reads a show as given: a playlist's link or ID; or a channel, as346// its link, "@handle" or "UC…" ID, its videos unless the link is to its347// live streams' tab (/streams).348func source(raw string) (src, error) {349 raw = strings.TrimSpace(raw)350 if raw == "" {351 return src{}, fmt.Errorf("%w: give a YouTube channel or playlist", ErrBadShow)352 }353 if page, err := youtube.Playlist(raw); err == nil {354 return src{url: page, playlist: true}, nil355 }356 u := raw357 if !strings.HasPrefix(u, "@") && !(strings.HasPrefix(u, "UC") && !strings.Contains(u, "/")) {358 if !strings.Contains(u, "://") {359 u = "https://" + u360 }361 p, err := url.Parse(u)362 host := ""363 if err == nil {364 host = strings.TrimPrefix(strings.TrimPrefix(strings.ToLower(p.Hostname()), "www."), "m.")365 }366 switch {367 case host == "youtu.be", host == "youtube.com" && (p.Path == "/watch" || strings.HasPrefix(p.Path, "/shorts/") || strings.HasPrefix(p.Path, "/live/")):368 return src{}, fmt.Errorf("%w: that's a link to a video; give its channel or a playlist", ErrBadShow)369 case host != "youtube.com":370 return src{}, fmt.Errorf("%w: give a YouTube channel or playlist", ErrBadShow)371 case strings.HasSuffix(strings.TrimRight(p.Path, "/"), "/podcasts"):372 return src{}, fmt.Errorf("%w: that's the channel's list of podcasts; give one of them, a playlist", ErrBadShow)373 }374 u = "https://www.youtube.com" + p.Path // one address for a show375 }376 tab := "videos"377 if strings.HasSuffix(strings.TrimRight(u, "/"), "/streams") {378 tab = "streams"379 }380 base := strings.TrimSuffix(youtube.VideosURL(u), "/videos")381 return src{url: base + "/" + tab, tab: tab}, nil382}383384func (s *show) source() src {385 if strings.Contains(s.URL, "playlist?list=") {386 return src{url: s.URL, playlist: true}387 }388 tab := "videos"389 if strings.HasSuffix(s.URL, "/streams") {390 tab = "streams"391 }392 return src{url: s.URL, tab: tab}393}394395// showID names a show by its address: short, and the same each time.396func showID(u string) string {397 sum := sha1.Sum([]byte(u))398 return hex.EncodeToString(sum[:6])399}400401// Input is how to play an episode from from seconds in: where it streams402// from (found ahead, or now), for the stream server to copy its H.264403// picture and make its sound AAC, keeping every segment to seek in.404func (l *Library) Input(ctx context.Context, id string, from float64) (tuner.Input, error) {405 l.mu.Lock()406 l.load()407 _, e := l.episode(id)408 var length float64409 if e != nil {410 length = e.Seconds411 }412 l.mu.Unlock()413 if e == nil {414 return tuner.Input{}, ErrNotFound415 }416 info, err := l.resolve(ctx, id)417 if err != nil {418 return tuner.Input{}, err419 }420 in := info.Inputs()421 if from <= 5 || length > 0 && from >= length-5 {422 from = 0423 }424 start := time.Duration(from * float64(time.Second))425 args, audio := youtube.InputArgs(in, start, youtube.HTTPChunks(l.FFmpeg))426 out := tuner.Input{Args: args, Video: "0:v:0", VOD: true, Offset: from, Copy: strings.HasPrefix(in[0].VCodec, "avc1")}427 if audio != "" {428 out.Audio = []tuner.AudioTrack{{Map: audio}}429 }430 // English captions, as text for the app to lay out, timed from where431 // the stream starts.432 cctx, cancel := context.WithTimeout(ctx, playCaptionsTime)433 defer cancel()434 if cues := l.captionsFor(cctx, id, info); len(cues) > 0 {435 out.Subtitles = &cc.Script{}436 out.Subtitles.Set(0, 1000*time.Hour, cc.Shift(cues, -start))437 }438 return out, nil439}440441const (442 // playCaptionsTime bounds waiting for an episode's captions at Play443 // (they're usually fetched ahead); captionsTime fetching them ahead.444 playCaptionsTime = 8 * time.Second445 captionsTime = 30 * time.Second446 // keptCaptions bounds how many episodes' captions are kept.447 keptCaptions = 64448)449450// captionsFor are an episode's English captions, the uploader's or451// YouTube's automatic ones, fetched once; nil when it has none. A failed452// fetch is tried again next time.453func (l *Library) captionsFor(ctx context.Context, id string, info youtube.Info) []cc.Cue {454 l.mu.Lock()455 cues, ok := l.captions[id]456 l.mu.Unlock()457 if ok {458 return cues459 }460 if sub, ok := info.Captions(); ok {461 raw, err := youtube.FetchCaptions(ctx, l.tool().Client(), sub)462 if err == nil {463 cues, err = cc.Parse(raw)464 }465 if err != nil {466 log.Printf("podcasts: captions for %s: %v", id, err)467 return nil468 }469 }470 l.mu.Lock()471 if l.captions == nil || len(l.captions) >= keptCaptions {472 l.captions = map[string][]cc.Cue{}473 }474 l.captions[id] = cues475 l.mu.Unlock()476 return cues477}