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 / library.go
library.go 5.7K
1package vchan23import (4 "fmt"5 "log"6 "os"7 "path/filepath"8 "regexp"9 "slices"10 "strings"11 "sync"12 "time"1314 "airwaves/internal/guide"15 "airwaves/internal/youtube"16)1718// Library finds folder channels: each subfolder of Root is a channel named19// after the folder. A leading number ("1.2 Cat Calming", 1.0 to 999.999,20// kept as written) sets the channel number; folders without one, or whose21// number another channel has, take the next free 1.x numbers. A folder holding jellyfin.json plays from a Jellyfin server22// instead of its own videos (see Jellyfin), and one holding youtube.json23// plays uploads from YouTube channels (see YouTube). Any channel's24// channel.json holds its details (see Details).25type Library struct {26 Root string27 FFmpeg string28 FFprobe string29 // Reserved lists the numbers other channels use (the weather30 // channel's), which folders don't get. The folders are numbered again31 // when they change.32 Reserved func() []string33 // YtDlp runs yt-dlp for YouTube channels; yt-dlp from PATH when nil.34 YtDlp *youtube.YtDlp35 // Loudness is the level the channels' sound is evened out to, in LUFS36 // (see DefaultLoudness); 0 leaves it as it is.37 Loudness float643839 mu sync.Mutex40 scanned time.Time41 reserved string // as of the last scan42 channels map[string]libraryChannel // by path, so probes survive rescans43 list []Entry44}4546// Entry is a channel folder.47type Entry struct {48 Folder string // folder name, "1.2 Cat Calming"49 Channel Channel50}5152type libraryChannel struct {53 Channel54 sig string // number, name and source the channel was made from55}5657// jellyfinConfig names the file that makes a folder a Jellyfin channel.58const jellyfinConfig = "jellyfin.json"5960// youtubeConfig names the file that makes a folder a YouTube channel.61const youtubeConfig = "youtube.json"6263var numbered = regexp.MustCompile(`^(\d+[.-]\d+)\s+(.+)$`)6465// closeChannel stops what a channel that left the library does in the66// background.67func closeChannel(c Channel) {68 if y, ok := c.(*YouTube); ok {69 y.close()70 }71}7273// Channels returns the folder channels that have something to play, by74// number.75func (l *Library) Channels() []Channel {76 var out []Channel77 for _, e := range l.scan() {78 if c, ok := e.Channel.(interface{ Empty() bool }); !ok || !c.Empty() {79 out = append(out, e.Channel)80 }81 }82 return out83}8485// Entries lists every channel folder by number, including ones with86// nothing to play yet.87func (l *Library) Entries() []Entry { return l.scan() }8889// Rescan makes the next call read the folders again, after they change.90func (l *Library) Rescan() {91 l.mu.Lock()92 l.scanned = time.Time{}93 l.mu.Unlock()94}9596func (l *Library) scan() []Entry {97 var reserved []string98 if l.Reserved != nil {99 reserved = l.Reserved()100 }101 l.mu.Lock()102 defer l.mu.Unlock()103 if !l.scanned.IsZero() && time.Since(l.scanned) < rescanEvery && strings.Join(reserved, " ") == l.reserved {104 return l.list105 }106 l.scanned, l.reserved = time.Now(), strings.Join(reserved, " ")107 entries, err := os.ReadDir(l.Root)108 if err != nil {109 if !os.IsNotExist(err) {110 log.Printf("channels: %v", err)111 }112 l.list = nil113 return nil114 }115 if l.channels == nil {116 l.channels = map[string]libraryChannel{}117 }118 taken := map[string]bool{}119 // Numbers are taken by value: "1.05" is 1.5.120 for _, n := range reserved {121 taken[NumberKey(n)] = true122 }123 type found struct{ path, number, name string }124 var named, unnamed []found125 for _, e := range entries {126 if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {127 continue128 }129 fd := found{path: filepath.Join(l.Root, e.Name()), name: e.Name()}130 if m := numbered.FindStringSubmatch(e.Name()); m != nil {131 if n, ok := ParseNumber(m[1]); ok && !taken[NumberKey(n)] {132 fd.number, fd.name = n, strings.TrimSpace(m[2])133 taken[NumberKey(n)] = true134 named = append(named, fd)135 continue136 }137 }138 unnamed = append(unnamed, fd)139 }140 minor := 1141 for i := range unnamed {142 for taken[fmt.Sprintf("1.%d", minor)] {143 minor++144 }145 unnamed[i].number = fmt.Sprintf("1.%d", minor)146 taken[unnamed[i].number] = true147 }148149 var list []Entry150 keep := map[string]bool{}151 for _, fd := range append(named, unnamed...) {152 sig := fd.number + "\x00" + fd.name153 cfg := filepath.Join(fd.path, jellyfinConfig)154 if info, err := os.Stat(cfg); err == nil {155 sig += "\x00jellyfin\x00" + info.ModTime().String()156 } else if _, err := os.Stat(filepath.Join(fd.path, youtubeConfig)); err == nil {157 // Not its modification time: a YouTube channel takes in edits158 // itself, so streams under way follow the new schedule.159 sig += "\x00youtube\x00"160 }161 c, ok := l.channels[fd.path]162 if !ok || c.sig != sig {163 if ok {164 closeChannel(c.Channel)165 }166 c = libraryChannel{sig: sig}167 if strings.Contains(sig, "\x00jellyfin\x00") {168 j := &Jellyfin{Num: fd.number, Title: fd.name, Config: cfg, FFmpeg: l.FFmpeg, Loudness: l.Loudness}169 j.start() // alongside the other channels, not when first asked170 c.Channel = j171 } else if strings.Contains(sig, "\x00youtube\x00") {172 y := &YouTube{Num: fd.number, Title: fd.name, Dir: fd.path, FFmpeg: l.FFmpeg, YtDlp: l.YtDlp, Loudness: l.Loudness}173 y.start()174 c.Channel = y175 } else {176 f := &Folder{Num: fd.number, Title: fd.name, Dir: fd.path, FFmpeg: l.FFmpeg, FFprobe: l.FFprobe, Loudness: l.Loudness}177 if old, ok := l.channels[fd.path].Channel.(*Folder); ok {178 old.mu.Lock()179 f.probed = old.probed180 old.mu.Unlock()181 }182 c.Channel = f183 }184 l.channels[fd.path] = c185 }186 keep[fd.path] = true187 list = append(list, Entry{Folder: filepath.Base(fd.path), Channel: c.Channel})188 }189 for p, c := range l.channels {190 if !keep[p] {191 closeChannel(c.Channel)192 delete(l.channels, p)193 }194 }195 slices.SortFunc(list, func(a, b Entry) int { return guide.CompareNumbers(a.Channel.Number(), b.Channel.Number()) })196 l.list = list197 return list198}