1package vchan23import (4 "cmp"5 "errors"6 "fmt"7 "slices"8 "strings"9 "time"10)1112// Schedule is when a sequential channel airs, from its channel.json: its13// items in order from Start, beginning with First and looping at the14// end. With Blocks it airs only in them, standing by between; without,15// around the clock.16type Schedule struct {17 // Start is when the channel begins, in local time: "2026-10-04" (the18 // first block that day) or "2026-10-04T20:00".19 Start string `json:"start"`20 // First is the item it begins with, counting from 1; 0 is the first.21 First int `json:"first,omitempty"`22 Blocks []Block `json:"blocks,omitempty"`23}2425// Block is a daily run of a scheduled channel: from At, Count items, or26// until Until. One of the two ends it.27type Block struct {28 // Days are the days it airs, "mon" to "sun"; none for every day.29 Days []string `json:"days,omitempty"`30 At string `json:"at"` // "20:00", local time31 // Count is how many items it airs, back to back.32 Count int `json:"count,omitempty"`33 // Until is when it stops: nothing starts from then, and the item on34 // finishes. One earlier than At is the next day's.35 Until string `json:"until,omitempty"`36}3738// Sequenced is a channel whose items can air in order on a schedule:39// folder and Jellyfin channels, and YouTube channels playing a playlist.40type Sequenced interface {41 Channel42 // Sequence lists the items in the order they air; ok is false for a43 // channel that doesn't play in order (YouTube uploads).44 Sequence() (items []SequenceItem, ok bool)45}4647// SequenceItem is an item of a Sequenced channel, for choosing where its48// schedule begins.49type SequenceItem struct {50 Title string51 Subtitle string // an episode's name, or a video's channel52 Season string53 Episode string54 Length time.Duration55}5657var weekdays = []string{"sun", "mon", "tue", "wed", "thu", "fri", "sat"}5859// sched is a Schedule read.60type sched struct {61 start time.Time62 first int // from 063 blocks []block64}6566type block struct {67 days [7]bool // by time.Weekday68 at time.Duration // after midnight69 count int70 until time.Duration // after midnight; may be more than a day; 0 with a count71}7273// Check reports what's wrong with the schedule, in words for the admin74// page.75func (s Schedule) Check() error {76 _, err := s.read(time.Local)77 return err78}7980func (s Schedule) read(loc *time.Location) (*sched, error) {81 out := &sched{first: max(s.First-1, 0)}82 start := strings.TrimSpace(s.Start)83 var err error84 if out.start, err = time.ParseInLocation("2006-01-02T15:04", start, loc); err != nil {85 if out.start, err = time.ParseInLocation("2006-01-02", start, loc); err != nil {86 return nil, fmt.Errorf("the start %q isn't a date like 2026-10-04 or a time like 2026-10-04T20:00", s.Start)87 }88 }89 if s.First < 0 {90 return nil, errors.New("the first item counts from 1")91 }92 if len(s.Blocks) > 12 {93 return nil, errors.New("a channel airs in up to 12 blocks")94 }95 for i, b := range s.Blocks {96 var r block97 name := fmt.Sprintf("block %d", i+1)98 if r.at, err = clockTime(b.At); err != nil {99 return nil, fmt.Errorf("%s starts at %q: %w", name, b.At, err)100 }101 switch {102 case b.Count < 0 || b.Count > 100:103 return nil, fmt.Errorf("%s airs 1 to 100 items", name)104 case b.Count > 0 && b.Until != "":105 return nil, fmt.Errorf("%s has both a count and a time to stop; give one", name)106 case b.Count > 0:107 r.count = b.Count108 case b.Until == "":109 return nil, fmt.Errorf("%s needs a count or a time to stop", name)110 default:111 if r.until, err = clockTime(b.Until); err != nil {112 return nil, fmt.Errorf("%s stops at %q: %w", name, b.Until, err)113 }114 if r.until <= r.at {115 r.until += 24 * time.Hour116 }117 }118 if len(b.Days) == 0 {119 r.days = [7]bool{true, true, true, true, true, true, true}120 }121 for _, d := range b.Days {122 k := slices.Index(weekdays, strings.ToLower(strings.TrimSpace(d)))123 if k < 0 {124 return nil, fmt.Errorf("%s: %q isn't a day (mon to sun)", name, d)125 }126 r.days[k] = true127 }128 out.blocks = append(out.blocks, r)129 }130 slices.SortStableFunc(out.blocks, func(a, b block) int { return cmp.Compare(a.at, b.at) })131 return out, nil132}133134// clockTime reads "20:00" as the time after midnight.135func clockTime(s string) (time.Duration, error) {136 t, err := time.Parse("15:04", strings.TrimSpace(s))137 if err != nil {138 return 0, errors.New("give a time like 20:00")139 }140 return time.Duration(t.Hour())*time.Hour + time.Duration(t.Minute())*time.Minute, nil141}142143// slot is one airing on a scheduled channel: item I of the channel's,144// from Start to End. A gap, off the air, has I -1.145type slot struct {146 I int147 Start, End time.Time148}149150// scheduleReach is how far past its window a search for the next airing151// looks, as for a channel starting next month.152const scheduleReach = 400 * 24 * time.Hour153154// slots returns the airings that end after from and start before to, in155// order, at most limit of them. lengths are the items' lengths in channel156// order; items without one are skipped.157func (s *sched) slots(lengths []time.Duration, from, to time.Time, limit int) []slot {158 var total time.Duration159 for _, l := range lengths {160 total += max(l, 0)161 }162 if total <= 0 || limit <= 0 {163 return nil164 }165 var out []slot166 k := s.first % len(lengths) // the next item167 emit := func(at time.Time) time.Time {168 for lengths[k] <= 0 {169 k = (k + 1) % len(lengths)170 }171 end := at.Add(lengths[k])172 if end.After(from) && at.Before(to) {173 out = append(out, slot{k, at, end})174 }175 k = (k + 1) % len(lengths)176 return end177 }178179 if len(s.blocks) == 0 {180 // Around the clock: whole loops are skipped at once.181 at := s.start182 if from.After(at) {183 loops := from.Sub(at) / total184 at = at.Add(loops * total)185 }186 for at.Before(to) && len(out) < limit {187 at = emit(at)188 }189 return out190 }191192 end := s.start // when the last airing ends193 loc := s.start.Location()194 y, m, d := s.start.Date()195 for day := time.Date(y, m, d, 0, 0, 0, 0, loc); day.Before(to) && len(out) < limit; day = day.AddDate(0, 0, 1) {196 for _, b := range s.blocks {197 if !b.days[day.Weekday()] {198 continue199 }200 at := dayAt(day, b.at)201 if at.Before(s.start) {202 continue203 }204 // A block that the one before ran into starts when it ends.205 at = maxTime(at, end)206 if b.count > 0 {207 for range b.count {208 at = emit(at)209 }210 } else {211 until := dayAt(day, b.until)212 for at.Before(until) {213 at = emit(at)214 }215 }216 end = at217 if len(out) >= limit {218 break219 }220 }221 }222 if len(out) > limit {223 out = out[:limit]224 }225 return out226}227228// dayAt is the time d after midnight on day by the clock, so 20:00 is229// 20:00 on days that change for daylight saving too.230func dayAt(day time.Time, d time.Duration) time.Time {231 y, m, dd := day.Date()232 return time.Date(y, m, dd, int(d/time.Hour), int(d%time.Hour/time.Minute), 0, 0, day.Location())233}234235func maxTime(a, b time.Time) time.Time {236 if b.After(a) {237 return b238 }239 return a240}241242// at returns the airing on at t, or when the channel is off the air, the243// next one; ok is false when nothing will air.244func (s *sched) at(lengths []time.Duration, t time.Time) (on slot, onAir, ok bool) {245 next := s.slots(lengths, t, t.Add(scheduleReach), 1)246 if len(next) == 0 {247 next = s.slots(lengths, t, s.start.Add(scheduleReach), 1)248 }249 if len(next) == 0 {250 return slot{}, false, false251 }252 return next[0], !next[0].Start.After(t), true253}254255// premiere returns the schedule's very first airing, which an off-air256// note says it "starts" with rather than is "back" with; ok is false when257// nothing will air.258func (s *sched) premiere(lengths []time.Duration) (first slot, ok bool) {259 if a := s.slots(lengths, s.start, s.start.Add(scheduleReach), 1); len(a) > 0 {260 return a[0], true261 }262 return slot{}, false263}264265// guide returns what airs from from to to, with the gaps between: slots266// with I -1, the one before an airing ending when it starts, past to if267// need be. At most limit entries.268func (s *sched) guide(lengths []time.Duration, from, to time.Time, limit int) []slot {269 airs := s.slots(lengths, from, to, limit)270 // The airing after the window ends its last gap.271 if len(airs) < limit {272 after := s.slots(lengths, maxTime(to, from), to.Add(scheduleReach), 1)273 if len(after) == 0 {274 after = s.slots(lengths, maxTime(to, from), s.start.Add(scheduleReach), 1)275 }276 if len(after) > 0 && (len(airs) == 0 || after[0].Start.After(airs[len(airs)-1].Start)) {277 airs = append(airs, after[0])278 }279 }280 var out []slot281 at := from282 for _, a := range airs {283 if a.Start.After(at) {284 if len(out) == limit {285 break286 }287 out = append(out, slot{-1, at, a.Start})288 }289 if !a.Start.Before(to) {290 break291 }292 if len(out) == limit {293 break294 }295 out = append(out, a)296 at = a.End297 }298 if len(airs) == 0 && len(out) < limit {299 out = append(out, slot{-1, from, to})300 }301 return out302}303304// offAirProgram is a guide entry for a gap: what's next and when, as a305// viewer planning to watch wants it.306func offAirProgram(gap slot, nextTitle string, next time.Time, premiere bool) Program {307 p := Program{Start: gap.Start, End: gap.End, Title: "Off air", OffAir: true}308 if nextTitle == "" {309 return p310 }311 when := next.Format("at 3:04 PM")312 if next.Format(time.DateOnly) != gap.Start.Format(time.DateOnly) {313 when = next.Format("Mon, Jan 2 at 3:04 PM")314 }315 if premiere {316 p.Description = fmt.Sprintf("Starts %s with %s.", when, nextTitle)317 } else {318 p.Description = fmt.Sprintf("Back %s with %s.", when, nextTitle)319 }320 return p321}