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 / admin / mcp.go
mcp.go 56.2K
1package admin23// Agents: the page's channel management as tools for AI agents, served4// over MCP (Streamable HTTP) at /admin/mcp. Each tool is a thin wrapper5// over the page's own API, run in-process through the same handler, so an6// agent gets the page's checks and behaviour exactly. The page registers7// the same tools with WebMCP (ui/webmcp.js), calling them here, so an8// agent working through the open page gets them too.910import (11 "bytes"12 "context"13 "encoding/json"14 "errors"15 "fmt"16 "log"17 "net/http"18 "net/url"19 "slices"20 "strings"21 "time"22 "unicode/utf8"2324 "github.com/google/jsonschema-go/jsonschema"25 "github.com/modelcontextprotocol/go-sdk/mcp"2627 "airwaves/internal/guide"28 "airwaves/internal/vchan"29)3031const (32 mcpPath = "/admin/mcp"33 // viaHeader marks the calls the page makes for WebMCP, for the log.34 viaHeader = "X-Airwaves-Via"35 // browseLimit is how many library items browse_jellyfin returns at36 // most, and browseDefault when the agent doesn't say.37 browseLimit = 20038 browseDefault = 5039 // overviewCut is how much of an item's overview browse_jellyfin keeps.40 overviewCut = 20041)4243// agentGuide is what an agent is told when it connects.44const agentGuide = `Airwaves is a home TV server: antenna channels from a tuner, and custom channels it makes itself, in one guide and in an emulated HDHomeRun lineup. These tools manage the custom channels, as the admin page at /admin/ does. Changes go live at once for every viewer, and there is no undo.4546Every channel has a number "major.minor" (major 1 to 999, minor 0 to 999, like "1.4" or "104.0"; "1.05" and "1.5" are the same number), a short name (up to 48 characters, no slashes), and details: a call sign (up to 8 letters and digits, like TOON), a category, a description (up to 500 characters) and whether it is enabled (a disabled channel stays here but leaves the lineup and the app).4748Numbers are unique among custom channels, the weather channel's included. A custom channel may take an antenna channel's number, but then replaces that channel in the HDHomeRun lineup, so pick a number list_antenna_channels doesn't show; list_channels suggests the next free one.4950Kinds: weather (built in), folder (a folder of video files on the server, made by hand), jellyfin (series, movies, collections and playlists from the owner's Jellyfin server) and youtube (the uploads of up to 10 YouTube channels, or one YouTube playlist in its order, streamed, never downloaded). Jellyfin and YouTube channels are created, edited and deleted here; set_channel_details works on every kind.5152Planning a channel:531. list_channels and list_antenna_channels: what exists, and a free number.542. YouTube: search_youtube_channels (by topic or name) or lookup_youtube_channel (by link or @handle), then create_youtube_channel with their @handles or links; or create_youtube_channel with a playlist's link, to play it in order. Jellyfin: browse_jellyfin for the exact keys ("Name (Year)"), then create_jellyfin_channel.553. get_channel shows what was picked up (Jellyfin names that matched nothing, YouTube listing progress), and get_schedule what airs next.5657Saving a channel's settings plans its schedule again from that moment, the current airing included.5859Folder and Jellyfin channels, and YouTube channels playing a playlist, loop their videos in order around the clock. A schedule (the schedule input of set_channel_details and the Jellyfin and YouTube tools) makes one air in daily blocks from a start date instead, such as four episodes a night at 8 PM from the first, off the air between; get_channel lists the videos in the order they air, for picking the first.6061The channel setup is backed up by itself 30 seconds after changes, and daily. Before a big reorganization, create_backup keeps a labelled copy until the owner deletes it; list_backups shows the backups. Restoring one is for a person, on the admin page.`6263// addAgentRoutes serves the agent tools at /admin/mcp, each calling api,64// the page's API.65func addAgentRoutes(mux *http.ServeMux, api http.Handler) {66 h := agentHandler(api)67 // One pattern per method: "/admin/mcp" alone would clash with68 // "GET /admin/".69 for _, m := range []string{http.MethodGet, http.MethodPost, http.MethodDelete} {70 mux.Handle(m+" "+mcpPath, h)71 }72}7374// agentHandler is the MCP endpoint. It is stateless, so a client keeps75// working across server restarts, and answers in plain JSON. Cross-origin76// browser requests are refused: only the page itself, and agents outside77// a browser, may call it.78func agentHandler(api http.Handler) http.Handler {79 srv := mcp.NewServer(&mcp.Implementation{Name: "airwaves", Title: "Airwaves channels", Version: "1.0.0"},80 &mcp.ServerOptions{Instructions: agentGuide})81 (&agents{api: api}).register(srv)82 h := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { return srv },83 &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true})84 return http.NewCrossOriginProtection().Handler(h)85}8687// agents runs the tools against the page's API.88type agents struct {89 api http.Handler90}9192// ---------- calling the API ----------9394// recorder keeps an answer from the API.95type recorder struct {96 header http.Header97 code int98 body bytes.Buffer99}100101func (r *recorder) Header() http.Header { return r.header }102103func (r *recorder) WriteHeader(code int) {104 if r.code == 0 {105 r.code = code106 }107}108109func (r *recorder) Write(p []byte) (int, error) {110 r.WriteHeader(http.StatusOK)111 return r.body.Write(p)112}113114// call makes a request of the page's API, as the page would, and decodes115// the answer into out when it isn't nil. An error answer comes back as an116// error with the API's message.117func (a *agents) call(ctx context.Context, method, path string, body, out any) error {118 var raw []byte119 if body != nil {120 var err error121 if raw, err = json.Marshal(body); err != nil {122 return err123 }124 }125 req, err := http.NewRequestWithContext(ctx, method, path, bytes.NewReader(raw))126 if err != nil {127 return err128 }129 req.Header.Set("Accept", "application/json")130 if body != nil {131 req.Header.Set("Content-Type", "application/json")132 }133 rec := &recorder{header: http.Header{}}134 a.api.ServeHTTP(rec, req)135 if rec.code == 0 {136 rec.code = http.StatusOK137 }138 if rec.code >= 400 {139 var e struct {140 Error string `json:"error"`141 }142 if json.Unmarshal(rec.body.Bytes(), &e) == nil && e.Error != "" {143 return errors.New(e.Error)144 }145 return fmt.Errorf("%s %s: %d %s", method, path, rec.code, strings.TrimSpace(rec.body.String()))146 }147 if out == nil || rec.code == http.StatusNoContent {148 return nil149 }150 return json.Unmarshal(rec.body.Bytes(), out)151}152153// stateJSON is the page's state.154type stateJSON struct {155 Account accountStatus `json:"account"`156 Channels []channelJSON `json:"channels"`157 Categories []string `json:"categories"`158 Antenna []antennaJSON `json:"antenna"`159}160161func (a *agents) state(ctx context.Context) (stateJSON, error) {162 var st stateJSON163 err := a.call(ctx, http.MethodGet, "/admin/api/state", nil, &st)164 return st, err165}166167// channel finds the channel numbered number.168func (a *agents) channel(ctx context.Context, number string) (channelJSON, error) {169 st, err := a.state(ctx)170 if err != nil {171 return channelJSON{}, err172 }173 n := strings.TrimSpace(number)174 for _, c := range st.Channels {175 if vchan.SameNumber(c.Number, n) {176 return c, nil177 }178 }179 return channelJSON{}, fmt.Errorf("there's no channel %s; list_channels shows the channels there are", n)180}181182func (a *agents) schedule(ctx context.Context, number string) ([]programJSON, error) {183 var s struct {184 Programs []programJSON `json:"programs"`185 }186 err := a.call(ctx, http.MethodGet, "/admin/api/channels/"+url.PathEscape(number)+"/schedule", nil, &s)187 return s.Programs, err188}189190// put saves a channel as the page does: number is the one it has now, or191// "new".192func (a *agents) put(ctx context.Context, number string, body map[string]any) (channelJSON, error) {193 var c channelJSON194 err := a.call(ctx, http.MethodPut, "/admin/api/channels/"+url.PathEscape(number), body, &c)195 return c, err196}197198// fresh reads a Jellyfin channel again once its first fetch from the199// server is done, which asking for its schedule waits for (up to 15200// seconds), so the agent sees what its picks matched. A save answers201// before then.202func (a *agents) fresh(ctx context.Context, c channelJSON) channelJSON {203 if c.Kind != "jellyfin" {204 return c205 }206 if _, err := a.schedule(ctx, c.Number); err != nil {207 return c208 }209 if f, err := a.channel(ctx, c.Number); err == nil {210 return f211 }212 return c213}214215// ---------- schemas ----------216217// object is an object schema. It refuses properties it doesn't name218// (additionalProperties false), so a misspelt one is an error rather than219// ignored.220func object(props map[string]*jsonschema.Schema, required ...string) *jsonschema.Schema {221 return &jsonschema.Schema{Type: "object", Properties: props, Required: required, AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}}222}223224func str(desc string) *jsonschema.Schema {225 return &jsonschema.Schema{Type: "string", Description: desc}226}227228func boolean(desc string) *jsonschema.Schema {229 return &jsonschema.Schema{Type: "boolean", Description: desc}230}231232func strs(desc string) *jsonschema.Schema {233 return &jsonschema.Schema{Type: "array", Description: desc, Items: &jsonschema.Schema{Type: "string"}}234}235236func num(desc string, lo, hi float64) *jsonschema.Schema {237 return &jsonschema.Schema{Type: "number", Description: desc, Minimum: &lo, Maximum: &hi}238}239240func integer(desc string, lo, hi float64) *jsonschema.Schema {241 return &jsonschema.Schema{Type: "integer", Description: desc, Minimum: &lo, Maximum: &hi}242}243244func enum[T any](typ, desc string, values []T) *jsonschema.Schema {245 s := &jsonschema.Schema{Type: typ, Description: desc}246 for _, v := range values {247 s.Enum = append(s.Enum, v)248 }249 return s250}251252// with adds properties to a set.253func with(props map[string]*jsonschema.Schema, more ...map[string]*jsonschema.Schema) map[string]*jsonschema.Schema {254 for _, m := range more {255 for k, v := range m {256 props[k] = v257 }258 }259 return props260}261262const (263 numberDesc = `The channel's number, like "1.4".`264 newNumberDesc = `A new number for the channel, "major.minor" (major 1 to 999, minor 0 to 999); leave out to keep it.`265 nameDesc = "The channel's name as guides show it: up to 48 characters, no slashes."266 newNameDesc = "A new name, up to 48 characters, no slashes; leave out to keep it."267)268269// detailProps are a channel's details, as every tool that saves them270// takes them.271func detailProps() map[string]*jsonschema.Schema {272 return map[string]*jsonschema.Schema{273 "callSign": str(`A short label guides show beside the number: up to 8 letters and digits, with & + ! . ' - and single spaces allowed ("TOON", "A&E"). "" removes it.`),274 "category": enum("string", "What guides file the channel under.", vchan.Categories),275 "description": str("A sentence or two about the channel for guides, up to 500 characters. " +276 `"" removes it.`),277 "enabled": boolean("false takes the channel out of the lineup and the app, keeping it here; true puts it back. New channels are enabled."),278 }279}280281func ytSettingProps() map[string]*jsonschema.Schema {282 return map[string]*jsonschema.Schema{283 "repeatDays": num("No video airs again within this many days, while the channels have enough videos (then the one aired longest ago goes next). Default 30.", 0, 365),284 "maxHeight": enum("integer", "The picture height streamed, at most. Default 720.", heights),285 "minMinutes": num("Videos shorter than this are left out (Shorts always are). Default 3.", 0, 600),286 "maxMinutes": num("Videos longer than this are left out; 0 for no limit. Must be more than minMinutes. Default 240, so raise it (or use 0) for long videos like ambience loops, livestream archives or full sets.", 0, 1440),287 "rerunMix": enum("string", "How reruns lean to recent uploads: recent picks about 70% of reruns from the last six months, 20% from the 18 months before and 10% from any time; balanced 40/30/30; any treats every upload alike. Default balanced.",288 rerunMixes),289 "maxAgeDays": num("Only videos uploaded within this many days play, first runs and reruns alike, aging out as days pass; 0 for any age. With none that recent, the channel leaves the lineup until a new upload arrives. Default 0.", 0, 3650),290 "deadAir": boolean("true never repeats a video within repeatDays: a YouTube channel with nothing left to air sits out its turn, and when every video has aired, the channel shows \"Please stand by\" (\"Off air\" in the guide) until a new upload arrives. false (the default) cycles reruns instead, the one aired longest ago first."),291 }292}293294// scheduleProp is a channel's schedule, as the tools that save one take295// it (vchan.Schedule).296func scheduleProp() *jsonschema.Schema {297 days := &jsonschema.Schema{Type: "array", Description: `The days it airs; leave out for every day.`,298 Items: enum("string", "A day.", []string{"mon", "tue", "wed", "thu", "fri", "sat", "sun"})}299 block := object(map[string]*jsonschema.Schema{300 "days": days,301 "at": str(`When it starts, "20:00" (24-hour, the server's time zone).`),302 "count": integer("How many videos it airs back to back. Give count or until.", 1, 100),303 "until": str(`When it stops, "23:30": nothing starts from then, and the video on finishes. Earlier than at is the next day's.`),304 }, "at")305 block.Description = "A daily run of the channel."306 most := 12307 s := object(map[string]*jsonschema.Schema{308 "start": str(`When the channel begins, in the server's time zone: "2026-10-04" (its first block that day) or "2026-10-04T20:00".`),309 "first": integer("The video it begins with, counting from 1 in the order get_channel's items lists. Default 1.", 1, 1e6),310 "blocks": {Type: "array", Description: "When it airs each day, up to 12 blocks; leave out to air around the clock from start.",311 Items: block, MaxItems: &most},312 }, "start")313 s.Type, s.Types = "", []string{"object", "null"}314 s.Description = "When a folder, Jellyfin or YouTube playlist channel airs (Jellyfin best with order aired): its videos in order from start, beginning with first and looping at the end; " +315 "with blocks only in them, off the air between, with the guide saying when it's back. " +316 `Four episodes a night at 8 PM from the first: {"start": "2026-10-04", "blocks": [{"at": "20:00", "count": 4}]}. ` +317 "null takes the schedule away, back to looping around the clock; left out, it stays as it is."318 return s319}320321const ytChannelsDesc = `YouTube channels whose uploads play, up to 10: "@handle", a channel link (youtube.com/@handle, /channel/UC..., /c/name) or a "UC..." channel ID. Video links are refused, and a playlist goes in playlist.`322323const ytPlaylistDesc = `A YouTube playlist to play instead of channels, in its order, looping at the end: its link (youtube.com/playlist?list=..., or a link to a video in it with list=) or its ID. ` +324 "Its private, deleted and upcoming videos are left out, and videos added to it join daily. Of the settings, only maxHeight applies to it. " +325 "It airs around the clock, or with a schedule only then, picking up where it left off."326327func pickProps(verb string) map[string]*jsonschema.Schema {328 return map[string]*jsonschema.Schema{329 "series": strs("Series " + verb + `, by browse_jellyfin's key ("Name (Year)") or plain name.`),330 "movies": strs("Movies " + verb + ", likewise."),331 "collections": strs("Collections " + verb + ", by name."),332 "playlists": strs("Playlists " + verb + ", by name."),333 }334}335336func jellyfinSettingProps() map[string]*jsonschema.Schema {337 return map[string]*jsonschema.Schema{338 "order": enum("string", "shuffle (a fixed shuffle of everything picked) or aired (each series in season and episode order, movies by release date). Default shuffle.",339 []string{"shuffle", "aired"}),340 "maxBitrate": num("A cap in Mb/s: the Jellyfin server transcodes to H.264 at that rate and at most 720p (4 is plenty for TV). 0 streams the original files. Default 0.", 0, 100),341 }342}343344// ---------- tools ----------345346var readOnly = &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: new(false)}347348// register adds the tools to srv.349func (a *agents) register(srv *mcp.Server) {350 addTool(srv, &mcp.Tool{351 Name: "list_channels",352 Title: "List channels",353 Description: "Start here: every custom channel (weather, folder, Jellyfin and YouTube) with its number, name, kind, call sign, category, whether it's enabled, what it plays, " +354 "how many videos are in rotation and what's on now; the Jellyfin account's state; the categories; and nextFree, the lowest free 1.x number that no antenna channel uses.",355 InputSchema: object(map[string]*jsonschema.Schema{}),356 Annotations: readOnly,357 }, a.listChannels)358359 addTool(srv, &mcp.Tool{360 Name: "get_channel",361 Title: "Get a channel",362 Description: "One channel in full, as the admin page shows it: its details, its settings (config for Jellyfin: series, movies, collections, playlists, order, maxBitrate; " +363 "youtube for YouTube: channels and the settings), what it picked up (unmatched lists Jellyfin names that matched nothing; sources lists each YouTube channel's videos, " +364 "how many fit the settings, and whether it's still being listed), its schedule, any error, and the next airings; for a folder, Jellyfin or YouTube playlist channel, items lists its videos " +365 "in the order they air (numbered from 1, for a schedule's first).",366 InputSchema: object(map[string]*jsonschema.Schema{"number": str(numberDesc)}, "number"),367 Annotations: readOnly,368 }, a.getChannel)369370 addTool(srv, &mcp.Tool{371 Name: "get_schedule",372 Title: "Get a channel's schedule",373 Description: "What a channel airs next: up to 12 programs within the next 24 hours, with start and end times in the server's time zone. " +374 "Empty while a new YouTube channel's sources are being listed, or when nothing fits its settings or no Jellyfin pick matched.",375 InputSchema: object(map[string]*jsonschema.Schema{"number": str(numberDesc)}, "number"),376 Annotations: readOnly,377 }, a.getSchedule)378379 addTool(srv, &mcp.Tool{380 Name: "list_antenna_channels",381 Title: "List antenna channels",382 Description: "The antenna channels the tuner receives, by number. A custom channel with one of these numbers takes that channel's place in the HDHomeRun lineup, " +383 "so pick numbers not listed here.",384 InputSchema: object(map[string]*jsonschema.Schema{}),385 Annotations: readOnly,386 }, a.listAntenna)387388 addTool(srv, &mcp.Tool{389 Name: "browse_jellyfin",390 Title: "Browse the Jellyfin library",391 Description: "Search the Jellyfin server's series, movies, collections or playlists, for picks to put on a Jellyfin channel. Returns each match's key, the name to use in " +392 "create_jellyfin_channel and update_jellyfin_channel (\"Name (Year)\"), with year, genres, episode or item count, runtime and a short overview, a page at a time; " +393 "genres lists the most common genres of that kind, for the genre filter. Needs the Jellyfin account to work (see list_channels).",394 InputSchema: object(map[string]*jsonschema.Schema{395 "kind": enum("string", "What to browse.", []string{"series", "movies", "collections", "playlists"}),396 "text": str("Words that must all appear in the name or overview, ignoring case; name matches come first."),397 "genre": str(`Only items with this genre, ignoring case ("Animation", "Comedy").`),398 "yearFrom": integer("Only items from this year on.", 1800, 2200),399 "yearTo": integer("Only items up to this year.", 1800, 2200),400 "limit": integer(fmt.Sprintf("How many items to return, default %d, at most %d.", browseDefault, browseLimit), 1, browseLimit),401 "offset": integer("How many matches to skip, to page through them.", 0, 1e6),402 }, "kind"),403 Annotations: readOnly,404 }, a.browseJellyfin)405406 addTool(srv, &mcp.Tool{407 Name: "search_youtube_channels",408 Title: "Search YouTube channels",409 Description: "Find YouTube channels by name or topic (up to 15, with handle, link, ID, subscribers and description), for create_youtube_channel. " +410 "Runs yt-dlp on the server, so it takes several seconds; results are kept for 5 minutes.",411 InputSchema: object(map[string]*jsonschema.Schema{"query": str("A channel name or topic, up to 100 characters.")}, "query"),412 Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: new(true)},413 }, a.searchYouTube)414415 addTool(srv, &mcp.Tool{416 Name: "lookup_youtube_channel",417 Title: "Look up a YouTube channel",418 Description: "One YouTube channel from its link, @handle or UC... ID: name, handle, link, ID, subscribers, roughly how many videos it has, and its description. " +419 "Runs yt-dlp on the server (up to a minute); results are kept for hours.",420 InputSchema: object(map[string]*jsonschema.Schema{"channel": str(`"@handle", a channel link or a "UC..." channel ID.`)}, "channel"),421 Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: new(true)},422 }, a.lookupYouTube)423424 addTool(srv, &mcp.Tool{425 Name: "create_youtube_channel",426 Title: "Create a YouTube channel",427 Description: "Make a channel that runs the uploads of YouTube channels like a TV channel, with a real guide, or plays a YouTube playlist in order. Give channels or playlist. " +428 "Once saved, the server lists each source's uploads with yt-dlp (about 75 videos a second, so a 20,000-video channel takes about 5 minutes) and the channel joins " +429 "the lineup when all are listed; get_channel shows the progress. Settings left out take the defaults.",430 InputSchema: object(with(map[string]*jsonschema.Schema{431 "number": str(`The new channel's number, "major.minor" (major 1 to 999, minor 0 to 999), like "1.9"; it must be free (see list_channels' nextFree).`),432 "name": str(nameDesc),433 "channels": strs(ytChannelsDesc),434 "playlist": str(ytPlaylistDesc),435 "schedule": scheduleProp(),436 }, ytSettingProps(), detailProps()), "number", "name"),437 Annotations: &mcp.ToolAnnotations{OpenWorldHint: new(true)},438 }, a.createYouTube)439440 addTool(srv, &mcp.Tool{441 Name: "update_youtube_channel",442 Title: "Update a YouTube channel",443 Description: "Change a YouTube channel's sources, playlist, settings, schedule, number or name; anything left out stays as it is. channels replaces the whole list, " +444 "addChannels and removeChannels change it; a playlist takes the channels' place, and channels the playlist's. Saving plans the schedule again from now, " +445 "the current airing included, and lists a playlist again. For call sign, category, description, enabled or logo use set_channel_details.",446 InputSchema: object(with(map[string]*jsonschema.Schema{447 "number": str(numberDesc),448 "newNumber": str(newNumberDesc),449 "name": str(newNameDesc),450 "channels": strs(ytChannelsDesc + " Replaces the list."),451 "addChannels": strs("YouTube channels to add to the list, in the same forms."),452 "removeChannels": strs("YouTube channels to drop from the list: as listed, or by @handle, name or channel ID."),453 "playlist": str(ytPlaylistDesc + " Replaces the channels, or the playlist it plays."),454 "schedule": scheduleProp(),455 }, ytSettingProps()), "number"),456 Annotations: &mcp.ToolAnnotations{OpenWorldHint: new(true)},457 }, a.updateYouTube)458459 addTool(srv, &mcp.Tool{460 Name: "create_jellyfin_channel",461 Title: "Create a Jellyfin channel",462 Description: "Make a channel that plays series, movies, collections and playlists from the owner's Jellyfin server on a fixed clock, with a full guide. " +463 "Use browse_jellyfin's keys; names match ignoring case, and a trailing year picks between same-named titles. The answer's unmatched lists names that matched nothing, " +464 "and items how many episodes and movies are in rotation. Needs the Jellyfin account to work.",465 InputSchema: object(with(map[string]*jsonschema.Schema{466 "number": str(`The new channel's number, "major.minor" (major 1 to 999, minor 0 to 999), like "1.9"; it must be free (see list_channels' nextFree).`),467 "name": str(nameDesc),468 "schedule": scheduleProp(),469 }, pickProps("to play"), jellyfinSettingProps(), detailProps()), "number", "name"),470 Annotations: &mcp.ToolAnnotations{OpenWorldHint: new(false)},471 }, a.createJellyfin)472473 pickSet := func(verb string) *jsonschema.Schema {474 s := object(pickProps(verb))475 s.Description = "Picks " + verb + "."476 return s477 }478 addTool(srv, &mcp.Tool{479 Name: "update_jellyfin_channel",480 Title: "Update a Jellyfin channel",481 Description: "Change what a Jellyfin channel plays, its order, bitrate cap, schedule, number or name; anything left out stays as it is. series, movies, collections and " +482 "playlists replace those lists; add and remove change them. Adding or removing picks reshuffles the schedule. For call sign, category, description, enabled " +483 "or logo use set_channel_details.",484 InputSchema: object(with(map[string]*jsonschema.Schema{485 "number": str(numberDesc),486 "newNumber": str(newNumberDesc),487 "name": str(newNameDesc),488 "add": pickSet("to add"),489 "remove": pickSet("to remove, as listed or by plain name"),490 "schedule": scheduleProp(),491 }, pickProps("to play, replacing the list"), jellyfinSettingProps()), "number"),492 Annotations: &mcp.ToolAnnotations{OpenWorldHint: new(false)},493 }, a.updateJellyfin)494495 addTool(srv, &mcp.Tool{496 Name: "set_channel_details",497 Title: "Set a channel's details",498 Description: "Set any channel's details, the weather and folder channels' included: call sign, category, description, enabled, number, name and logo, " +499 "and a folder, Jellyfin or YouTube playlist channel's schedule. Anything left out stays as it is.",500 InputSchema: object(with(map[string]*jsonschema.Schema{501 "number": str(numberDesc),502 "newNumber": str(newNumberDesc),503 "name": str(newNameDesc),504 "schedule": scheduleProp(),505 "logo": enum("string", "source makes the source's picture the logo (a Jellyfin channel's first pick's poster, collections first; a YouTube channel's first channel's avatar); "+506 "none removes the logo.", []string{"source", "none"}),507 }, detailProps()), "number"),508 Annotations: &mcp.ToolAnnotations{OpenWorldHint: new(false)},509 }, a.setDetails)510511 addTool(srv, &mcp.Tool{512 Name: "delete_channel",513 Title: "Delete a channel",514 Description: "Delete a Jellyfin or YouTube channel: its folder's settings, details, logo, catalog and schedule go, and it leaves the lineup at once. " +515 "Can't be undone. Weather and folder channels aren't deleted here (set enabled false with set_channel_details to take one off the air).",516 InputSchema: object(map[string]*jsonschema.Schema{517 "number": str(numberDesc),518 "confirm": boolean("Must be true: deleting can't be undone."),519 }, "number", "confirm"),520 Annotations: &mcp.ToolAnnotations{DestructiveHint: new(true), IdempotentHint: true, OpenWorldHint: new(false)},521 }, a.deleteChannel)522523 addTool(srv, &mcp.Tool{524 Name: "list_backups",525 Title: "List backups",526 Description: "The backups of the channel setup, newest first: each channel's settings, details, logo and YouTube schedule, the weather channel and the Jellyfin account " +527 "(never videos). For each, when and why it was made (change: 30 seconds after changes through the admin page or these tools; daily; restore: just before one; " +528 "manual: with create_backup or by the owner; uploaded), its label, and its channels. Restoring is for a person, on the admin page.",529 InputSchema: object(map[string]*jsonschema.Schema{}),530 Annotations: readOnly,531 }, a.listBackups)532533 addTool(srv, &mcp.Tool{534 Name: "create_backup",535 Title: "Back up the channel setup",536 Description: "Back up the channel setup now, with a label saying why, before a big change. Changes are backed up by themselves too, but those backups are pruned " +537 "(the last 30, and one a day for 14 days); one made here is kept until the owner deletes it. Changes nothing else.",538 InputSchema: object(map[string]*jsonschema.Schema{539 "label": str(fmt.Sprintf(`A short note on why, up to %d characters ("before reorganizing the kids channels").`, maxLabel)),540 }),541 Annotations: &mcp.ToolAnnotations{DestructiveHint: new(false), OpenWorldHint: new(false)},542 }, a.createBackup)543}544545// addTool adds a tool whose run answers with a value the agent reads as546// JSON, or an error it reads as its message, and logs each call.547func addTool[In any](srv *mcp.Server, t *mcp.Tool, run func(context.Context, In) (any, error)) {548 mcp.AddTool(srv, t, func(ctx context.Context, req *mcp.CallToolRequest, in In) (*mcp.CallToolResult, any, error) {549 start := time.Now()550 out, err := run(ctx, in)551 logCall(req, t.Name, in, err, time.Since(start))552 if err != nil {553 return nil, nil, err554 }555 raw, err := json.MarshalIndent(out, "", " ")556 if err != nil {557 return nil, nil, err558 }559 return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: string(raw)}}}, nil, nil560 })561}562563// numbered is a tool's input naming a channel.564type numbered interface{ channel() string }565566// logCall notes a tool call: its name and channel, never what was asked567// or found.568func logCall(req *mcp.CallToolRequest, name string, in any, err error, took time.Duration) {569 what := name570 if n, ok := in.(numbered); ok && n.channel() != "" {571 what += " " + n.channel()572 }573 if req != nil && req.Extra != nil && req.Extra.Header.Get(viaHeader) == "webmcp" {574 what += " (through the page)"575 }576 switch {577 case err != nil:578 log.Printf("admin: agent: %s: %v", what, err)579 case took >= time.Millisecond:580 log.Printf("admin: agent: %s in %s", what, took.Round(time.Millisecond))581 default:582 log.Printf("admin: agent: %s", what)583 }584}585586// ---------- inputs ----------587588type noInput struct{}589590type numberIn struct {591 Number string `json:"number"`592}593594func (in numberIn) channel() string { return in.Number }595596type detailsIn struct {597 CallSign *string `json:"callSign"`598 Category *string `json:"category"`599 Description *string `json:"description"`600 Enabled *bool `json:"enabled"`601}602603func (d detailsIn) given() bool {604 return d.CallSign != nil || d.Category != nil || d.Description != nil || d.Enabled != nil605}606607// into adds the details given to a channel as the page saves it.608func (d detailsIn) into(m map[string]any) {609 for k, v := range map[string]*string{"callSign": d.CallSign, "category": d.Category, "description": d.Description} {610 if v != nil {611 m[k] = *v612 }613 }614 if d.Enabled != nil {615 m["enabled"] = *d.Enabled616 }617}618619// scheduleIn is a channel's schedule as the tools take it: left out, it620// stays as it is, and null takes it away.621type scheduleIn struct {622 Schedule json.RawMessage `json:"schedule"`623}624625func (s scheduleIn) given() bool { return s.Schedule != nil }626627// into adds the schedule given to a channel as the page saves it.628func (s scheduleIn) into(m map[string]any) {629 if s.Schedule != nil {630 m["schedule"] = s.Schedule631 }632}633634type ytSettingsIn struct {635 RepeatDays *float64 `json:"repeatDays"`636 MaxHeight *int `json:"maxHeight"`637 MinMinutes *float64 `json:"minMinutes"`638 MaxMinutes *float64 `json:"maxMinutes"`639 RerunMix *string `json:"rerunMix"`640 MaxAgeDays *float64 `json:"maxAgeDays"`641 DeadAir *bool `json:"deadAir"`642}643644func (y ytSettingsIn) given() bool {645 return y.RepeatDays != nil || y.MaxHeight != nil || y.MinMinutes != nil || y.MaxMinutes != nil || y.RerunMix != nil || y.MaxAgeDays != nil ||646 y.DeadAir != nil647}648649// into adds the settings given to a YouTube channel's, as the page saves650// them.651func (y ytSettingsIn) into(m map[string]any) {652 for k, v := range map[string]*float64{"repeatDays": y.RepeatDays, "minMinutes": y.MinMinutes, "maxMinutes": y.MaxMinutes, "maxAgeDays": y.MaxAgeDays} {653 if v != nil {654 m[k] = *v655 }656 }657 if y.MaxHeight != nil {658 m["maxHeight"] = *y.MaxHeight659 }660 if y.RerunMix != nil {661 m["rerunMix"] = *y.RerunMix662 }663 if y.DeadAir != nil {664 m["deadAir"] = *y.DeadAir665 }666}667668type picksIn struct {669 Series []string `json:"series"`670 Movies []string `json:"movies"`671 Collections []string `json:"collections"`672 Playlists []string `json:"playlists"`673}674675// lists pairs each of the picks with the kind it is and the list it676// changes.677func (p *picksIn) lists(c *channelConfig) []pickList {678 return []pickList{679 {"series", &p.Series, &c.Series}, {"movies", &p.Movies, &c.Movies},680 {"collections", &p.Collections, &c.Collections}, {"playlists", &p.Playlists, &c.Playlists},681 }682}683684type pickList struct {685 kind string686 given, to *[]string687}688689type jellyfinSettingsIn struct {690 Order *string `json:"order"`691 MaxBitrate *float64 `json:"maxBitrate"`692}693694// ---------- reading ----------695696// channelLine is a channel as list_channels shows it.697type channelLine struct {698 Number string `json:"number"`699 Name string `json:"name"`700 Kind string `json:"kind"`701 CallSign string `json:"callSign,omitempty"`702 Category string `json:"category"`703 Enabled bool `json:"enabled"`704 Plays string `json:"plays,omitempty"`705 InRotation *int `json:"inRotation,omitempty"`706 Listing bool `json:"listing,omitempty"`707 OnNow string `json:"onNow,omitempty"`708 Antenna string `json:"replacesAntenna,omitempty"`709 // Schedule is when it airs, for one that has one.710 Schedule *vchan.Schedule `json:"schedule,omitempty"`711 Error string `json:"error,omitempty"`712}713714// plays says briefly what a channel plays.715func plays(c channelJSON) string {716 switch c.Kind {717 case "weather":718 return "local conditions, forecast and radar"719 case "folder":720 if c.Videos != nil {721 return count(*c.Videos, "video file", "video files")722 }723 case "jellyfin":724 if c.Config == nil {725 return ""726 }727 var parts []string728 for _, l := range []struct {729 list []string730 one, many string731 }{732 {c.Config.Series, "series", "series"}, {c.Config.Movies, "movie", "movies"},733 {c.Config.Collections, "collection", "collections"}, {c.Config.Playlists, "playlist", "playlists"},734 } {735 if len(l.list) > 0 {736 parts = append(parts, count(len(l.list), l.one, l.many))737 }738 }739 if len(c.Unmatched) > 0 {740 parts = append(parts, count(len(c.Unmatched), "name that matched nothing", "names that matched nothing"))741 }742 return strings.Join(parts, ", ") + " from Jellyfin"743 case "youtube":744 var names []string745 for _, s := range c.Sources {746 if isPlaylist(c) {747 by := ""748 if s.Owner != "" {749 by = " by " + s.Owner750 }751 return fmt.Sprintf("the playlist %q%s on YouTube, in order", cmp(s.Name, s.URL), by)752 }753 names = append(names, cmp(s.Handle, cmp(s.Name, s.URL)))754 }755 return strings.Join(names, ", ") + " on YouTube"756 }757 return ""758}759760// isPlaylist reports whether c is a YouTube channel playing a playlist.761func isPlaylist(c channelJSON) bool { return c.YouTube != nil && c.YouTube.Playlist != "" }762763func count(n int, one, many string) string {764 if n == 1 {765 return "1 " + one766 }767 return fmt.Sprintf("%d %s", n, many)768}769770func onNow(p *programJSON) string {771 if p == nil {772 return ""773 }774 if p.Subtitle != "" {775 return p.Title + ": " + p.Subtitle776 }777 return p.Title778}779780// nextFree is the lowest 1.x number no channel, custom or antenna, uses.781func nextFree(st stateJSON) string {782 used := map[string]bool{}783 for _, c := range st.Channels {784 used[vchan.NumberKey(c.Number)] = true785 }786 for _, c := range st.Antenna {787 used[vchan.NumberKey(c.Number)] = true788 }789 for minor := 1; minor <= 999; minor++ {790 if n := fmt.Sprintf("1.%d", minor); !used[n] {791 return n792 }793 }794 return ""795}796797func (a *agents) listChannels(ctx context.Context, _ noInput) (any, error) {798 st, err := a.state(ctx)799 if err != nil {800 return nil, err801 }802 lines := []channelLine{}803 for _, c := range st.Channels {804 l := channelLine{Number: c.Number, Name: c.Name, Kind: c.Kind, CallSign: c.CallSign, Category: c.Category, Enabled: c.Enabled,805 Plays: plays(c), Listing: c.Listing, OnNow: onNow(c.Now), Antenna: c.Antenna, Schedule: c.Schedule, Error: c.Error}806 if c.Kind == "jellyfin" || c.Kind == "youtube" {807 l.InRotation = c.Items808 }809 lines = append(lines, l)810 }811 slices.SortFunc(lines, func(x, y channelLine) int { return guide.CompareNumbers(x.Number, y.Number) })812 acct := map[string]any{"ok": st.Account.OK}813 for k, v := range map[string]string{"server": st.Account.Server, "user": st.Account.User, "serverName": st.Account.ServerName, "error": st.Account.Error} {814 if v != "" {815 acct[k] = v816 }817 }818 return map[string]any{819 "channels": lines, "jellyfinAccount": acct, "categories": st.Categories, "nextFree": nextFree(st),820 "antennaChannels": len(st.Antenna),821 }, nil822}823824func (a *agents) getChannel(ctx context.Context, in numberIn) (any, error) {825 c, err := a.channel(ctx, in.Number)826 if err != nil {827 return nil, err828 }829 next, err := a.schedule(ctx, c.Number)830 if err != nil {831 return nil, err832 }833 out := map[string]any{"channel": c, "next": next, "serverTime": time.Now().Format(time.RFC3339)}834 // A channel that plays in order lists its videos, for a schedule's first.835 var seq struct {836 Items []sequenceItemJSON `json:"items"`837 }838 if a.call(ctx, http.MethodGet, "/admin/api/channels/"+url.PathEscape(c.Number)+"/items", nil, &seq) == nil {839 items := []string{}840 for _, it := range seq.Items[:min(len(seq.Items), maxItemsListed)] {841 items = append(items, itemLine(it))842 }843 out["items"] = items844 if more := len(seq.Items) - len(items); more > 0 {845 out["moreItems"] = more846 }847 }848 return out, nil849}850851// maxItemsListed is how many of a channel's videos get_channel lists.852const maxItemsListed = 500853854// itemLine is a channel's video as get_channel lists it: "4. House of the855// Dragon S1 E4: The King of the Narrow Sea (58 min)".856func itemLine(it sequenceItemJSON) string {857 s := fmt.Sprintf("%d. %s", it.N, it.Title)858 if it.Season != "" && it.Episode != "" {859 s += fmt.Sprintf(" S%s E%s", it.Season, it.Episode)860 }861 if it.Subtitle != "" {862 s += ": " + it.Subtitle863 }864 return s + fmt.Sprintf(" (%d min)", (it.Seconds+30)/60)865}866867func (a *agents) getSchedule(ctx context.Context, in numberIn) (any, error) {868 c, err := a.channel(ctx, in.Number)869 if err != nil {870 return nil, err871 }872 progs, err := a.schedule(ctx, c.Number)873 if err != nil {874 return nil, err875 }876 out := map[string]any{"number": c.Number, "name": c.Name, "serverTime": time.Now().Format(time.RFC3339), "programs": progs}877 if len(progs) == 0 {878 out["note"] = emptyReason(c)879 }880 return out, nil881}882883// emptyReason guesses why a channel has nothing to air.884func emptyReason(c channelJSON) string {885 switch {886 case c.Error != "":887 return "Nothing airs: " + c.Error888 case c.Listing && isPlaylist(c):889 return "Nothing airs yet: its playlist is still being listed; try again in a minute."890 case c.Listing:891 return "Nothing airs yet: its YouTube channels are still being listed; try again in a minute or two."892 case isPlaylist(c):893 return "Nothing airs: none of the playlist's videos can play (private, deleted, members-only, live and upcoming ones are left out)."894 case c.Kind == "youtube":895 return "Nothing airs: no video fits the settings (minMinutes, maxMinutes, maxAgeDays); get_channel shows how many each source has."896 case c.Kind == "jellyfin" && len(c.Unmatched) > 0:897 return "Nothing airs: some picks matched nothing on the Jellyfin server (" + strings.Join(c.Unmatched, ", ") + "); browse_jellyfin has the exact keys."898 case c.Kind == "jellyfin":899 return "Nothing airs: no episodes or movies to play; add picks with update_jellyfin_channel."900 }901 return "Nothing scheduled in the next 24 hours."902}903904func (a *agents) listAntenna(ctx context.Context, _ noInput) (any, error) {905 st, err := a.state(ctx)906 if err != nil {907 return nil, err908 }909 out := map[string]any{"antenna": st.Antenna}910 if len(st.Antenna) == 0 {911 out["note"] = "No antenna channels are known: the tuner may not be set up or scanned yet."912 }913 return out, nil914}915916type browseIn struct {917 Kind string `json:"kind"`918 Text string `json:"text"`919 Genre string `json:"genre"`920 YearFrom int `json:"yearFrom"`921 YearTo int `json:"yearTo"`922 Limit int `json:"limit"`923 Offset int `json:"offset"`924}925926// libraryItem is a Jellyfin item as browse_jellyfin shows it.927type libraryItem struct {928 Key string `json:"key"`929 Year int `json:"year,omitempty"`930 Genres []string `json:"genres,omitempty"`931 Episodes int `json:"episodes,omitempty"`932 Items int `json:"items,omitempty"`933 RuntimeMinutes int `json:"runtimeMinutes,omitempty"`934 Overview string `json:"overview,omitempty"`935}936937type genreCount struct {938 Genre string `json:"genre"`939 Count int `json:"count"`940}941942func (a *agents) browseJellyfin(ctx context.Context, in browseIn) (any, error) {943 var lib struct {944 Items []itemJSON `json:"items"`945 }946 if err := a.call(ctx, http.MethodGet, "/admin/api/library/"+url.PathEscape(in.Kind), nil, &lib); err != nil {947 return nil, err948 }949 words := strings.Fields(strings.ToLower(in.Text))950 all := func(s string) bool {951 s = strings.ToLower(s)952 for _, w := range words {953 if !strings.Contains(s, w) {954 return false955 }956 }957 return true958 }959 genres := map[string]int{}960 var byName, byOverview []itemJSON961 for _, it := range lib.Items {962 for _, g := range it.Genres {963 genres[g]++964 }965 switch {966 case in.Genre != "" && !slices.ContainsFunc(it.Genres, func(g string) bool { return strings.EqualFold(g, strings.TrimSpace(in.Genre)) }):967 case in.YearFrom > 0 && it.Year < in.YearFrom, in.YearTo > 0 && (it.Year == 0 || it.Year > in.YearTo):968 case all(it.Key):969 byName = append(byName, it)970 case all(it.Key + " " + it.Overview):971 byOverview = append(byOverview, it)972 }973 }974 matched := append(byName, byOverview...)975 limit := in.Limit976 if limit == 0 {977 limit = browseDefault978 }979 from := min(in.Offset, len(matched))980 page := matched[from:min(from+limit, len(matched))]981 items := []libraryItem{}982 for _, it := range page {983 x := libraryItem{Key: it.Key, Year: it.Year, Genres: it.Genres, RuntimeMinutes: it.RuntimeMinutes, Overview: cut(it.Overview, overviewCut)}984 if in.Kind == "series" {985 x.Episodes = it.Count986 } else {987 x.Items = it.Count988 }989 items = append(items, x)990 }991 top := []genreCount{}992 for g, n := range genres {993 top = append(top, genreCount{g, n})994 }995 slices.SortFunc(top, func(x, y genreCount) int {996 if x.Count != y.Count {997 return y.Count - x.Count998 }999 return strings.Compare(x.Genre, y.Genre)1000 })1001 out := map[string]any{"kind": in.Kind, "total": len(lib.Items), "matched": len(matched), "offset": from, "items": items, "genres": top[:min(len(top), 25)]}1002 if more := len(matched) - from - len(page); more > 0 {1003 out["more"] = fmt.Sprintf("%d more; ask again with offset %d", more, from+len(page))1004 }1005 return out, nil1006}10071008// cut shortens s to about n characters, at a word.1009func cut(s string, n int) string {1010 if utf8.RuneCountInString(s) <= n {1011 return s1012 }1013 r := []rune(s)[:n]1014 if i := strings.LastIndexByte(string(r), ' '); i > n/2 {1015 return string(r)[:i] + "..."1016 }1017 return string(r) + "..."1018}10191020// ytFound is a YouTube channel a search or lookup found, as agents see it.1021type ytFound struct {1022 Name string `json:"name"`1023 Handle string `json:"handle,omitempty"`1024 URL string `json:"url"`1025 ID string `json:"id"`1026 Subscribers int64 `json:"subscribers"`1027 Videos int `json:"videos,omitempty"`1028 Description string `json:"description,omitempty"`1029}10301031func found(c ytChannelJSON) ytFound {1032 return ytFound{Name: c.Name, Handle: c.Handle, URL: c.URL, ID: c.ID, Subscribers: c.Subscribers, Videos: c.Videos, Description: c.Description}1033}10341035type searchIn struct {1036 Query string `json:"query"`1037}10381039func (a *agents) searchYouTube(ctx context.Context, in searchIn) (any, error) {1040 var res struct {1041 Channels []ytChannelJSON `json:"channels"`1042 }1043 if err := a.call(ctx, http.MethodGet, "/admin/api/youtube/search?q="+url.QueryEscape(in.Query), nil, &res); err != nil {1044 return nil, err1045 }1046 out := []ytFound{}1047 for _, c := range res.Channels {1048 out = append(out, found(c))1049 }1050 return map[string]any{"query": in.Query, "channels": out}, nil1051}10521053type lookupIn struct {1054 Channel string `json:"channel"`1055}10561057func (a *agents) lookupYouTube(ctx context.Context, in lookupIn) (any, error) {1058 var c ytChannelJSON1059 if err := a.call(ctx, http.MethodGet, "/admin/api/youtube/channel?u="+url.QueryEscape(in.Channel), nil, &c); err != nil {1060 return nil, err1061 }1062 return found(c), nil1063}10641065// ---------- saving ----------10661067// saved is a channel just saved, with what an agent should know about it.1068func saved(c channelJSON, notes ...string) map[string]any {1069 if c.Antenna != "" {1070 notes = append(notes, fmt.Sprintf("%s takes the place of antenna channel %s %s in the HDHomeRun lineup; if that isn't meant, move it to a free number with newNumber.",1071 c.Number, c.Number, c.Antenna))1072 }1073 if len(c.Unmatched) > 0 {1074 notes = append(notes, "These picks matched nothing on the Jellyfin server: "+strings.Join(c.Unmatched, ", ")+"; browse_jellyfin has the exact keys.")1075 }1076 switch {1077 case c.Listing && isPlaylist(c):1078 notes = append(notes, "Its playlist is being listed now; it joins the lineup once it is. get_channel shows the progress.")1079 case c.Listing:1080 notes = append(notes, "Its YouTube channels are being listed now (about 75 videos a second); it joins the lineup once they all are. get_channel shows the progress.")1081 }1082 if c.Error != "" {1083 notes = append(notes, "Error: "+c.Error)1084 }1085 out := map[string]any{"channel": c}1086 if len(notes) > 0 {1087 out["notes"] = notes1088 }1089 return out1090}10911092type createYouTubeIn struct {1093 Number string `json:"number"`1094 Name string `json:"name"`1095 Channels []string `json:"channels"`1096 Playlist string `json:"playlist"`1097 ytSettingsIn1098 detailsIn1099 scheduleIn1100}11011102func (in createYouTubeIn) channel() string { return in.Number }11031104func (a *agents) createYouTube(ctx context.Context, in createYouTubeIn) (any, error) {1105 yt := map[string]any{"channels": in.Channels, "playlist": in.Playlist}1106 in.ytSettingsIn.into(yt)1107 body := map[string]any{"kind": "youtube", "number": in.Number, "name": in.Name, "youtube": yt}1108 in.detailsIn.into(body)1109 in.scheduleIn.into(body)1110 c, err := a.put(ctx, "new", body)1111 if err != nil {1112 return nil, err1113 }1114 return saved(c), nil1115}11161117type updateYouTubeIn struct {1118 Number string `json:"number"`1119 NewNumber string `json:"newNumber"`1120 Name string `json:"name"`1121 Channels []string `json:"channels"`1122 AddChannels []string `json:"addChannels"`1123 RemoveChannels []string `json:"removeChannels"`1124 Playlist *string `json:"playlist"`1125 ytSettingsIn1126 scheduleIn1127}11281129func (in updateYouTubeIn) channel() string { return in.Number }11301131func (a *agents) updateYouTube(ctx context.Context, in updateYouTubeIn) (any, error) {1132 cur, err := a.channel(ctx, in.Number)1133 if err != nil {1134 return nil, err1135 }1136 if cur.Kind != "youtube" {1137 return nil, wrongKind(cur, "youtube")1138 }1139 if in.NewNumber == "" && in.Name == "" && in.Channels == nil && in.AddChannels == nil && in.RemoveChannels == nil && in.Playlist == nil &&1140 !in.ytSettingsIn.given() && !in.scheduleIn.given() {1141 return nil, errors.New("nothing to change: pass channels, addChannels, removeChannels, playlist, a setting, schedule, newNumber or name")1142 }1143 yt := map[string]any{}1144 in.ytSettingsIn.into(yt)1145 if in.Playlist != nil {1146 yt["playlist"] = *in.Playlist1147 }1148 if in.Channels != nil || in.AddChannels != nil || in.RemoveChannels != nil {1149 list := in.Channels1150 if list == nil && cur.YouTube != nil {1151 list = slices.Clone(cur.YouTube.Channels)1152 }1153 for _, r := range in.RemoveChannels {1154 i := slices.IndexFunc(list, func(ch string) bool { return sameSource(ch, r, cur.Sources) })1155 if i < 0 {1156 return nil, fmt.Errorf("%s doesn't play %q; it plays %s", cur.Number, r, strings.Join(list, ", "))1157 }1158 list = slices.Delete(list, i, i+1)1159 }1160 yt["channels"] = append(list, in.AddChannels...)1161 }1162 body := map[string]any{"kind": "youtube", "number": cmp(in.NewNumber, cur.Number), "name": cmp(in.Name, cur.Name), "youtube": yt}1163 in.scheduleIn.into(body)1164 c, err := a.put(ctx, cur.Number, body)1165 if err != nil {1166 return nil, err1167 }1168 if isPlaylist(c) {1169 return saved(c, "The playlist is listed again, for videos added to it."), nil1170 }1171 return saved(c, "The schedule was planned again from now."), nil1172}11731174// sameSource reports whether ch, a YouTube channel in a channel's list,1175// is the one given: the same page, or a source's handle, name or ID.1176func sameSource(ch, given string, sources []ytSourceJSON) bool {1177 given = strings.TrimSpace(given)1178 page, err := youtubeChannel(ch)1179 if err != nil {1180 page = ch1181 }1182 if p, err := youtubeChannel(given); err == nil && strings.EqualFold(p, page) {1183 return true1184 }1185 for _, s := range sources {1186 if strings.EqualFold(s.Channel, ch) || strings.EqualFold(s.URL, page) {1187 for _, v := range []string{s.Handle, s.Name, s.ID, strings.TrimPrefix(s.Handle, "@")} {1188 if v != "" && strings.EqualFold(v, given) {1189 return true1190 }1191 }1192 }1193 }1194 return strings.EqualFold(ch, given)1195}11961197func wrongKind(c channelJSON, want string) error {1198 if c.Kind == "jellyfin" || c.Kind == "youtube" {1199 return fmt.Errorf("%s %s is a %s channel: use update_%s_channel, or set_channel_details for its details", c.Number, c.Name, kindLabel[c.Kind], c.Kind)1200 }1201 return fmt.Errorf("%s %s is a %s channel, not a %s one: only its details change here, with set_channel_details", c.Number, c.Name, kindLabel[c.Kind], kindLabel[want])1202}12031204type createJellyfinIn struct {1205 Number string `json:"number"`1206 Name string `json:"name"`1207 picksIn1208 jellyfinSettingsIn1209 detailsIn1210 scheduleIn1211}12121213func (in createJellyfinIn) channel() string { return in.Number }12141215func (a *agents) createJellyfin(ctx context.Context, in createJellyfinIn) (any, error) {1216 body := map[string]any{"kind": "jellyfin", "number": in.Number, "name": in.Name}1217 jellyfinBody(body, channelConfig{Series: in.Series, Movies: in.Movies, Collections: in.Collections, Playlists: in.Playlists,1218 Order: deref(in.Order), MaxBitrate: deref(in.MaxBitrate)})1219 in.detailsIn.into(body)1220 in.scheduleIn.into(body)1221 c, err := a.put(ctx, "new", body)1222 if err != nil {1223 return nil, err1224 }1225 return saved(a.fresh(ctx, c)), nil1226}12271228// jellyfinBody adds a Jellyfin channel's whole config to a channel as the1229// page saves it: whatever it leaves out is removed.1230func jellyfinBody(m map[string]any, c channelConfig) {1231 m["series"], m["movies"], m["collections"], m["playlists"] = orEmpty(c.Series), orEmpty(c.Movies), orEmpty(c.Collections), orEmpty(c.Playlists)1232 m["order"], m["maxBitrate"] = cmp(c.Order, "shuffle"), c.MaxBitrate1233}12341235func orEmpty(l []string) []string {1236 if l == nil {1237 return []string{}1238 }1239 return l1240}12411242func deref[T any](p *T) T {1243 var zero T1244 if p == nil {1245 return zero1246 }1247 return *p1248}12491250type updateJellyfinIn struct {1251 Number string `json:"number"`1252 NewNumber string `json:"newNumber"`1253 Name string `json:"name"`1254 Add *picksIn `json:"add"`1255 Remove *picksIn `json:"remove"`1256 picksIn1257 jellyfinSettingsIn1258 scheduleIn1259}12601261func (in updateJellyfinIn) channel() string { return in.Number }12621263func (a *agents) updateJellyfin(ctx context.Context, in updateJellyfinIn) (any, error) {1264 cur, err := a.channel(ctx, in.Number)1265 if err != nil {1266 return nil, err1267 }1268 if cur.Kind != "jellyfin" {1269 return nil, wrongKind(cur, "jellyfin")1270 }1271 cfg := channelConfig{Order: "shuffle"}1272 if cur.Config != nil {1273 cfg = *cur.Config1274 }1275 changed := in.NewNumber != "" || in.Name != "" || in.Order != nil || in.MaxBitrate != nil || in.scheduleIn.given()1276 for _, l := range in.picksIn.lists(&cfg) {1277 if *l.given != nil {1278 *l.to, changed = *l.given, true1279 }1280 }1281 if in.Remove != nil {1282 for _, l := range in.Remove.lists(&cfg) {1283 for _, r := range *l.given {1284 i := slices.IndexFunc(*l.to, func(p string) bool { return samePick(p, r) })1285 if i < 0 {1286 return nil, fmt.Errorf("%s has no %s named %q; its %s: %s", cur.Number, l.kind, r, l.kind, listOrNone(*l.to))1287 }1288 *l.to, changed = slices.Delete(slices.Clone(*l.to), i, i+1), true1289 }1290 }1291 }1292 if in.Add != nil {1293 for _, l := range in.Add.lists(&cfg) {1294 for _, p := range *l.given {1295 if !slices.ContainsFunc(*l.to, func(q string) bool { return strings.EqualFold(strings.TrimSpace(q), strings.TrimSpace(p)) }) {1296 *l.to, changed = append(slices.Clone(*l.to), p), true1297 }1298 }1299 }1300 }1301 if !changed {1302 return nil, errors.New("nothing to change: pass picks (series, movies, collections, playlists, add or remove), order, maxBitrate, schedule, newNumber or name")1303 }1304 if in.Order != nil {1305 cfg.Order = *in.Order1306 }1307 if in.MaxBitrate != nil {1308 cfg.MaxBitrate = *in.MaxBitrate1309 }1310 body := map[string]any{"kind": "jellyfin", "number": cmp(in.NewNumber, cur.Number), "name": cmp(in.Name, cur.Name)}1311 jellyfinBody(body, cfg)1312 in.scheduleIn.into(body)1313 c, err := a.put(ctx, cur.Number, body)1314 if err != nil {1315 return nil, err1316 }1317 return saved(a.fresh(ctx, c)), nil1318}13191320// samePick reports whether p, a pick in a channel's config, is the one1321// given: the same, ignoring case, or the same but for p's year.1322func samePick(p, given string) bool {1323 p, given = strings.TrimSpace(p), strings.TrimSpace(given)1324 if strings.EqualFold(p, given) {1325 return true1326 }1327 if i := strings.LastIndex(p, " ("); i > 0 && strings.HasSuffix(p, ")") {1328 return strings.EqualFold(p[:i], given)1329 }1330 return false1331}13321333func listOrNone(l []string) string {1334 if len(l) == 0 {1335 return "none"1336 }1337 return strings.Join(l, ", ")1338}13391340type setDetailsIn struct {1341 Number string `json:"number"`1342 NewNumber string `json:"newNumber"`1343 Name string `json:"name"`1344 Logo string `json:"logo"`1345 detailsIn1346 scheduleIn1347}13481349func (in setDetailsIn) channel() string { return in.Number }13501351func (a *agents) setDetails(ctx context.Context, in setDetailsIn) (any, error) {1352 cur, err := a.channel(ctx, in.Number)1353 if err != nil {1354 return nil, err1355 }1356 resave := in.NewNumber != "" || in.Name != "" || in.detailsIn.given() || in.scheduleIn.given()1357 if !resave && in.Logo == "" {1358 return nil, errors.New("nothing to change: pass callSign, category, description, enabled, logo, schedule, newNumber or name")1359 }1360 c := cur1361 if resave {1362 body := map[string]any{"kind": cur.Kind, "number": cmp(in.NewNumber, cur.Number), "name": cmp(in.Name, cur.Name)}1363 if cur.Kind == "jellyfin" {1364 // The page saves a Jellyfin channel's config with its details:1365 // send it as it is.1366 cfg := channelConfig{}1367 if cur.Config != nil {1368 cfg = *cur.Config1369 }1370 jellyfinBody(body, cfg)1371 }1372 in.detailsIn.into(body)1373 in.scheduleIn.into(body)1374 if c, err = a.put(ctx, cur.Number, body); err != nil {1375 return nil, err1376 }1377 }1378 logo := "/admin/api/channels/" + url.PathEscape(c.Number) + "/logo"1379 switch in.Logo {1380 case "source":1381 err = a.call(ctx, http.MethodPost, logo+"/source", nil, &c)1382 case "none":1383 err = a.call(ctx, http.MethodDelete, logo, nil, &c)1384 }1385 if err != nil {1386 return nil, fmt.Errorf("the logo: %w", err)1387 }1388 return saved(a.fresh(ctx, c)), nil1389}13901391type deleteIn struct {1392 Number string `json:"number"`1393 Confirm bool `json:"confirm"`1394}13951396func (in deleteIn) channel() string { return in.Number }13971398func (a *agents) deleteChannel(ctx context.Context, in deleteIn) (any, error) {1399 cur, err := a.channel(ctx, in.Number)1400 if err != nil {1401 return nil, err1402 }1403 if !in.Confirm {1404 return nil, fmt.Errorf("pass confirm: true to delete %s %s; it can't be undone", cur.Number, cur.Name)1405 }1406 if err := a.call(ctx, http.MethodDelete, "/admin/api/channels/"+url.PathEscape(cur.Number), nil, nil); err != nil {1407 return nil, err1408 }1409 return map[string]any{"deleted": map[string]string{"number": cur.Number, "name": cur.Name, "kind": cur.Kind}}, nil1410}14111412// ---------- backups ----------14131414// backupLine is a backup as list_backups shows it.1415type backupLine struct {1416 File string `json:"file"`1417 Created string `json:"created"`1418 Reason string `json:"reason"`1419 Label string `json:"label,omitempty"`1420 Kept string `json:"kept"`1421 Channels []string `json:"channels"`1422 Weather bool `json:"weather,omitempty"`1423 Account bool `json:"jellyfinAccount,omitempty"`1424 Schedules bool `json:"youtubeSchedules,omitempty"`1425 Error string `json:"error,omitempty"`1426}14271428func lineOf(b backupJSON) backupLine {1429 l := backupLine{File: b.File, Created: b.Created.Local().Format(time.RFC3339), Reason: b.Reason, Label: b.Label, Kept: "until deleted",1430 Channels: []string{}, Weather: b.Weather, Account: b.Account, Schedules: b.Schedules, Error: b.Error}1431 if b.Auto {1432 l.Kept = "pruned in time"1433 }1434 for _, f := range b.Folders {1435 l.Channels = append(l.Channels, f.Folder+" ("+kindLabel[f.Kind]+")")1436 }1437 return l1438}14391440type listBackupsIn struct{}14411442func (a *agents) listBackups(ctx context.Context, _ listBackupsIn) (any, error) {1443 var res struct {1444 Backups []backupJSON `json:"backups"`1445 Pending bool `json:"pending"`1446 }1447 if err := a.call(ctx, http.MethodGet, "/admin/api/backups", nil, &res); err != nil {1448 return nil, err1449 }1450 lines := []backupLine{}1451 for _, b := range res.Backups {1452 lines = append(lines, lineOf(b))1453 }1454 out := map[string]any{"backups": lines, "serverTime": time.Now().Format(time.RFC3339)}1455 if res.Pending {1456 out["note"] = "Changes made in the last 30 seconds aren't in a backup yet; they will be shortly."1457 }1458 return out, nil1459}14601461type createBackupIn struct {1462 Label string `json:"label"`1463}14641465func (a *agents) createBackup(ctx context.Context, in createBackupIn) (any, error) {1466 var b backupJSON1467 if err := a.call(ctx, http.MethodPost, "/admin/api/backups", map[string]string{"label": in.Label}, &b); err != nil {1468 return nil, err1469 }1470 return map[string]any{"backup": lineOf(b)}, nil1471}