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 / vchan / ytcatalog.go
ytcatalog.go 11.8K
1package vchan23import (4 "encoding/json"5 "fmt"6 "math"7 "os"8 "path/filepath"9 "time"1011 "airwaves/internal/youtube"12)1314// ytConfig is a YouTube channel's youtube.json.15type ytConfig struct {16 // Channels are the YouTube channels whose uploads play: channel URLs,17 // "@handle"s or "UC…" channel IDs.18 Channels []string `json:"channels"`19 // Playlist, instead of Channels, plays a playlist in its order,20 // looping: its link or ID (see ytplaylist.go). Of the settings below,21 // only MaxHeight applies to it.22 Playlist string `json:"playlist,omitempty"`23 // RepeatDays is how long before a video may air again, when the24 // channels have enough videos.25 RepeatDays float64 `json:"repeatDays"`26 MaxHeight int `json:"maxHeight"` // picture height to stream, at most27 // MinMinutes and MaxMinutes leave out shorter and longer videos;28 // MaxMinutes 0 has no limit.29 MinMinutes float64 `json:"minMinutes"`30 MaxMinutes float64 `json:"maxMinutes"`31 // RerunMix is how reruns lean to recent uploads: "recent" (about 70%32 // from the last six months, 20% from the 18 months before, 10% from33 // any time), "balanced" (40/30/30) or "any" (all equally likely).34 RerunMix string `json:"rerunMix"`35 // LockHours is how far ahead the schedule is kept as it is when new36 // uploads arrive; they go in after that. Editing the config replans37 // at once regardless.38 LockHours float64 `json:"lockHours"`39 // MaxAgeDays, when set, leaves out videos uploaded longer ago, first40 // runs and reruns alike, as ages are judged for the rerun mix. Videos41 // age out as days pass; 0 has no limit.42 MaxAgeDays float64 `json:"maxAgeDays"`43 // DeadAir leaves the channel off the air rather than repeat a video44 // within RepeatDays: a YouTube channel with nothing rested sits out45 // its turn, and with none left, "Please stand by" plays until a new46 // upload arrives or a video is rested again.47 DeadAir bool `json:"deadAir"`48}4950// ytRerunMixes are the rerun mixes' shares of reruns from the last six51// months, the 18 months before, and any time.52var ytRerunMixes = map[string][3]float64{53 "recent": {0.7, 0.2, 0.1},54 "balanced": {0.4, 0.3, 0.3},55 "any": {0, 0, 1},56}5758func readYtConfig(path string) (ytConfig, error) {59 c := ytConfig{RepeatDays: 30, MaxHeight: 720, MinMinutes: 3, MaxMinutes: 240, RerunMix: "balanced", LockHours: 2}60 raw, err := os.ReadFile(path)61 if err != nil {62 return c, err63 }64 if err := json.Unmarshal(raw, &c); err != nil {65 return c, fmt.Errorf("%s: %w", filepath.Base(path), err)66 }67 switch {68 case c.Playlist != "" && len(c.Channels) > 0:69 return c, fmt.Errorf("%s names both channels and a playlist; it plays one or the other", filepath.Base(path))70 case c.Playlist != "":71 if _, err := youtube.Playlist(c.Playlist); err != nil {72 return c, fmt.Errorf("%s: playlist %q: %w", filepath.Base(path), c.Playlist, err)73 }74 case len(c.Channels) == 0:75 return c, fmt.Errorf("%s names no channels or playlist", filepath.Base(path))76 }77 if _, ok := ytRerunMixes[c.RerunMix]; !ok {78 return c, fmt.Errorf("%s: rerunMix is %q; it can be \"recent\", \"balanced\" or \"any\"", filepath.Base(path), c.RerunMix)79 }80 if c.MaxHeight <= 0 {81 c.MaxHeight = 72082 }83 c.LockHours = max(c.LockHours, 0)84 c.MaxAgeDays = max(c.MaxAgeDays, 0)85 return c, nil86}8788func (c ytConfig) repeat() time.Duration {89 return time.Duration(c.RepeatDays * float64(24*time.Hour))90}9192// maxAge is MaxAgeDays as a duration, 0 for no limit.93func (c ytConfig) maxAge() time.Duration {94 return time.Duration(c.MaxAgeDays * float64(24*time.Hour))95}9697// playable reports whether v can go on the schedule.98func (c ytConfig) playable(v *ytVideo) bool {99 if v.Skip != "" || v.Seconds <= 0 {100 return false101 }102 m := float64(v.Seconds) / 60103 return m >= c.MinMinutes && (c.MaxMinutes <= 0 || m <= c.MaxMinutes)104}105106// ytCatalog is what's known about the channels' uploads: metadata only,107// kept in the channel folder so restarts don't list them again.108type ytCatalog struct {109 Sources []*ytSource `json:"sources"`110}111112// ytSource is one YouTube channel's uploads.113type ytSource struct {114 URL string `json:"url"` // as configured115 ID string `json:"id"` // "UC…"116 Name string `json:"name"`117 Listed time.Time `json:"listed,omitzero"` // the last full listing118 Checked time.Time `json:"checked,omitzero"` // the last feed check119 // Dated is set once a listing has asked for upload dates; catalogs120 // listed before that are listed again straight away.121 Dated bool `json:"dated,omitempty"`122 Videos []*ytVideo `json:"videos"` // newest first; a playlist's in its order123 // Owner and OwnerID are a playlist's channel, the one that made it.124 Owner string `json:"owner,omitempty"`125 OwnerID string `json:"ownerID,omitempty"`126}127128// ytVideo is an upload.129type ytVideo struct {130 ID string `json:"id"`131 Title string `json:"title"`132 Seconds int `json:"seconds,omitempty"` // 0 until known133 // Published is the upload date; Approx marks one a listing gave,134 // from YouTube's "3 years ago", rather than the feed or a lookup.135 Published time.Time `json:"published,omitzero"`136 Approx bool `json:"approx,omitempty"`137 Description string `json:"description,omitempty"` // its first paragraph138 // Found is when a video that came after the first listing was found.139 Found time.Time `json:"found,omitzero"`140 New bool `json:"new,omitempty"` // its first run is due141 // Skip says why the video can't play: "short", "live", "upcoming" (a142 // premiere, checked again later) or "gone".143 Skip string `json:"skip,omitempty"`144 Looked time.Time `json:"looked,omitzero"` // the last lookup of its details145 // Loudness is its sound's, once measured (see ytloudness.go).146 Loudness *loudness `json:"loudness,omitempty"`147 // AudioLang is its sound's language as YouTube gives it ("en-US",148 // "es"), "" until looked up or when YouTube doesn't say; Captions is149 // whether it has English captions, the uploader's or YouTube's.150 AudioLang string `json:"audioLang,omitempty"`151 Captions bool `json:"captions,omitempty"`152 // Channel is the name of a playlist's video's channel, which may not153 // be the playlist's.154 Channel string `json:"channel,omitempty"`155}156157func readYtCatalog(path string) (ytCatalog, error) {158 var c ytCatalog159 raw, err := os.ReadFile(path)160 if err != nil {161 return c, err162 }163 err = json.Unmarshal(raw, &c)164 return c, err165}166167// source returns the catalog of the channel configured as url, adding an168// empty one.169func (c *ytCatalog) source(url string) *ytSource {170 for _, s := range c.Sources {171 if s.URL == url {172 return s173 }174 }175 s := &ytSource{URL: url}176 c.Sources = append(c.Sources, s)177 return s178}179180// ytListing is a channel's uploads tab, from yt-dlp --flat-playlist -J181// with youtube.ListingArgs.182type ytListing struct {183 ID, Name string184 Videos []*ytVideo // newest first; a playlist's in its order185 // Owner and OwnerID are a playlist's channel (see parseYtPlaylist).186 Owner, OwnerID string187}188189func parseYtListing(raw []byte) (ytListing, error) {190 l, err := youtube.ParseListing(raw)191 return listingOf(l, true), err192}193194// listingOf is a listing as the catalog keeps it: dated, its upload195// dates, rough as they are.196func listingOf(l youtube.Listing, dated bool) ytListing {197 out := ytListing{ID: l.ID, Name: l.Name, Owner: l.Owner, OwnerID: l.OwnerID}198 for _, e := range l.Entries {199 v := &ytVideo{ID: e.ID, Title: e.Title, Seconds: e.Seconds, Skip: e.Skip, Channel: e.Channel}200 if dated && !e.Published.IsZero() {201 v.Published, v.Approx = e.Published, true202 }203 out.Videos = append(out.Videos, v)204 }205 return out206}207208// ytNewAtTop is how far down a listing a video it didn't have before209// counts as a new upload, due a first run, rather than an old one that210// came back.211const ytNewAtTop = 15212213// mergeListing replaces the source's videos with a full listing, keeping214// what's known about each. Videos found lately that the listing lacks215// stay a while: Shorts and streams from the feed, and uploads the listing216// doesn't show yet.217func (s *ytSource) mergeListing(l ytListing, now time.Time) (added int) {218 old := map[string]*ytVideo{}219 for _, v := range s.Videos {220 old[v.ID] = v221 }222 first := s.Listed.IsZero()223 seen := map[string]bool{}224 var out []*ytVideo225 for i, v := range l.Videos {226 if seen[v.ID] {227 continue228 }229 seen[v.ID] = true230 if o := old[v.ID]; o != nil {231 o.Title = v.Title232 if v.Seconds > 0 {233 o.Seconds = v.Seconds234 }235 if o.Skip == "upcoming" && v.Skip == "" && v.Seconds > 0 {236 o.Skip = ""237 }238 if !v.Published.IsZero() && (o.Published.IsZero() || o.Approx) {239 o.Published, o.Approx = v.Published, true240 }241 out = append(out, o)242 continue243 }244 if !first {245 v.Found = now246 v.New = i < ytNewAtTop && v.Skip == ""247 added++248 }249 out = append(out, v)250 }251 var recent []*ytVideo // ahead of the listing, keeping newest first252 for _, o := range s.Videos {253 if !seen[o.ID] && !o.Found.IsZero() && now.Sub(o.Found) < 3*24*time.Hour {254 recent = append(recent, o)255 }256 }257 s.ID, s.Name, s.Listed, s.Dated, s.Videos = l.ID, l.Name, now, true, append(recent, out...)258 return added259}260261// mergeNewest takes in the top of a listing in the feed's place: uploads262// the source doesn't have yet are new, due a first run, as from the feed,263// and ones it has get a length or rough date they lack. The rest of the264// catalog is left as it is.265func (s *ytSource) mergeNewest(l ytListing, now time.Time) (added int, changed bool) {266 known := map[string]*ytVideo{}267 for _, v := range s.Videos {268 known[v.ID] = v269 }270 var fresh []*ytVideo271 for _, v := range l.Videos {272 if o := known[v.ID]; o != nil {273 if o.Seconds == 0 && v.Seconds > 0 {274 o.Seconds, changed = v.Seconds, true275 }276 if o.Published.IsZero() && !v.Published.IsZero() {277 o.Published, o.Approx, changed = v.Published, true, true278 }279 continue280 }281 v.Found, v.New = now, v.Skip == ""282 if v.New {283 added++284 }285 known[v.ID] = v286 fresh = append(fresh, v)287 }288 s.Videos = append(fresh, s.Videos...)289 return added, changed || len(fresh) > 0290}291292// mergeFeed adds the feed's uploads the source doesn't have yet, as new293// videos due a first run once their details are looked up, and fills in294// the dates and descriptions of ones it has. Shorts are recognized by295// their links, and kept only so they aren't looked at again. added counts296// the new uploads.297func (s *ytSource) mergeFeed(entries []youtube.FeedEntry, now time.Time) (added int, changed bool) {298 known := map[string]*ytVideo{}299 for _, v := range s.Videos {300 known[v.ID] = v301 }302 var fresh []*ytVideo303 for _, e := range entries {304 if e.ID == "" {305 continue306 }307 if v := known[e.ID]; v != nil {308 if (v.Published.IsZero() || v.Approx) && !e.Published.IsZero() {309 v.Published, v.Approx, changed = e.Published, false, true310 }311 if d := youtube.Description(e.Description); v.Description == "" && d != "" {312 v.Description, changed = d, true313 }314 continue315 }316 v := &ytVideo{ID: e.ID, Title: e.Title, Published: e.Published,317 Description: youtube.Description(e.Description), Found: now, New: true}318 if e.Short() {319 v.Skip, v.New = "short", false320 } else {321 added++322 }323 known[e.ID] = v324 fresh = append(fresh, v)325 }326 s.Videos = append(fresh, s.Videos...)327 s.Checked = now328 return added, changed || len(fresh) > 0329}330331// learn takes in a video's looked-up details.332func (v *ytVideo) learn(i youtube.Info, now time.Time) {333 v.Looked = now334 if i.Title != "" {335 v.Title = i.Title336 }337 if i.Duration > 0 {338 v.Seconds = int(math.Round(i.Duration))339 }340 if i.Timestamp > 0 {341 v.Published, v.Approx = time.Unix(int64(i.Timestamp), 0).UTC(), false342 }343 if d := youtube.Description(i.Description); d != "" {344 v.Description = d345 }346 if l := i.AudioLang(); l != "" {347 v.AudioLang = l348 }349 _, v.Captions = i.Captions()350 switch {351 case i.MediaType == "short":352 v.Skip = "short"353 case i.LiveStatus == "is_upcoming":354 v.Skip = "upcoming"355 case i.LiveStatus == "is_live" || i.LiveStatus == "post_live" || i.LiveStatus == "was_live" || i.MediaType == "livestream":356 v.Skip = "live"357 case v.Skip == "upcoming" || v.Skip == "gone":358 v.Skip = ""359 }360 if v.Skip != "" {361 v.New = false362 }363}