1package vchan23import (4 "cmp"5 "context"6 "encoding/json"7 "fmt"8 "io"9 "log"10 "math"11 "os"12 "os/exec"13 "path/filepath"14 "regexp"15 "slices"16 "strconv"17 "strings"18 "sync"19 "time"2021 "airwaves/internal/cc"22)2324// Folder is a channel that loops a folder of videos on a fixed clock, the25// way a cable channel runs its library: the guide lists each video, and26// tuning in joins whatever is on part way through. Nothing is decoded unless27// someone is watching.28type Folder struct {29 Num string // channel number, "1.2"30 Title string // channel name, "Cat Calming"31 Dir string32 FFmpeg string33 FFprobe string34 // Loudness is the level the videos' sound is evened out to, in LUFS;35 // 0 leaves it as it is.36 Loudness float643738 mu sync.Mutex39 scanned time.Time40 items []item41 probed map[string]item // by path, size and modification time42 subs map[string]*captions // likewise, by their file's43 record recordFile44 levels loudnessFile // remembered, by file name, size and modification time45 audible []string // the keys in levels of the videos with sound46}4748// rescanEvery is how soon new or removed videos are noticed.49const rescanEvery = 30 * time.Second5051var videoExts = []string{".mp4", ".mkv", ".webm", ".mov", ".m4v", ".ts"}5253// partial matches files yt-dlp is still writing: "x.mp4.part",54// "x.temp.mp4", and the separate streams it merges ("x.f137.mp4").55var partial = regexp.MustCompile(`\.(part|ytdl)$|\.temp\.[^.]+$|\.f\d+\.[^.]+$`)5657// videoID matches the " [id]" suffix yt-dlp adds to file names.58var videoID = regexp.MustCompile(`\s*\[[A-Za-z0-9_-]{6,}\]$`)5960// Number implements Channel.61func (f *Folder) Number() string { return f.Num }6263// Name implements Channel.64func (f *Folder) Name() string { return f.Title }6566// Details implements Channel, from channel.json in the folder.67func (f *Folder) Details() Details { return folderDetails(&f.record, f.Dir, f.Num, f.Title) }6869// Empty reports whether the folder has no playable videos.70func (f *Folder) Empty() bool { return len(f.list()) == 0 }7172// Videos is how many playable videos the folder has.73func (f *Folder) Videos() int { return len(f.list()) }7475// list returns the folder's videos in file name order, rescanning when the76// last scan is stale. New files are probed once.77func (f *Folder) list() []item {78 f.mu.Lock()79 defer f.mu.Unlock()80 if !f.scanned.IsZero() && time.Since(f.scanned) < rescanEvery {81 return f.items82 }83 f.scanned = time.Now()84 entries, err := os.ReadDir(f.Dir)85 if err != nil {86 log.Printf("channel %s: %v", f.Num, err)87 f.items = nil88 return nil89 }90 if f.probed == nil {91 f.probed = map[string]item{}92 }93 var items []item94 var audible []string // the keys in levels of the videos with sound95 seen := map[string]bool{}96 names := map[string]os.DirEntry{}97 for _, e := range entries {98 names[e.Name()] = e99 }100 subs := map[string]*captions{}101 for _, e := range entries {102 name := e.Name()103 if e.IsDir() || strings.HasPrefix(name, ".") || partial.MatchString(name) ||104 !slices.Contains(videoExts, strings.ToLower(filepath.Ext(name))) {105 continue106 }107 info, err := e.Info()108 if err != nil {109 continue110 }111 path := filepath.Join(f.Dir, name)112 key := fmt.Sprintf("%s\x00%d\x00%d", path, info.Size(), info.ModTime().UnixNano())113 seen[key] = true114 it, ok := f.probed[key]115 if !ok {116 if it, err = f.probe(path); err != nil {117 // Still downloading, or not a video; try again next scan.118 log.Printf("channel %s: skipping %s: %v", f.Num, name, err)119 continue120 }121 f.probed[key] = it122 }123 if e, ok := sidecar(name, names); ok {124 it.Captions = f.captions(filepath.Join(f.Dir, e.Name()), e, subs)125 }126 if f.Loudness != 0 && it.Audio {127 key := fmt.Sprintf("%s|%d|%d", name, info.Size(), info.ModTime().UnixNano())128 it.Level = &itemLevel{Target: f.Loudness, Key: key, Memory: &f.levels}129 audible = append(audible, key)130 }131 items = append(items, it)132 }133 for k := range f.probed {134 if !seen[k] {135 delete(f.probed, k)136 }137 }138 f.items, f.subs, f.audible = items, subs, audible139 if f.Loudness != 0 {140 f.levels.use(filepath.Join(f.Dir, loudnessFileName), audible)141 }142 return items143}144145// Leveled is how many of the videos with sound have had their loudness146// measured as they played, of how many.147func (f *Folder) Leveled() (measured, total int) {148 f.list()149 f.mu.Lock()150 keys := f.audible151 f.mu.Unlock()152 return f.levels.count(keys), len(keys)153}154155// sidecar finds a video's English subtitles beside it, among the folder's156// files: "<name>.en.srt", "<name>.en.vtt" (as yt-dlp writes them),157// "<name>.eng.srt", or "<name>.srt".158func sidecar(video string, files map[string]os.DirEntry) (os.DirEntry, bool) {159 base := strings.TrimSuffix(video, filepath.Ext(video))160 for _, ext := range []string{".en.srt", ".en.vtt", ".eng.srt", ".srt", ".vtt"} {161 if e, ok := files[base+ext]; ok && !e.IsDir() {162 return e, true163 }164 }165 return nil, false166}167168// captions are a subtitle file's, kept while it's unchanged. f.mu is held.169func (f *Folder) captions(path string, e os.DirEntry, keep map[string]*captions) *captions {170 info, err := e.Info()171 if err != nil {172 return nil173 }174 key := fmt.Sprintf("%s\x00%d\x00%d", path, info.Size(), info.ModTime().UnixNano())175 c := f.subs[key]176 if c == nil {177 c = newCaptions(key, func(context.Context) ([]byte, error) { return os.ReadFile(path) })178 }179 keep[key] = c180 return c181}182183// probe reads a video's length and sound, and its guide details from184// yt-dlp's .info.json when there is one.185func (f *Folder) probe(path string) (item, error) {186 ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)187 defer cancel()188 out, err := exec.CommandContext(ctx, f.FFprobe, "-v", "error",189 "-show_entries", "format=duration:stream=codec_type:stream_tags=language,title:stream_disposition=default,comment,visual_impaired",190 "-of", "json", path).Output()191 if err != nil {192 return item{}, fmt.Errorf("ffprobe: %w", err)193 }194 var p struct {195 Format struct {196 Duration string `json:"duration"`197 } `json:"format"`198 Streams []struct {199 CodecType string `json:"codec_type"`200 Tags map[string]string `json:"tags"`201 Disposition map[string]int `json:"disposition"`202 } `json:"streams"`203 }204 if err := json.Unmarshal(out, &p); err != nil {205 return item{}, err206 }207 secs, _ := strconv.ParseFloat(p.Format.Duration, 64)208 it := item{Path: path, Frames: int64(math.Round(secs * loopFPS))}209 video := false210 var sound []audioTrack211 for _, s := range p.Streams {212 video = video || s.CodecType == "video"213 if s.CodecType == "audio" {214 sound = append(sound, audioTrack{215 lang: s.Tags["language"],216 extra: s.Disposition["comment"] == 1 || s.Disposition["visual_impaired"] == 1 || extraAudio.MatchString(s.Tags["title"]),217 def: s.Disposition["default"] == 1,218 })219 }220 }221 if !video || it.Frames < loopFPS {222 return item{}, fmt.Errorf("no video, or shorter than a second")223 }224 var lang string225 it.Title, it.Description, lang = describe(path)226 if i := preferredAudio(sound); i >= 0 {227 it.Audio = true228 it.AudioStream = fmt.Sprintf("0:a:%d", i)229 it.AudioLang = cmp.Or(sound[i].lang, lang)230 }231 return it, nil232}233234// audioTrack is a sound stream in a file.235type audioTrack struct {236 lang string // as tagged237 extra bool // a commentary, or described video238 def bool // the default239}240241// extraAudio matches the titles of commentary and described video tracks.242var extraAudio = regexp.MustCompile(`(?i)comment|descri`)243244// preferredAudio picks the sound to play, as jellyfin.Video.PreferredAudio245// does: English when there is some, else the default, else the first, with246// commentaries and described video last. -1 when there is none.247func preferredAudio(tracks []audioTrack) int {248 best, score := -1, -1249 for i, t := range tracks {250 n := 0251 if !t.extra {252 n += 4253 }254 if cc.English(t.lang) {255 n += 2256 }257 if t.def {258 n++259 }260 if n > score {261 best, score = i, n262 }263 }264 return best265}266267// describe returns a video's guide title and description, and the268// language it's in: from yt-dlp's .info.json beside it, else the file name269// without yt-dlp's " [id]", and no language.270func describe(path string) (title, desc, lang string) {271 base := strings.TrimSuffix(path, filepath.Ext(path))272 title = videoID.ReplaceAllString(filepath.Base(base), "")273 raw, err := os.ReadFile(base + ".info.json")274 if err != nil {275 return title, "", ""276 }277 var info struct {278 Title string `json:"title"`279 Description string `json:"description"`280 Language string `json:"language"`281 }282 if json.Unmarshal(raw, &info) != nil {283 return title, "", ""284 }285 if info.Title != "" {286 title = info.Title287 }288 return title, firstParagraph(info.Description, 300), info.Language289}290291// firstParagraph trims a video description (often a wall of links) to its292// opening paragraph, at most n characters.293func firstParagraph(s string, n int) string {294 s = strings.TrimSpace(s)295 if i := strings.Index(s, "\n\n"); i >= 0 {296 s = s[:i]297 }298 s = strings.Join(strings.Fields(s), " ")299 if r := []rune(s); len(r) > n {300 cut := string(r[:n])301 if i := strings.LastIndex(cut, " "); i > n/2 {302 cut = cut[:i]303 }304 s = cut + "…"305 }306 return s307}308309// Programs implements Channel: one entry per video, in file name order,310// on the channel's schedule when it has one.311func (f *Folder) Programs(from, to time.Time) []Program { return guideFor(f.lineup, from, to) }312313// Stream implements Channel.314func (f *Folder) Stream(ctx context.Context, w io.Writer) error {315 if len(f.list()) == 0 {316 return fmt.Errorf("channel %s: no videos in %s", f.Num, f.Dir)317 }318 return play(ctx, w, f.FFmpeg, "channel "+f.Num, f.lineup)319}320321// Sequence implements Sequenced: the videos in file name order.322func (f *Folder) Sequence() ([]SequenceItem, bool) { return sequence(f.list()), true }323324func (f *Folder) lineup() ([]item, *sched) { return f.list(), folderSchedule(&f.record, f.Dir) }