1// Package admin serves the server owner's web page for custom channels:2// every channel's number, name and details (call sign, category,3// description, logo, and whether it's on), the Jellyfin account, which4// series, movies, collections and playlists each Jellyfin channel plays,5// and which YouTube channels' uploads each YouTube channel plays. It6// writes the same files a person would: a channel.json, jellyfin.json or7// youtube.json in each channel's folder, and .jellyfin.json for the8// account and .weather.json for the weather channel at the top of the9// channels folder. It keeps backups of all that, to restore from10// (backups.go, restore.go).11package admin1213import (14 "bytes"15 "context"16 "crypto/rand"17 "embed"18 "encoding/hex"19 "encoding/json"20 "errors"21 "fmt"22 "io/fs"23 "log"24 "net/http"25 "net/url"26 "os"27 "path/filepath"28 "regexp"29 "slices"30 "strings"31 "sync"32 "time"33 "unicode/utf8"3435 "airwaves/internal/guide"36 "airwaves/internal/jellyfin"37 "airwaves/internal/phase"38 "airwaves/internal/sd"39 "airwaves/internal/vchan"40)4142//go:embed ui43var ui embed.FS4445// Server is the admin page and its API, under /admin/.46type Server struct {47 Library *vchan.Library48 // Weather is the weather channel, nil when there's none.49 Weather *vchan.Weather50 // Antenna lists the antenna channels by number, with their names, to51 // warn of custom channels taking their numbers; optional.52 Antenna func(ctx context.Context) map[string]string53 HTTP *http.Client54 // Backups is the folder backups of the channel setup are kept in55 // (backups.go); none are made when it's "".56 Backups string57 // Version is the server's, noted in each backup.58 Version string59 // ChannelsDir and MusicDir are where the channels and the weather60 // music folders are as the host sees them, for the page's hints.61 ChannelsDir, MusicDir string62 // Listings is the guide's Schedules Direct account; nil on a server63 // without an antenna.64 Listings Listings6566 bk backupState67 mu sync.Mutex68 account accountStatus69 checked time.Time70 browse map[string]browsed // by kind71 codes map[string]quickConnect // Quick Connect sign-ins in progress, by handle72 searches map[string]searched // YouTube searches, by query73 lookups map[string]lookedUp // YouTube channels looked up, by page and "id:" ID74 ytSlots chan struct{} // yt-dlp runs under way75}7677// quickConnect is a code sign-in waiting for the user to approve it.78type quickConnect struct {79 server, secret string80 started time.Time81}8283type browsed struct {84 items []itemJSON85 at time.Time86}8788const (89 // deviceID is how Jellyfin lists Airwaves among the user's devices.90 deviceID = "airwaves"91 accountFile = ".jellyfin.json"92 channelFile = "jellyfin.json"93 // recheckEvery is how long an account check and a library listing are94 // trusted.95 recheckEvery = 5 * time.Minute96 // codeLife is how long a Quick Connect code waits for approval.97 codeLife = 10 * time.Minute98)99100var idRe = regexp.MustCompile(`^[0-9A-Za-z-]{8,64}$`)101102// Handler serves the page and its API.103func (s *Server) Handler() http.Handler {104 s.ytSlots = make(chan struct{}, ytRuns)105 mux := http.NewServeMux()106 static, _ := fs.Sub(ui, "ui")107 files := http.StripPrefix("/admin/", http.FileServerFS(static))108 mux.Handle("GET /admin/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {109 // The page changes with the server; never run a stale copy.110 w.Header().Set("Cache-Control", "no-cache")111 files.ServeHTTP(w, r)112 }))113 mux.HandleFunc("GET /admin/api/state", s.getState)114 mux.HandleFunc("PUT /admin/api/account", s.putAccount)115 mux.HandleFunc("PUT /admin/api/listings", s.putListings)116 mux.HandleFunc("DELETE /admin/api/listings", s.deleteListings)117 mux.HandleFunc("POST /admin/api/account/quickconnect", s.startQuickConnect)118 mux.HandleFunc("GET /admin/api/account/quickconnect/{id}", s.checkQuickConnect)119 mux.HandleFunc("GET /admin/api/library/{kind}", s.getLibrary)120 mux.HandleFunc("GET /admin/api/image/{id}", s.getImage)121 mux.HandleFunc("PUT /admin/api/channels/{number}", s.putChannel)122 mux.HandleFunc("DELETE /admin/api/channels/{number}", s.deleteChannel)123 mux.HandleFunc("GET /admin/api/channels/{number}/schedule", s.getSchedule)124 mux.HandleFunc("GET /admin/api/channels/{number}/items", s.getItems)125 mux.HandleFunc("GET /admin/api/channels/{number}/logo", s.getLogo)126 mux.HandleFunc("PUT /admin/api/channels/{number}/logo", s.putLogo)127 mux.HandleFunc("POST /admin/api/channels/{number}/logo", s.putLogo)128 mux.HandleFunc("DELETE /admin/api/channels/{number}/logo", s.deleteLogo)129 mux.HandleFunc("POST /admin/api/channels/{number}/logo/source", s.logoFromSource)130 mux.HandleFunc("GET /admin/api/youtube/search", s.searchYouTube)131 mux.HandleFunc("GET /admin/api/youtube/channel", s.lookupYouTube)132 mux.HandleFunc("GET /admin/api/youtube/playlist", s.lookupPlaylist)133 mux.HandleFunc("GET /admin/api/tunes", getTunes)134 s.addBackupRoutes(mux)135 // Changes through the API, the agent tools' included, are backed up.136 api := s.watch(mux)137 addAgentRoutes(mux, api) // the same, as tools for AI agents (mcp.go)138 return api139}140141// ---------- account ----------142143type accountStatus struct {144 Server string `json:"server"`145 User string `json:"user"`146 HasPassword bool `json:"hasPassword"`147 OK bool `json:"ok"`148 Error string `json:"error"`149 ServerName string `json:"serverName,omitempty"`150}151152func (s *Server) accountPath() string { return filepath.Join(s.Library.Root, accountFile) }153154func (s *Server) client() (*jellyfin.Client, error) {155 acct, err := jellyfin.LoadAccount(s.accountPath())156 if err != nil || acct.Server == "" {157 return nil, errors.New("set up the Jellyfin account first")158 }159 return jellyfin.New(acct, deviceID, s.HTTP), nil160}161162// accountStatus returns the account and whether it works, checking at most163// every few minutes unless force is set.164func (s *Server) accountStatus(ctx context.Context, force bool) accountStatus {165 s.mu.Lock()166 if !force && !s.checked.IsZero() && time.Since(s.checked) < recheckEvery {167 defer s.mu.Unlock()168 return s.account169 }170 s.mu.Unlock()171172 acct, err := jellyfin.LoadAccount(s.accountPath())173 st := accountStatus{Server: acct.Server, User: acct.User, HasPassword: acct.Password != "" || acct.Token != ""}174 switch {175 case errors.Is(err, fs.ErrNotExist) || (err == nil && acct.Server == ""):176 st.Error = "No Jellyfin account yet"177 case err != nil:178 st.Error = "Can't read the account file: " + err.Error()179 default:180 ctx, cancel := context.WithTimeout(ctx, 20*time.Second)181 defer cancel()182 st.ServerName = s.serverName(ctx, acct.Server)183 if err := jellyfin.New(acct, deviceID, s.HTTP).Login(ctx); err != nil {184 st.Error = err.Error()185 } else {186 st.OK = true187 }188 }189 s.mu.Lock()190 s.account, s.checked = st, time.Now()191 s.mu.Unlock()192 return st193}194195// serverName asks a Jellyfin server its name (no login needed).196func (s *Server) serverName(ctx context.Context, server string) string {197 req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(server, "/")+"/System/Info/Public", nil)198 if err != nil {199 return ""200 }201 resp, err := s.HTTP.Do(req)202 if err != nil {203 return ""204 }205 defer resp.Body.Close()206 var info struct{ ServerName string }207 _ = json.NewDecoder(resp.Body).Decode(&info)208 return info.ServerName209}210211func (s *Server) putAccount(w http.ResponseWriter, r *http.Request) {212 var req struct{ Server, User, Password string }213 if !decode(w, r, &req) {214 return215 }216 req.Server = strings.TrimRight(strings.TrimSpace(req.Server), "/")217 req.User = strings.TrimSpace(req.User)218 if u, err := url.Parse(req.Server); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {219 writeErr(w, http.StatusBadRequest, errors.New("the server should be a URL like https://jellyfin.example.com"))220 return221 }222 acct, _ := jellyfin.LoadAccount(s.accountPath())223 if acct.Server != req.Server || acct.User != req.User || req.Password != "" {224 // A different login: drop the old one's token.225 acct = jellyfin.Account{Server: req.Server, User: req.User, Password: cmp(req.Password, acct.Password)}226 }227 if err := s.saveAccount(acct); err != nil {228 writeErr(w, http.StatusInternalServerError, err)229 return230 }231 writeJSON(w, s.accountStatus(r.Context(), true))232}233234func (s *Server) saveAccount(acct jellyfin.Account) error {235 raw, err := json.MarshalIndent(acct, "", " ")236 if err != nil {237 return err238 }239 // Only this user may read the password or token.240 if err := writeFile(s.accountPath(), append(raw, '\n'), 0o600); err != nil {241 return err242 }243 s.mu.Lock()244 s.browse = nil245 s.mu.Unlock()246 s.backupSoon() // a Quick Connect sign-in comes by GET, which watch passes over247 return nil248}249250// startQuickConnect begins a code sign-in: the page shows the code, and the251// user approves it from a Jellyfin app they're signed in to.252func (s *Server) startQuickConnect(w http.ResponseWriter, r *http.Request) {253 var req struct{ Server string }254 if !decode(w, r, &req) {255 return256 }257 server := strings.TrimRight(strings.TrimSpace(req.Server), "/")258 if u, err := url.Parse(server); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {259 writeErr(w, http.StatusBadRequest, errors.New("the server should be a URL like https://jellyfin.example.com"))260 return261 }262 ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)263 defer cancel()264 if on, err := jellyfin.QuickConnectEnabled(ctx, server, s.HTTP); err != nil {265 writeErr(w, http.StatusBadGateway, err)266 return267 } else if !on {268 writeErr(w, http.StatusConflict, errors.New("Quick Connect is turned off on this server"))269 return270 }271 qc, err := jellyfin.StartQuickConnect(ctx, server, deviceID, s.HTTP)272 if err != nil {273 writeErr(w, http.StatusBadGateway, err)274 return275 }276 b := make([]byte, 12)277 _, _ = rand.Read(b)278 id := hex.EncodeToString(b)279 s.mu.Lock()280 if s.codes == nil {281 s.codes = map[string]quickConnect{}282 }283 for k, c := range s.codes {284 if time.Since(c.started) > codeLife {285 delete(s.codes, k)286 }287 }288 s.codes[id] = quickConnect{server: server, secret: qc.Secret, started: time.Now()}289 s.mu.Unlock()290 writeJSON(w, map[string]string{"id": id, "code": qc.Code})291}292293// checkQuickConnect reports whether the code was approved, and when it is,294// saves the account (a token, never a password).295func (s *Server) checkQuickConnect(w http.ResponseWriter, r *http.Request) {296 id := r.PathValue("id")297 s.mu.Lock()298 c, ok := s.codes[id]299 s.mu.Unlock()300 expired := func() {301 s.mu.Lock()302 delete(s.codes, id)303 s.mu.Unlock()304 writeJSON(w, map[string]string{"state": "expired"})305 }306 if !ok || time.Since(c.started) > codeLife {307 expired()308 return309 }310 ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)311 defer cancel()312 approved, err := jellyfin.CheckQuickConnect(ctx, c.server, c.secret, s.HTTP)313 if err != nil {314 // Most often the server has forgotten the code; start over.315 log.Printf("admin: quick connect: %v", err)316 expired()317 return318 }319 if !approved {320 writeJSON(w, map[string]string{"state": "waiting"})321 return322 }323 acct, err := jellyfin.FinishQuickConnect(ctx, c.server, c.secret, deviceID, s.HTTP)324 if err != nil {325 writeErr(w, http.StatusBadGateway, err)326 return327 }328 s.mu.Lock()329 delete(s.codes, id)330 s.mu.Unlock()331 if err := s.saveAccount(acct); err != nil {332 writeErr(w, http.StatusInternalServerError, err)333 return334 }335 log.Printf("admin: signed in to %s as %s with Quick Connect", acct.Server, acct.User)336 writeJSON(w, map[string]any{"state": "done", "account": s.accountStatus(r.Context(), true)})337}338339// ---------- channels ----------340341type channelConfig struct {342 Series []string `json:"series"`343 Movies []string `json:"movies"`344 Collections []string `json:"collections"`345 Playlists []string `json:"playlists"`346 Order string `json:"order"`347 MaxBitrate float64 `json:"maxBitrate"`348}349350type programJSON struct {351 Start time.Time `json:"start"`352 End time.Time `json:"end"`353 Title string `json:"title"`354 Subtitle string `json:"subtitle,omitempty"`355 // OffAir marks a scheduled channel's time off the air; its subtitle356 // says what's next and when.357 OffAir bool `json:"offAir,omitempty"`358}359360// programOf is a guide entry as the page shows it, an off-air one's note361// as its subtitle.362func programOf(p vchan.Program) programJSON {363 out := programJSON{Start: p.Start, End: p.End, Title: p.Title, Subtitle: p.Subtitle, OffAir: p.OffAir}364 if p.OffAir {365 out.Subtitle = p.Description366 }367 return out368}369370type channelJSON struct {371 Number string `json:"number"`372 Name string `json:"name"`373 Kind string `json:"kind"` // "weather", "folder", "jellyfin" or "youtube"374 Folder string `json:"folder,omitempty"`375 // The channel's details, as guides and lineups show them.376 CallSign string `json:"callSign"`377 Category string `json:"category"`378 Description string `json:"description"`379 Logo string `json:"logo,omitempty"` // where the page gets it, with its version380 Enabled bool `json:"enabled"`381 // Antenna names the antenna channel with the same number, which this382 // one takes the place of in the HDHomeRun lineup.383 Antenna string `json:"antenna,omitempty"`384 // Videos counts a folder's videos, or a YouTube channel's catalog.385 Videos *int `json:"videos,omitempty"`386 Config *channelConfig `json:"config,omitempty"`387 // Items counts the videos in rotation: a Jellyfin channel's, or those388 // that fit a YouTube channel's settings.389 Items *int `json:"items,omitempty"`390 Unmatched []string `json:"unmatched,omitempty"`391 YouTube *ytConfigJSON `json:"youtube,omitempty"`392 Sources []ytSourceJSON `json:"sources,omitempty"`393 Listing bool `json:"listing,omitempty"` // a YouTube source is being listed394 // Leveled counts the videos whose loudness is remembered from an395 // airing, when leveling is on: of a folder's or a Jellyfin channel's396 // videos with sound, of what a YouTube channel has scheduled.397 Leveled *leveledJSON `json:"leveled,omitempty"`398 // Schedule is when a channel that plays in order airs, as its399 // channel.json has it; none airs around the clock.400 Schedule *vchan.Schedule `json:"schedule,omitempty"`401 Error string `json:"error,omitempty"`402 Now *programJSON `json:"now,omitempty"`403}404405type leveledJSON struct {406 Measured int `json:"measured"`407 Total int `json:"total"`408}409410// describe is a folder channel as the page shows it; antenna is the411// antenna channels by number.412func (s *Server) describe(e vchan.Entry, antenna map[string]string) channelJSON {413 c := channelJSON{Kind: vchan.KindOf(e.Channel), Folder: e.Folder}414 withDetails(&c, e.Channel.Details(), antenna)415 c.Schedule = vchan.ReadSchedule(filepath.Join(s.Library.Root, e.Folder))416 if l, ok := e.Channel.(interface{ Leveled() (int, int) }); ok {417 if m, t := l.Leveled(); t > 0 {418 c.Leveled = &leveledJSON{m, t}419 }420 }421 switch ch := e.Channel.(type) {422 case *vchan.Folder:423 n := ch.Videos()424 c.Videos = &n425 case *vchan.YouTube:426 s.describeYouTube(&c, ch)427 case *vchan.Jellyfin:428 cfg, err := readConfig(filepath.Join(s.Library.Root, e.Folder, channelFile))429 if err != nil {430 c.Error = err.Error()431 }432 c.Config = &cfg433 items, unmatched, err := ch.Status()434 c.Items, c.Unmatched = &items, unmatched435 if err != nil {436 c.Error = err.Error()437 }438 if items > 0 {439 c.Now = nowOn(ch)440 }441 }442 return c443}444445// describeWeather is the weather channel as the page shows it.446func (s *Server) describeWeather(antenna map[string]string) channelJSON {447 c := channelJSON{Kind: "weather"}448 withDetails(&c, s.Weather.Details(), antenna)449 return c450}451452// withDetails fills in a channel's number, name and details.453func withDetails(c *channelJSON, d vchan.Details, antenna map[string]string) {454 c.Number, c.Name, c.CallSign, c.Category, c.Description, c.Enabled = d.Number, d.Name, d.CallSign, d.Category, d.Description, d.Enabled455 if tag := vchan.LogoTag(d.Logo); d.Logo != "" && tag != "" {456 c.Logo = "/admin/api/channels/" + url.PathEscape(d.Number) + "/logo?v=" + tag457 }458 for n, name := range antenna {459 if vchan.SameNumber(n, d.Number) {460 c.Antenna = name461 }462 }463}464465// antenna lists the antenna channels by number, or none when they can't466// be known.467func (s *Server) antenna(ctx context.Context) map[string]string {468 if s.Antenna == nil {469 return nil470 }471 ctx, cancel := context.WithTimeout(ctx, 10*time.Second)472 defer cancel()473 return s.Antenna(ctx)474}475476// describeNumber is the channel numbered number as the page shows it.477func (s *Server) describeNumber(ctx context.Context, number string) (channelJSON, bool) {478 if s.isWeather(number) {479 return s.describeWeather(s.antenna(ctx)), true480 }481 if e, ok := s.find(number); ok {482 return s.describe(e, s.antenna(ctx)), true483 }484 return channelJSON{}, false485}486487func nowOn(ch vchan.Channel) *programJSON {488 now := time.Now()489 for _, p := range ch.Programs(now, now.Add(time.Second)) {490 if !p.Start.After(now) && p.End.After(now) {491 pj := programOf(p)492 return &pj493 }494 }495 return nil496}497498type antennaJSON struct {499 Number string `json:"number"`500 Name string `json:"name"`501}502503func (s *Server) getState(w http.ResponseWriter, r *http.Request) {504 antenna := s.antenna(r.Context())505 chans := []channelJSON{}506 if s.Weather != nil {507 chans = append(chans, s.describeWeather(antenna))508 }509 for _, e := range s.Library.Entries() {510 chans = append(chans, s.describe(e, antenna))511 }512 list := []antennaJSON{}513 for n, name := range antenna {514 list = append(list, antennaJSON{Number: n, Name: name})515 }516 slices.SortFunc(list, func(a, b antennaJSON) int { return guide.CompareNumbers(a.Number, b.Number) })517 state := map[string]any{518 "account": s.accountStatus(r.Context(), false), "channels": chans, "categories": vchan.Categories, "antenna": list,519 "channelsDir": s.ChannelsDir, "musicDir": s.MusicDir,520 }521 if s.Listings != nil {522 state["listings"] = s.Listings.Status()523 }524 writeJSON(w, state)525}526527func (s *Server) find(number string) (vchan.Entry, bool) {528 for _, e := range s.Library.Entries() {529 if vchan.SameNumber(e.Channel.Number(), number) {530 return e, true531 }532 }533 return vchan.Entry{}, false534}535536// isWeather reports whether number is the weather channel's.537func (s *Server) isWeather(number string) bool {538 return s.Weather != nil && vchan.SameNumber(s.Weather.Number(), number)539}540541// channelReq is a channel as the page saves it: its number, name and542// details, and a Jellyfin channel's settings, or a YouTube channel's under543// "youtube".544type channelReq struct {545 Kind string `json:"kind"` // "jellyfin" (when left out), "youtube", "folder" or "weather"546 Number string `json:"number"`547 Name string `json:"name"`548 channelConfig549 YouTube *ytReq `json:"youtube"`550 detailsReq551}552553// detailsReq is a channel's details as the page saves them. Those left out554// stay as they are; empty ones go back to the defaults.555type detailsReq struct {556 CallSign *string `json:"callSign"`557 Category *string `json:"category"`558 Description *string `json:"description"`559 Enabled *bool `json:"enabled"`560 // Schedule is when a channel that plays in order airs (see561 // vchan.Schedule); null takes it out, to air around the clock.562 Schedule json.RawMessage `json:"schedule"`563 schedule *vchan.Schedule // Schedule read, nil for null564}565566const (567 maxCallSign = 8568 maxDescription = 500569)570571// callSignRe is a call sign: letters and digits, with a few marks ("A&E",572// "E!") and single spaces.573var callSignRe = regexp.MustCompile(`^[\p{L}\p{N}&+!.'-]+( [\p{L}\p{N}&+!.'-]+)*$`)574575// check tidies the details and says what's wrong with them.576func (d *detailsReq) check() error {577 if d.CallSign != nil {578 c := strings.Join(strings.Fields(*d.CallSign), " ")579 if c != "" && (utf8.RuneCountInString(c) > maxCallSign || !callSignRe.MatchString(c)) {580 return fmt.Errorf("the call sign is a short label of up to %d letters and digits, like TOON", maxCallSign)581 }582 d.CallSign = &c583 }584 if d.Category != nil {585 c := vchan.Category(*d.Category)586 if c == "" && strings.TrimSpace(*d.Category) != "" {587 return fmt.Errorf("the category is one of %s", strings.Join(vchan.Categories, ", "))588 }589 d.Category = &c590 }591 if d.Description != nil {592 desc := strings.Join(strings.Fields(*d.Description), " ")593 if utf8.RuneCountInString(desc) > maxDescription {594 return fmt.Errorf("the description is up to %d characters", maxDescription)595 }596 d.Description = &desc597 }598 if d.Schedule != nil && !bytes.Equal(bytes.TrimSpace(d.Schedule), []byte("null")) {599 var sch vchan.Schedule600 dec := json.NewDecoder(bytes.NewReader(d.Schedule))601 dec.DisallowUnknownFields()602 if err := dec.Decode(&sch); err != nil {603 return fmt.Errorf("the schedule should be {start, first, blocks: [{days, at, count or until}]}: %v", err)604 }605 sch.Start = strings.TrimSpace(sch.Start)606 for i := range sch.Blocks {607 b := &sch.Blocks[i]608 b.At, b.Until = strings.TrimSpace(b.At), strings.TrimSpace(b.Until)609 for k, day := range b.Days {610 b.Days[k] = strings.ToLower(strings.TrimSpace(day))611 }612 }613 if err := sch.Check(); err != nil {614 return fmt.Errorf("the schedule: %w", err)615 }616 d.schedule = &sch617 }618 return nil619}620621func (d detailsReq) given() bool {622 return d.CallSign != nil || d.Category != nil || d.Description != nil || d.Enabled != nil || d.Schedule != nil623}624625// apply sets the details in a record.626func (d detailsReq) apply(m map[string]any) {627 set := func(key string, v *string) {628 switch {629 case v == nil:630 case *v == "":631 delete(m, key)632 default:633 m[key] = *v634 }635 }636 set("callSign", d.CallSign)637 set("category", d.Category)638 set("description", d.Description)639 if d.Enabled != nil {640 m["enabled"] = *d.Enabled641 }642 switch {643 case d.Schedule == nil:644 case d.schedule == nil:645 delete(m, "schedule")646 default:647 m["schedule"] = d.schedule648 }649}650651var kindLabel = map[string]string{"jellyfin": "Jellyfin", "youtube": "YouTube", "folder": "folder", "weather": "weather"}652653func (s *Server) putChannel(w http.ResponseWriter, r *http.Request) {654 current := r.PathValue("number")655 var req channelReq656 if !decode(w, r, &req) {657 return658 }659 number, numbered := vchan.ParseNumber(req.Number)660 req.Number, req.Name = number, strings.Join(strings.Fields(req.Name), " ")661 kind := cmp(req.Kind, "jellyfin")662 switch {663 case kindLabel[kind] == "":664 writeErr(w, http.StatusBadRequest, errors.New("kind is jellyfin, youtube, folder or weather"))665 return666 case !numbered:667 writeErr(w, http.StatusBadRequest, errors.New("the number should look like 1.4 or 104.0: 1 to 999, a dot, then 0 to 999"))668 return669 case req.Name == "" || len(req.Name) > 48 || strings.ContainsAny(req.Name, `/\`) || strings.HasPrefix(req.Name, "."):670 writeErr(w, http.StatusBadRequest, errors.New("give the channel a short name without slashes"))671 return672 case kind == "jellyfin" && req.Order != "" && req.Order != "shuffle" && req.Order != "aired":673 writeErr(w, http.StatusBadRequest, errors.New("order is shuffle or aired"))674 return675 case kind == "jellyfin" && (req.MaxBitrate < 0 || req.MaxBitrate > 100):676 writeErr(w, http.StatusBadRequest, errors.New("the bitrate cap is in Mbps, up to 100"))677 return678 }679 if err := req.check(); err != nil {680 writeErr(w, http.StatusBadRequest, err)681 return682 }683 if kind == "weather" {684 if req.schedule != nil {685 writeErr(w, http.StatusBadRequest, errors.New("the schedule: the weather channel airs around the clock"))686 return687 }688 s.putWeather(w, r, current, req)689 return690 }691 if s.isWeather(req.Number) {692 writeErr(w, http.StatusConflict, fmt.Errorf("%s is taken by %s", req.Number, s.Weather.Name()))693 return694 }695 var existing *vchan.Entry696 if current != "new" {697 e, ok := s.find(current)698 if !ok {699 writeErr(w, http.StatusNotFound, fmt.Errorf("no channel %s", current))700 return701 }702 if k := vchan.KindOf(e.Channel); k != kind {703 writeErr(w, http.StatusBadRequest, fmt.Errorf("%s is a %s channel and can't become a %s one; make a new channel instead",704 current, kindLabel[k], kindLabel[kind]))705 return706 }707 existing = &e708 } else if kind == "folder" {709 writeErr(w, http.StatusBadRequest, errors.New("folder channels are made by adding a folder of videos to the channels folder"))710 return711 }712 // What to write in the channel's folder besides its details: nothing713 // for a folder channel.714 var file string715 var write func(path string) error716 switch kind {717 case "jellyfin":718 file, write = channelFile, func(path string) error { return writeConfig(path, req.channelConfig) }719 case "youtube":720 cur := ytConfigOf(vchan.YouTubeDefaults())721 if existing != nil {722 c, _ := vchan.ReadYouTubeConfig(filepath.Join(s.Library.Root, existing.Folder))723 cur = ytConfigOf(c)724 }725 cfg, err := ytSettings(req.YouTube, cur)726 if err == nil && req.schedule != nil && cfg.Playlist == "" {727 err = errors.New("the schedule: uploads play in no order; a channel playing a playlist can air on a schedule")728 }729 if err != nil {730 writeErr(w, http.StatusBadRequest, err)731 return732 }733 file, write = youtubeFile, func(path string) error { return writeYtConfig(path, cfg) }734 }735 if e, ok := s.find(req.Number); ok && (existing == nil || e.Folder != existing.Folder) {736 writeErr(w, http.StatusConflict, fmt.Errorf("%s is taken by %s", req.Number, e.Channel.Name()))737 return738 }739740 // The folder is renamed only for a new number or name, so a folder741 // without a number keeps its name when only its details change.742 folder := req.Number + " " + req.Name743 switch {744 case existing == nil:745 if err := os.Mkdir(filepath.Join(s.Library.Root, folder), 0o755); err != nil {746 writeErr(w, http.StatusConflict, fmt.Errorf("can't make the folder: %w", err))747 return748 }749 case existing.Channel.Number() == req.Number && existing.Channel.Name() == req.Name:750 folder = existing.Folder751 case existing.Folder != folder:752 if _, err := os.Stat(filepath.Join(s.Library.Root, folder)); err == nil {753 writeErr(w, http.StatusConflict, fmt.Errorf("a folder named %q already exists", folder))754 return755 }756 if err := os.Rename(filepath.Join(s.Library.Root, existing.Folder), filepath.Join(s.Library.Root, folder)); err != nil {757 writeErr(w, http.StatusInternalServerError, err)758 return759 }760 }761 dir := filepath.Join(s.Library.Root, folder)762 var err error763 if write != nil {764 err = write(filepath.Join(dir, file))765 }766 if err == nil && req.given() {767 err = updateRecord(filepath.Join(dir, vchan.DetailsFile), req.apply)768 }769 if err != nil {770 if existing == nil {771 _ = os.RemoveAll(dir)772 }773 writeErr(w, http.StatusInternalServerError, err)774 return775 }776 s.Library.Rescan()777 e, ok := s.find(req.Number)778 if !ok {779 writeErr(w, http.StatusInternalServerError, errors.New("saved, but the channel didn't appear"))780 return781 }782 log.Printf("admin: saved channel %s", folder)783 writeJSON(w, s.describe(e, s.antenna(r.Context())))784}785786// putWeather saves the weather channel's number, name and details in its787// record.788func (s *Server) putWeather(w http.ResponseWriter, r *http.Request, current string, req channelReq) {789 if s.Weather == nil || s.Weather.Record == "" || !s.isWeather(current) {790 writeErr(w, http.StatusNotFound, fmt.Errorf("%s isn't the weather channel", current))791 return792 }793 if e, ok := s.find(req.Number); ok {794 writeErr(w, http.StatusConflict, fmt.Errorf("%s is taken by %s", req.Number, e.Channel.Name()))795 return796 }797 err := updateRecord(s.Weather.Record, func(m map[string]any) {798 m["number"], m["name"] = req.Number, req.Name799 req.apply(m)800 })801 if err != nil {802 writeErr(w, http.StatusInternalServerError, err)803 return804 }805 // The folders give way to the weather channel's number.806 s.Library.Rescan()807 log.Printf("admin: saved the weather channel as %s %s", req.Number, req.Name)808 writeJSON(w, s.describeWeather(s.antenna(r.Context())))809}810811// updateRecord changes a details file (channel.json or .weather.json),812// keeping anything the page doesn't edit.813func updateRecord(path string, change func(map[string]any)) error {814 m := map[string]any{}815 if raw, err := os.ReadFile(path); err == nil {816 if err := json.Unmarshal(raw, &m); err != nil {817 return fmt.Errorf("%s: %w", filepath.Base(path), err)818 }819 } else if !errors.Is(err, fs.ErrNotExist) {820 return err821 }822 change(m)823 raw, err := json.MarshalIndent(m, "", " ")824 if err != nil {825 return err826 }827 return writeFile(path, append(raw, '\n'), 0o644)828}829830func (s *Server) deleteChannel(w http.ResponseWriter, r *http.Request) {831 e, ok := s.find(r.PathValue("number"))832 if !ok {833 writeErr(w, http.StatusNotFound, errors.New("no such channel"))834 return835 }836 // The channel's files, the one that makes it a channel first, then837 // its details and logo.838 var files []string839 switch e.Channel.(type) {840 case *vchan.Jellyfin:841 files = []string{channelFile}842 case *vchan.YouTube:843 files = vchan.YouTubeFiles()844 default:845 writeErr(w, http.StatusBadRequest, errors.New("only Jellyfin and YouTube channels are deleted here"))846 return847 }848 dir := filepath.Join(s.Library.Root, e.Folder)849 if logo := e.Channel.Details().Logo; logo != "" {850 files = append(files, filepath.Base(logo))851 }852 files = append(files, vchan.DetailsFile)853 if err := os.Remove(filepath.Join(dir, files[0])); err != nil {854 writeErr(w, http.StatusInternalServerError, err)855 return856 }857 for _, f := range files[1:] {858 _ = os.Remove(filepath.Join(dir, f))859 }860 _ = os.Remove(dir) // only if nothing else is in it861 s.Library.Rescan()862 log.Printf("admin: deleted channel %s", e.Folder)863 w.WriteHeader(http.StatusNoContent)864}865866func (s *Server) getSchedule(w http.ResponseWriter, r *http.Request) {867 number := r.PathValue("number")868 var ch vchan.Channel869 if s.isWeather(number) {870 ch = s.Weather871 } else if e, ok := s.find(number); ok {872 ch = e.Channel873 }874 if ch == nil {875 writeErr(w, http.StatusNotFound, errors.New("no such channel"))876 return877 }878 now := time.Now()879 out := []programJSON{}880 for _, p := range ch.Programs(now, now.Add(24*time.Hour)) {881 if len(out) == 12 {882 break883 }884 out = append(out, programOf(p))885 }886 writeJSON(w, map[string]any{"programs": out})887}888889// sequenceItemJSON is an item of a channel that plays in order, for890// choosing which its schedule begins with.891type sequenceItemJSON struct {892 N int `json:"n"` // from 1, as a schedule's first counts893 Title string `json:"title"`894 Subtitle string `json:"subtitle,omitempty"`895 Season string `json:"season,omitempty"`896 Episode string `json:"episode,omitempty"`897 Seconds int `json:"seconds"`898}899900// getItems lists a channel's items in the order they air, for one that901// can air on a schedule.902func (s *Server) getItems(w http.ResponseWriter, r *http.Request) {903 number := r.PathValue("number")904 e, found := s.find(number)905 if !found && !s.isWeather(number) {906 writeErr(w, http.StatusNotFound, errors.New("no such channel"))907 return908 }909 seq, ok := e.Channel.(vchan.Sequenced)910 var items []vchan.SequenceItem911 if ok {912 items, ok = seq.Sequence()913 }914 if !ok {915 writeErr(w, http.StatusNotFound, fmt.Errorf("%s doesn't play its videos in order", number))916 return917 }918 out := make([]sequenceItemJSON, len(items))919 for i, it := range items {920 out[i] = sequenceItemJSON{N: i + 1, Title: it.Title, Subtitle: it.Subtitle, Season: it.Season, Episode: it.Episode, Seconds: int(it.Length.Seconds())}921 }922 writeJSON(w, map[string]any{"items": out})923}924925// readConfig reads the parts of a channel's jellyfin.json the page edits.926func readConfig(path string) (channelConfig, error) {927 c := channelConfig{Order: "shuffle"}928 raw, err := os.ReadFile(path)929 if err != nil {930 return c, err931 }932 if err := json.Unmarshal(raw, &c); err != nil {933 return c, fmt.Errorf("%s: %w", channelFile, err)934 }935 if c.Order == "" {936 c.Order = "shuffle"937 }938 for _, l := range []*[]string{&c.Series, &c.Movies, &c.Collections, &c.Playlists} {939 if *l == nil {940 *l = []string{}941 }942 }943 return c, nil944}945946// writeConfig updates a channel's jellyfin.json, keeping any settings the947// page doesn't edit (a different server, or libraries).948func writeConfig(path string, c channelConfig) error {949 m := map[string]any{}950 if raw, err := os.ReadFile(path); err == nil {951 if err := json.Unmarshal(raw, &m); err != nil {952 return fmt.Errorf("%s: %w", channelFile, err)953 }954 }955 set := func(key string, list []string) {956 list = slices.Compact(slices.Sorted(slices.Values(list)))957 if len(list) == 0 {958 delete(m, key)959 } else {960 m[key] = list961 }962 }963 set("series", c.Series)964 set("movies", c.Movies)965 set("collections", c.Collections)966 set("playlists", c.Playlists)967 m["order"] = cmp(c.Order, "shuffle")968 if c.MaxBitrate > 0 {969 m["maxBitrate"] = c.MaxBitrate970 } else {971 delete(m, "maxBitrate")972 }973 raw, err := json.MarshalIndent(m, "", " ")974 if err != nil {975 return err976 }977 // A channel file may carry its own login, so keep it private too.978 return writeFile(path, append(raw, '\n'), 0o600)979}980981func cmp(s, fallback string) string {982 if s == "" {983 return fallback984 }985 return s986}987988// writeFile replaces path atomically.989func writeFile(path string, data []byte, perm os.FileMode) error {990 tmp, err := os.CreateTemp(filepath.Dir(path), ".tmp-*")991 if err != nil {992 return err993 }994 defer os.Remove(tmp.Name())995 if _, err := tmp.Write(data); err != nil {996 tmp.Close()997 return err998 }999 if err := tmp.Chmod(perm); err != nil {1000 tmp.Close()1001 return err1002 }1003 if err := tmp.Close(); err != nil {1004 return err1005 }1006 return os.Rename(tmp.Name(), path)1007}10081009// ---------- library ----------10101011type itemJSON struct {1012 ID string `json:"id"`1013 Key string `json:"key"`1014 Name string `json:"name"`1015 Year int `json:"year,omitempty"`1016 Genres []string `json:"genres"`1017 Overview string `json:"overview,omitempty"`1018 Count int `json:"count,omitempty"`1019 RuntimeMinutes int `json:"runtimeMinutes,omitempty"`1020 Image string `json:"image"`1021}10221023func (s *Server) getLibrary(w http.ResponseWriter, r *http.Request) {1024 kind := r.PathValue("kind")1025 s.mu.Lock()1026 if b, ok := s.browse[kind]; ok && time.Since(b.at) < recheckEvery {1027 s.mu.Unlock()1028 writeJSON(w, map[string]any{"items": b.items})1029 return1030 }1031 s.mu.Unlock()10321033 c, err := s.client()1034 if err != nil {1035 writeErr(w, http.StatusServiceUnavailable, err)1036 return1037 }1038 list := map[string]func(context.Context) ([]jellyfin.Item, error){1039 "series": c.Series, "movies": c.Movies, "collections": c.Collections, "playlists": c.Playlists,1040 }[kind]1041 if list == nil {1042 writeErr(w, http.StatusNotFound, errors.New("kind is series, movies, collections or playlists"))1043 return1044 }1045 ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)1046 defer cancel()1047 found, err := list(ctx)1048 if err != nil {1049 writeErr(w, http.StatusBadGateway, err)1050 return1051 }1052 items := make([]itemJSON, 0, len(found))1053 for _, it := range found {1054 x := itemJSON{1055 ID: it.ID, Key: it.Key(), Name: it.Name, Year: it.Year, Genres: it.Genres, Overview: it.Overview,1056 Count: it.Count, RuntimeMinutes: int(it.Runtime.Minutes()),1057 }1058 if x.Genres == nil {1059 x.Genres = []string{}1060 }1061 if it.HasImage {1062 x.Image = "/admin/api/image/" + it.ID1063 }1064 items = append(items, x)1065 }1066 slices.SortFunc(items, func(a, b itemJSON) int { return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) })1067 s.mu.Lock()1068 if s.browse == nil {1069 s.browse = map[string]browsed{}1070 }1071 s.browse[kind] = browsed{items: items, at: time.Now()}1072 s.mu.Unlock()1073 writeJSON(w, map[string]any{"items": items})1074}10751076func (s *Server) getImage(w http.ResponseWriter, r *http.Request) {1077 id := r.PathValue("id")1078 if !idRe.MatchString(id) {1079 http.NotFound(w, r)1080 return1081 }1082 c, err := s.client()1083 if err != nil {1084 writeErr(w, http.StatusServiceUnavailable, err)1085 return1086 }1087 data, kind, err := c.Image(r.Context(), id, 300)1088 if err != nil {1089 writeErr(w, http.StatusBadGateway, err)1090 return1091 }1092 w.Header().Set("Content-Type", kind)1093 w.Header().Set("Cache-Control", "max-age=86400")1094 _, _ = w.Write(data)1095}10961097// ---------- helpers ----------10981099func decode(w http.ResponseWriter, r *http.Request, v any) bool {1100 if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(v); err != nil {1101 writeErr(w, http.StatusBadRequest, err)1102 return false1103 }1104 return true1105}11061107// getTunes reports how long channel changes took lately, by kind of1108// channel: the server's time to a ready stream, and the app's from the key1109// press to the picture, as apps report it (POST /api/metrics).1110func getTunes(w http.ResponseWriter, _ *http.Request) {1111 writeJSON(w, map[string]any{"tunes": phase.Tunes.Summaries()})1112}11131114func writeJSON(w http.ResponseWriter, v any) {1115 w.Header().Set("Content-Type", "application/json")1116 _ = json.NewEncoder(w).Encode(v)1117}11181119func writeErr(w http.ResponseWriter, code int, err error) {1120 w.Header().Set("Content-Type", "application/json")1121 w.WriteHeader(code)1122 _ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})1123}11241125// ---------- listings ----------11261127// Listings is a Schedules Direct account and how its listings stand.1128type Listings interface {1129 Status() sd.Status1130 // SetAccount keeps a login (an empty password keeps the saved one for1131 // the same user) and updates with it; an empty user removes it.1132 SetAccount(user, password string) error1133}11341135// putListings saves the Schedules Direct login and starts an update with1136// it; the page follows the update in the state.1137func (s *Server) putListings(w http.ResponseWriter, r *http.Request) {1138 if s.Listings == nil {1139 writeErr(w, http.StatusNotFound, errors.New("this server has no antenna, so no listings"))1140 return1141 }1142 var req struct{ User, Password string }1143 if !decode(w, r, &req) {1144 return1145 }1146 req.User = strings.TrimSpace(req.User)1147 if req.User == "" {1148 writeErr(w, http.StatusBadRequest, errors.New("enter the Schedules Direct user name"))1149 return1150 }1151 if err := s.Listings.SetAccount(req.User, req.Password); err != nil {1152 writeErr(w, http.StatusBadRequest, err)1153 return1154 }1155 writeJSON(w, s.Listings.Status())1156}11571158// deleteListings removes the Schedules Direct account; the guide goes back1159// to Gracenote's public grid.1160func (s *Server) deleteListings(w http.ResponseWriter, r *http.Request) {1161 if s.Listings == nil {1162 writeErr(w, http.StatusNotFound, errors.New("this server has no antenna, so no listings"))1163 return1164 }1165 if err := s.Listings.SetAccount("", ""); err != nil {1166 writeErr(w, http.StatusInternalServerError, err)1167 return1168 }1169 writeJSON(w, s.Listings.Status())1170}