1package vchan23import (4 "cmp"5 "context"6 "crypto/sha256"7 "encoding/hex"8 "encoding/json"9 "errors"10 "fmt"11 "hash/fnv"12 "io"13 "io/fs"14 "log"15 "math"16 "os"17 "path/filepath"18 "slices"19 "strconv"20 "strings"21 "sync"22 "time"2324 "airwaves/internal/cc"25 "airwaves/internal/jellyfin"26)2728// Jellyfin is a channel that loops videos from a Jellyfin server, the way29// ErsatzTV builds channels from a media library: the series, movies,30// collections, playlists and libraries named in the channel folder's31// jellyfin.json play on a fixed clock with a full guide, streamed from the32// server only while someone watches. The account to sign in with can be in33// jellyfin.json, or in .jellyfin.json in the channels folder, shared by34// every Jellyfin channel.35type Jellyfin struct {36 Num string37 Title string38 Config string // path to jellyfin.json39 FFmpeg string40 // Loudness is the level the videos' sound is evened out to, in LUFS;41 // 0 leaves it as it is.42 Loudness float644344 mu sync.Mutex45 ready chan struct{} // closed when the first fetch ends46 waitUntil time.Time // callers wait for the first fetch until then47 busy bool // a fetch is running48 next time.Time // when the list is next refreshed49 checked time.Time // when the account file was last looked at50 acctMod time.Time // its modification time as of the last fetch51 items []item52 unmatched []string53 err error // the last fetch's54 logged string // the last error logged55 record recordFile56 levels loudnessFile // remembered, by video and file57 audible []string // the keys in levels of the videos with sound58}5960// jellyfinChannel is a channel's jellyfin.json.61type jellyfinChannel struct {62 jellyfin.Account63 Collections []string `json:"collections"`64 Playlists []string `json:"playlists"`65 Series []string `json:"series"` // "Name", or "Name (Year)"66 Movies []string `json:"movies"` // likewise67 Libraries []string `json:"libraries"`68 Order string `json:"order"` // "shuffle" (the default) or "aired"69 MaxBitrate float64 `json:"maxBitrate"` // Mbps; 0 plays original files70}7172const (73 // jellyfinAccount names the shared account file in the channels folder.74 jellyfinAccount = ".jellyfin.json"75 jellyfinRefresh = 30 * time.Minute76 jellyfinRetry = 5 * time.Minute77 // jellyfinWait is how long a new channel's first fetch is waited for,78 // so it can join the lineup straight away.79 jellyfinWait = 15 * time.Second80 jellyfinTimeout = 5 * time.Minute81)8283// Number implements Channel.84func (j *Jellyfin) Number() string { return j.Num }8586// Name implements Channel.87func (j *Jellyfin) Name() string { return j.Title }8889// Details implements Channel, from channel.json beside jellyfin.json.90func (j *Jellyfin) Details() Details {91 return folderDetails(&j.record, filepath.Dir(j.Config), j.Num, j.Title)92}9394// Empty reports whether the channel has nothing to play.95func (j *Jellyfin) Empty() bool { return len(j.list()) == 0 }9697// Programs implements Channel: one entry per video, in channel order, on98// the channel's schedule when it has one.99func (j *Jellyfin) Programs(from, to time.Time) []Program { return guideFor(j.lineup, from, to) }100101// Stream implements Channel.102func (j *Jellyfin) Stream(ctx context.Context, w io.Writer) error {103 if len(j.list()) == 0 {104 return fmt.Errorf("channel %s: nothing to play from Jellyfin", j.Num)105 }106 return play(ctx, w, j.FFmpeg, "channel "+j.Num, j.lineup)107}108109// Sequence implements Sequenced: the videos in channel order.110func (j *Jellyfin) Sequence() ([]SequenceItem, bool) { return sequence(j.list()), true }111112func (j *Jellyfin) lineup() ([]item, *sched) {113 return j.list(), folderSchedule(&j.record, filepath.Dir(j.Config))114}115116// Status reports how many videos the channel has, the names in its config117// that matched nothing on the server, and the last fetch's error.118func (j *Jellyfin) Status() (items int, unmatched []string, err error) {119 j.mu.Lock()120 defer j.mu.Unlock()121 j.kick()122 return len(j.items), slices.Clone(j.unmatched), j.err123}124125// start begins the first fetch without waiting for it.126func (j *Jellyfin) start() {127 j.mu.Lock()128 defer j.mu.Unlock()129 j.kick()130}131132// list returns the channel's videos in order. Only a new channel's first133// fetch is waited for, and briefly, so a slow or unreachable server can't134// hold up the lineup; later refreshes happen in the background.135func (j *Jellyfin) list() []item {136 j.mu.Lock()137 j.kick()138 ready, wait := j.ready, time.Until(j.waitUntil)139 j.mu.Unlock()140 if wait > 0 {141 select {142 case <-ready:143 case <-time.After(wait):144 }145 }146 j.mu.Lock()147 defer j.mu.Unlock()148 return j.items149}150151// Leveled is how many of the videos with sound have had their loudness152// measured as they played, of how many.153func (j *Jellyfin) Leveled() (measured, total int) {154 j.mu.Lock()155 keys := j.audible156 j.mu.Unlock()157 return j.levels.count(keys), len(keys)158}159160// kick starts a fetch when one is due: the first, every half hour, sooner161// after a failure, and when the account file changes. j.mu is held.162func (j *Jellyfin) kick() {163 now := time.Now()164 switch {165 case j.ready == nil:166 j.ready = make(chan struct{})167 j.waitUntil = now.Add(jellyfinWait)168 case j.busy:169 return170 case now.Before(j.next) && !j.accountChanged(now):171 return172 }173 j.busy = true174 go j.refresh()175}176177// accountChanged reports whether the account file has changed since the178// last fetch, looking at most every rescanEvery. (The library replaces the179// channel when its own jellyfin.json changes.) j.mu is held.180func (j *Jellyfin) accountChanged(now time.Time) bool {181 if now.Sub(j.checked) < rescanEvery {182 return false183 }184 j.checked = now185 return !modTime(j.accountPath()).Equal(j.acctMod)186}187188func (j *Jellyfin) accountPath() string {189 return filepath.Join(filepath.Dir(filepath.Dir(j.Config)), jellyfinAccount)190}191192func modTime(path string) time.Time {193 info, err := os.Stat(path)194 if err != nil {195 return time.Time{}196 }197 return info.ModTime()198}199200// refresh fetches the list, keeping the last good one if that fails.201func (j *Jellyfin) refresh() {202 ctx, cancel := context.WithTimeout(context.Background(), jellyfinTimeout)203 defer cancel()204 acctMod := modTime(j.accountPath())205 items, unmatched, err := j.fetch(ctx, acctMod)206207 j.mu.Lock()208 defer j.mu.Unlock()209 first := true210 select {211 case <-j.ready:212 first = false213 default:214 defer close(j.ready)215 }216 j.busy, j.acctMod, j.err = false, acctMod, err217 if err != nil {218 j.next = time.Now().Add(jellyfinRetry)219 if err.Error() != j.logged {220 log.Printf("channel %s: %v", j.Num, err)221 j.logged = err.Error()222 }223 return224 }225 j.next = time.Now().Add(jellyfinRefresh)226 if first || len(items) != len(j.items) || j.logged != "" {227 log.Printf("channel %s: %d videos from Jellyfin", j.Num, len(items))228 }229 j.items, j.unmatched, j.logged = items, unmatched, ""230 j.audible = nil231 for _, it := range items {232 if it.Level != nil {233 j.audible = append(j.audible, it.Level.Key)234 }235 }236 if j.Loudness != 0 {237 j.levels.use(filepath.Join(filepath.Dir(j.Config), loudnessFileName), j.audible)238 }239}240241// fetch reads the config and builds the list from the server, returning242// the names that matched nothing too.243func (j *Jellyfin) fetch(ctx context.Context, acctMod time.Time) ([]item, []string, error) {244 cfg, err := j.settings()245 if err != nil {246 return nil, nil, err247 }248 c := jellyfin.New(cfg.Account, jellyfinDevice(cfg.Account), nil)249 // A password the server refused is only tried again once the files250 // change, since repeated failures lock the account.251 changed := modTime(j.Config)252 if acctMod.After(changed) {253 changed = acctMod254 }255 c.Retry(changed)256257 j.mu.Lock()258 reported := j.unmatched259 j.mu.Unlock()260 var from []jellyfin.Item261 var unmatched []string262 for _, src := range []struct {263 kind, what string264 names []string265 }{266 {"BoxSet", "collection", cfg.Collections},267 {"Playlist", "playlist", cfg.Playlists},268 {"Series", "series", cfg.Series},269 {"Movie", "movie", cfg.Movies},270 {"Library", "library", cfg.Libraries},271 } {272 found, missing, err := c.Find(ctx, src.kind, src.names)273 if err != nil {274 return nil, nil, err275 }276 for _, n := range missing {277 if !slices.Contains(reported, n) {278 log.Printf("channel %s: no %s named %q on %s", j.Num, src.what, n, c.Server())279 }280 }281 from = append(from, found...)282 unmatched = append(unmatched, missing...)283 }284 videos, err := c.Videos(ctx, from)285 if err != nil {286 return nil, nil, err287 }288 if cfg.Order == "aired" {289 airedOrder(videos)290 } else {291 shuffle(videos, j.Num+"\x00"+j.Title)292 }293 // Captions already fetched are kept.294 j.mu.Lock()295 had := map[string]*captions{}296 for _, it := range j.items {297 if it.Captions != nil && !it.Captions.failed() {298 had[it.Captions.key] = it.Captions299 }300 }301 j.mu.Unlock()302 var items []item303 for _, v := range videos {304 if it, ok := jellyfinItem(c, v, cfg.MaxBitrate); ok {305 if it.Captions != nil && had[it.Captions.key] != nil {306 it.Captions = had[it.Captions.key]307 }308 if j.Loudness != 0 && it.Audio {309 it.Level = &itemLevel{Target: j.Loudness, Key: v.ID + "/" + v.Source, Memory: &j.levels}310 }311 items = append(items, it)312 }313 }314 return items, unmatched, nil315}316317// settings reads jellyfin.json. The account comes from the shared account318// file when the channel's own file names no user or token, and the server319// does when it names none.320func (j *Jellyfin) settings() (jellyfinChannel, error) {321 var cfg jellyfinChannel322 raw, err := os.ReadFile(j.Config)323 if err != nil {324 return cfg, err325 }326 if err := json.Unmarshal(raw, &cfg); err != nil {327 return cfg, fmt.Errorf("%s: %w", j.Config, err)328 }329 shared, err := jellyfin.LoadAccount(j.accountPath())330 if err != nil && !errors.Is(err, fs.ErrNotExist) {331 return cfg, err332 }333 if cfg.User == "" && cfg.Token == "" {334 server := cfg.Server335 cfg.Account = shared336 if server != "" {337 cfg.Server = server338 }339 } else if cfg.Server == "" {340 cfg.Server = shared.Server341 }342 if cfg.Server == "" || (cfg.User == "" && cfg.Token == "") {343 return cfg, fmt.Errorf("%s: no Jellyfin server, and user or token, here or in %s", j.Config, jellyfinAccount)344 }345 if cfg.Order != "" && cfg.Order != "shuffle" && cfg.Order != "aired" {346 return cfg, fmt.Errorf("%s: order %q is not shuffle or aired", j.Config, cfg.Order)347 }348 return cfg, nil349}350351// jellyfinDevice identifies Airwaves to the server, the same across352// restarts for an account.353func jellyfinDevice(a jellyfin.Account) string {354 sum := sha256.Sum256([]byte(strings.ToLower(a.Server + "\x00" + a.User)))355 return "airwavesd-" + hex.EncodeToString(sum[:6])356}357358// shuffle orders videos by a hash of the channel and each video, so the359// order depends only on which videos there are: the schedule holds until360// that set changes, and each channel's differs.361func shuffle(vs []jellyfin.Video, seed string) {362 key := map[string]uint64{}363 for _, v := range vs {364 h := fnv.New64a()365 h.Write([]byte(seed + "\x00" + v.ID))366 key[v.ID] = h.Sum64()367 }368 slices.SortFunc(vs, func(a, b jellyfin.Video) int {369 return cmp.Or(cmp.Compare(key[a.ID], key[b.ID]), strings.Compare(a.ID, b.ID))370 })371}372373// airedOrder plays each series through by season and episode, specials374// last, and movies on their own, placing each by when it first aired.375func airedOrder(vs []jellyfin.Video) {376 group := func(v jellyfin.Video) string {377 if v.Type == "Episode" && v.SeriesID != "" {378 return v.SeriesID379 }380 return v.ID381 }382 aired := func(v jellyfin.Video) string {383 switch {384 case v.Premiere != "":385 return v.Premiere386 case v.Year > 0:387 return strconv.Itoa(v.Year)388 }389 return "9999" // unknown: last390 }391 season := func(v jellyfin.Video) int {392 if v.Season == 0 {393 return math.MaxInt394 }395 return v.Season396 }397 first := map[string]string{}398 for _, v := range vs {399 if d, ok := first[group(v)]; !ok || aired(v) < d {400 first[group(v)] = aired(v)401 }402 }403 slices.SortFunc(vs, func(a, b jellyfin.Video) int {404 return cmp.Or(405 cmp.Compare(first[group(a)], first[group(b)]),406 strings.Compare(group(a), group(b)),407 cmp.Compare(season(a), season(b)),408 cmp.Compare(a.Episode, b.Episode),409 strings.Compare(aired(a), aired(b)),410 strings.Compare(a.ID, b.ID),411 )412 })413}414415// jellyfinItem is a video's place in the loop and the guide, or false for416// one too short to schedule.417func jellyfinItem(c *jellyfin.Client, v jellyfin.Video, mbps float64) (item, bool) {418 frames := int64(v.Runtime * loopFPS / time.Second)419 if frames < loopFPS {420 return item{}, false421 }422 it := item{423 Frames: frames, Audio: v.Audio, Title: v.Name, Description: firstParagraph(v.Overview, 1000), Image: v.Image,424 Input: jellyfinInput(c, v, mbps), Aired: v.Premiere, Year: v.Year, Cast: v.Cast, Rating: v.Rating, Genres: v.Genres,425 }426 if v.Type == "Movie" {427 it.Category = "Movie"428 } else if v.Series != "" {429 it.Title, it.Subtitle = v.Series, v.Name430 if v.Season > 0 && v.Episode > 0 {431 it.Season, it.Episode = strconv.Itoa(v.Season), strconv.Itoa(v.Episode)432 }433 }434 // English sound when there's a choice; a transcode is asked for it, and435 // has the one track.436 if a, ok := v.PreferredAudio(); ok {437 it.AudioLang = a.Language438 if mbps <= 0 && a.Position > 0 {439 it.AudioStream = fmt.Sprintf("0:a:%d", a.Position)440 }441 }442 if s, ok := v.EnglishSubtitle(cc.Foreign(it.AudioLang)); ok {443 it.Captions = newCaptions(v.ID+"\x00"+strconv.Itoa(s.Index), func(ctx context.Context) ([]byte, error) {444 return c.Subtitle(ctx, v, s)445 })446 }447 return it, true448}449450// jellyfinInput reads a video from the server: the original451// file, seeking here, or with a bitrate cap the server's transcode, which452// it seeks in. The token goes in a header, never the URL, so it stays out453// of logs.454func jellyfinInput(c *jellyfin.Client, v jellyfin.Video, mbps float64) func(time.Duration) []string {455 return func(offset time.Duration) []string {456 args := []string{457 "-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_on_network_error", "1", "-reconnect_delay_max", "5",458 "-rw_timeout", "30000000", // 30 s in microseconds: give up on a stalled server459 "-headers", "Authorization: " + c.Authorization() + "\r\n",460 }461 if mbps <= 0 {462 if offset > 0 {463 args = append(args, "-ss", strconv.FormatFloat(offset.Seconds(), 'f', 3, 64))464 }465 return append(args, "-i", c.FileURL(v))466 }467 t := jellyfin.Transcode{Bitrate: int(mbps * 1e6), MaxWidth: loopWidth, MaxHeight: loopHeight}468 return append(args, "-i", c.TranscodeURL(v, offset, t))469 }470}