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 / details.go
details.go 8.9K
1package vchan23// Channel details: what guides and lineups show of a custom channel4// besides its schedule. A channel folder's are in its channel.json (the5// folder's name stays the source of its number and name, so renaming the6// folder by hand still works); the weather channel has no folder, and7// keeps its whole record, number and name included, in .weather.json in8// the channels folder.910import (11 "encoding/json"12 "fmt"13 "log"14 "net/http"15 "os"16 "path/filepath"17 "regexp"18 "strconv"19 "strings"20 "sync"21 "time"2223 "airwaves/internal/guide"24)2526// Categories are what a custom channel can be filed under, for guides'27// filters.28var Categories = []string{"Kids", "Family", "Movies", "Sports", "News", "Music", "Pets", "Gaming", "Documentary", "Weather", "Other"}2930const (31 // DetailsFile holds a channel folder's details.32 DetailsFile = "channel.json"33 // WeatherFile holds the weather channel's record, in the channels34 // folder.35 WeatherFile = ".weather.json"36 // LogoName is a channel folder's logo file without its extension37 // ("logo.png"), and WeatherLogoName the weather channel's, beside38 // WeatherFile.39 LogoName = "logo"40 WeatherLogoName = ".weather-logo"41)4243// The weather channel's record when nothing else is set.44const (45 WeatherNumber = "1.1"46 WeatherName = "Airwaves Weather"47 WeatherCallSign = "WX"48 weatherBlurb = "Current conditions, the local and extended forecast, and radar, around the clock."49)5051// LogoTypes are the content types of the logo files' extensions.52var LogoTypes = map[string]string{53 ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".svg": "image/svg+xml", ".webp": "image/webp",54}5556// logoExts are the extensions a logo is looked for with, in order, when57// the record names none.58var logoExts = []string{".png", ".svg", ".webp", ".jpg", ".jpeg"}5960// Details describe a custom channel for guides and lineups.61type Details struct {62 Number string63 Name string64 CallSign string // a short label, "WX" or "TOON"; "" for none65 Category string // one of Categories66 Description string67 // Logo is the path of the channel's logo file, "" when it has none.68 Logo string69 // Enabled channels are in the HDHomeRun lineup and the app; disabled70 // ones are only on the admin page.71 Enabled bool72}7374// record is a details file.75type record struct {76 Number string `json:"number"` // .weather.json's only77 Name string `json:"name"` // likewise78 CallSign string `json:"callSign"`79 Category string `json:"category"`80 Description string `json:"description"`81 Logo string `json:"logo"` // a file beside it, "logo.png"82 Enabled *bool `json:"enabled"`83 // Schedule, on a channel that plays its items in order, says when84 // they air; nil airs them around the clock as always.85 Schedule *Schedule `json:"schedule,omitempty"`86}8788// recordFile is a details file, read again when it changes.89type recordFile struct {90 mu sync.Mutex91 path string92 mod time.Time93 size int6494 rec record95 logged string // the last error logged96}9798// read returns the record at path, or an empty one when there's none or99// it can't be read.100func (f *recordFile) read(path string) record {101 f.mu.Lock()102 defer f.mu.Unlock()103 info, err := os.Stat(path)104 if err != nil {105 f.path, f.rec = "", record{}106 return f.rec107 }108 if f.path == path && info.ModTime().Equal(f.mod) && info.Size() == f.size {109 return f.rec110 }111 f.path, f.mod, f.size, f.rec = path, info.ModTime(), info.Size(), record{}112 raw, err := os.ReadFile(path)113 if err == nil {114 err = json.Unmarshal(raw, &f.rec)115 }116 if err != nil {117 f.rec = record{}118 if err.Error() != f.logged {119 log.Printf("%s: %v", path, err)120 f.logged = err.Error()121 }122 return f.rec123 }124 f.logged = ""125 return f.rec126}127128// details fills in d, a channel's defaults, from the record; dir holds129// the logo, named base plus an extension unless the record names it.130func (r record) details(dir, base string, d Details) Details {131 if s := strings.TrimSpace(r.CallSign); s != "" {132 d.CallSign = s133 }134 if c := Category(r.Category); c != "" {135 d.Category = c136 }137 if s := strings.TrimSpace(r.Description); s != "" {138 d.Description = s139 }140 d.Enabled = r.Enabled == nil || *r.Enabled141 d.Logo = findLogo(dir, r.Logo, base)142 return d143}144145// findLogo returns the path of the logo file named in a record, or with146// none named, the first of base.png, base.svg and so on that's there.147func findLogo(dir, named, base string) string {148 if named != "" {149 if filepath.Base(named) != named || LogoTypes[strings.ToLower(filepath.Ext(named))] == "" {150 return ""151 }152 if p := filepath.Join(dir, named); isFile(p) {153 return p154 }155 return ""156 }157 for _, ext := range logoExts {158 if p := filepath.Join(dir, base+ext); isFile(p) {159 return p160 }161 }162 return ""163}164165func isFile(path string) bool {166 info, err := os.Stat(path)167 return err == nil && info.Mode().IsRegular()168}169170// folderDetails are a channel folder's: its number and name, and the rest171// from channel.json in it.172func folderDetails(f *recordFile, dir, number, name string) Details {173 return f.read(filepath.Join(dir, DetailsFile)).details(dir, LogoName, Details{Number: number, Name: name, Category: "Other"})174}175176// folderSchedule is the schedule in channel.json in dir, read, or nil177// when it has none or it can't be read (which is logged).178func folderSchedule(f *recordFile, dir string) *sched {179 r := f.read(filepath.Join(dir, DetailsFile))180 if r.Schedule == nil {181 return nil182 }183 s, err := r.Schedule.read(time.Local)184 if err != nil {185 f.mu.Lock()186 defer f.mu.Unlock()187 if msg := "schedule: " + err.Error(); msg != f.logged {188 log.Printf("%s: %s; airing around the clock", filepath.Join(dir, DetailsFile), msg)189 f.logged = msg190 }191 return nil192 }193 return s194}195196// ReadSchedule returns the schedule in channel.json in a channel's folder197// as written, nil when it has none or the file can't be read.198func ReadSchedule(dir string) *Schedule {199 raw, err := os.ReadFile(filepath.Join(dir, DetailsFile))200 if err != nil {201 return nil202 }203 var r record204 if json.Unmarshal(raw, &r) != nil {205 return nil206 }207 return r.Schedule208}209210// Category returns the category s names (in any case) as Categories has211// it, or "" for none.212func Category(s string) string {213 s = strings.TrimSpace(s)214 for _, c := range Categories {215 if strings.EqualFold(c, s) {216 return c217 }218 }219 return ""220}221222var numberParts = regexp.MustCompile(`^(\d{1,3})[.-](\d{1,3})$`)223224// ParseNumber reads a custom channel number such as "1.4" or "104.0": a225// major number 1 to 999 and a minor 0 to 999, joined by a dot (or in a226// folder's name, a dash). It returns the number as written, with a dot:227// "104.0" stays "104.0".228func ParseNumber(s string) (string, bool) {229 m := numberParts.FindStringSubmatch(strings.TrimSpace(s))230 if m == nil {231 return "", false232 }233 if major, _ := strconv.Atoi(m[1]); major < 1 {234 return "", false235 }236 return m[1] + "." + m[2], true237}238239// NumberKey is a channel number's value, for telling whether two are the240// same however they're written: "1.05" and "1.5" are both "1.5".241func NumberKey(s string) string {242 major, minor := guide.SplitNumber(strings.Replace(strings.TrimSpace(s), "-", ".", 1))243 return fmt.Sprintf("%d.%d", major, minor)244}245246// SameNumber reports whether a and b are the same channel number.247func SameNumber(a, b string) bool { return NumberKey(a) == NumberKey(b) }248249// KindOf names a channel's kind: "weather", "folder", "jellyfin" or250// "youtube".251func KindOf(ch Channel) string {252 switch ch.(type) {253 case *Weather:254 return "weather"255 case *Folder:256 return "folder"257 case *Jellyfin:258 return "jellyfin"259 case *YouTube:260 return "youtube"261 }262 return "other"263}264265// LogoTag identifies the version of a logo file, for its URLs and ETag;266// "" when there's no file.267func LogoTag(path string) string {268 info, err := os.Stat(path)269 if err != nil {270 return ""271 }272 return logoTag(info)273}274275func logoTag(info os.FileInfo) string {276 return strconv.FormatInt(info.ModTime().UnixNano(), 36) + "-" + strconv.FormatInt(info.Size(), 36)277}278279// ServeLogo serves a logo file. A request whose v names the file's280// version (as LogoTag gives it) may be cached for good; others briefly,281// and revalidated by ETag.282func ServeLogo(w http.ResponseWriter, r *http.Request, path string) {283 f, err := os.Open(path)284 if err != nil {285 http.NotFound(w, r)286 return287 }288 defer f.Close()289 info, err := f.Stat()290 if err != nil || !info.Mode().IsRegular() {291 http.NotFound(w, r)292 return293 }294 tag := logoTag(info)295 h := w.Header()296 h.Set("Content-Type", LogoTypes[strings.ToLower(filepath.Ext(path))])297 h.Set("ETag", `"`+tag+`"`)298 if r.URL.Query().Get("v") == tag {299 h.Set("Cache-Control", "public, max-age=31536000, immutable")300 } else {301 h.Set("Cache-Control", "public, max-age=300")302 }303 // An SVG opened on its own runs no scripts, and nothing is sniffed.304 h.Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox")305 h.Set("X-Content-Type-Options", "nosniff")306 h.Set("Access-Control-Allow-Origin", "*")307 http.ServeContent(w, r, "", info.ModTime(), f)308}