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 / ui / admin.js
admin.js 171.8K
1// Airwaves channel admin. Plain JS, no build step. Talks to /admin/api.2'use strict';34(() => {5 const API = '/admin/api';6 const KINDS = ['series', 'movies', 'collections', 'playlists'];7 const KIND = {8 series: { label: 'Series', one: 'series', many: 'series', empty: 'Shows added to Jellyfin show up here.' },9 movies: { label: 'Movies', one: 'movie', many: 'movies', empty: 'Movies added to Jellyfin show up here.' },10 collections: { label: 'Collections', one: 'collection', many: 'collections', empty: 'Collections made in Jellyfin show up here.' },11 playlists: { label: 'Playlists', one: 'playlist', many: 'playlists', empty: 'Playlists made in Jellyfin show up here.' },12 };13 const CHANNEL_KIND = { weather: 'Weather', folder: 'Folder', jellyfin: 'Jellyfin', youtube: 'YouTube' };14 // Channel details: the server's categories (vchan.Categories) and limits (internal/admin).15 const CATEGORIES = ['Kids', 'Family', 'Movies', 'Sports', 'News', 'Music', 'Pets', 'Gaming', 'Documentary', 'Weather', 'Other'];16 const MAX_CALL = 8;17 const MAX_DESC = 500;18 const CALL_RE = /^[\p{L}\p{N}&+!.'-]+( [\p{L}\p{N}&+!.'-]+)*$/u;19 const LOGO_MAX = 2 << 20;20 const LOGO_TYPES = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp'];21 const LOGO_HINT = 'A PNG, JPEG, SVG or WebP image, up to 2 MB. You can also drop one on the tile.';22 // YouTube channels: the server's defaults and limits (internal/admin/youtube.go).23 const YT_DEFAULTS = { minMinutes: 3, maxMinutes: 240, repeatDays: 30, maxHeight: 720, rerunMix: 'balanced', maxAgeDays: 0, deadAir: false };24 const YT_MAX_SOURCES = 10;25 const YT_HEIGHTS = [480, 720, 1080];26 const YT_RERUNS = [27 { v: 'recent', label: 'Mostly recent', help: 'About 70% of reruns come from the last six months of uploads.' },28 { v: 'balanced', label: 'Balanced', help: 'Reruns lean toward recent uploads, with plenty of older ones.' },29 { v: 'any', label: 'Anything', help: 'Every upload is as likely to rerun as any other.' },30 ];31 // What a YouTube channel does once every video has aired within the repeat window.32 const YT_RUNOUT = [33 { v: false, label: 'Cycle reruns', help: 'When every video has aired lately, the one aired longest ago goes next.' },34 { v: true, label: 'Dead air', help: 'No video airs twice within the repeat window. When everything has, the channel stands by until a new upload.' },35 ];36 // yt-dlp lists about 75 videos a second (Northernlion's 22,000 took five minutes).37 const YT_LIST_RATE = 75;38 const YT_POLL = 5000;39 const YT_LINK = /^(https?:\/\/)?((www|m)\.)?(youtube\.com|youtu\.be)\/|^@[^\s/]+$|^UC[\w-]{22}$/i;40 // What a YouTube channel plays: its channels' uploads, or a playlist in its order.41 const YT_MODES = [42 { v: 'uploads', label: 'Uploads from channels', help: "The channels' uploads take turns, new uploads first, with no repeats for a while." },43 { v: 'playlist', label: 'A playlist, in order', help: "Plays the playlist from the top in its order, then again. Airs below can give it a schedule." },44 ];45 // A playlist's link (or a video's in it) has its ID after list=; an ID alone will do too (internal/vchan/ytplaylist.go).46 const YT_PLAYLIST = /[?&]list=[\w-]{2,}|^(PL|UU|UL|FL|OLAK5uy_)[\w-]{10,}$/;47 const ytListId = (s) => { const m = /[?&]list=([\w-]+)/.exec(s) || /^([\w-]{12,})$/.exec(String(s || '').trim()); return m ? m[1] : ''; };48 const ORDERS = [49 { v: 'shuffle', label: 'Shuffle', help: 'Mixes everything picked into one random rotation.' },50 { v: 'aired', label: 'In order', help: "Plays each show's episodes in season order." },51 ];52 // maxBitrate is a cap in Mbps (the server accepts up to 100); 0 means the original file.53 const BITRATES = [0, 4, 8, 12];54 const CHUNK = 120;55 const TOP_GENRES = 10;56 const SCHEDULE_POLLS = [1500, 4000, 8000, 15000];5758 const S = {59 state: null, stateErr: null,60 sel: null, // what the URL points at: a channel number, 'new', or null61 ch: null, // the channel being shown (null for a new one)62 origNumber: null, // number used in the PUT path; 'new' until created63 draft: null, base: '',64 touched: {}, serverErr: {}, saveMsg: null, saving: false,65 tab: 'series', filters: null, genresOpen: false,66 lib: {}, view: null,67 sched: { number: null, status: 'idle', programs: [], error: '' },68 items: null, // a channel's videos in the order they air, for its schedule ({ number, status, list })69 schedToken: 0, refreshing: false,70 acctOpen: false, acctMode: 'code', acctDraft: null, acctMsg: null, acctNote: '', acctBusy: false,71 qc: null, qcToken: 0,72 detailSig: '',73 logoBusy: '', logoMsg: false, // a logo change under way ('upload', 'source', 'remove'), and whether its message shows74 // YouTube: channels found so far, by lowercased page ({ status, data, error }),75 // searches by query, playlists looked up by link as typed, and what the results pane shows.76 yt: { info: new Map(), searches: new Map(), pls: new Map(), view: null, token: 0, timer: 0, wasBusy: false },77 };78 let E = {};7980 // ---------- Helpers ----------81 const $ = (sel, root = document) => root.querySelector(sel);8283 function h(tag, props, ...kids) {84 const el = document.createElement(tag);85 if (props) {86 for (const [k, v] of Object.entries(props)) {87 if (v == null) continue;88 if (k.startsWith('aria-')) { el.setAttribute(k, String(v)); continue; }89 if (v === false) continue;90 if (k === 'class') el.className = v;91 else if (k === 'text') el.textContent = v;92 else if (k === 'style') Object.assign(el.style, v);93 else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2), v);94 else if (k === 'value' || k === 'checked' || k === 'disabled' || k === 'hidden') el[k] = v;95 else el.setAttribute(k, v === true ? '' : v);96 }97 }98 for (const kid of kids.flat(Infinity)) {99 if (kid == null || kid === false || kid === '') continue;100 el.append(kid instanceof Node ? kid : String(kid));101 }102 return el;103 }104105 // A Blob body (a logo) goes as it is, with its type; anything else as JSON.106 async function api(method, path, body) {107 const opt = { method, headers: { Accept: 'application/json' } };108 if (body instanceof Blob) {109 opt.headers['Content-Type'] = body.type || 'application/octet-stream';110 opt.body = body;111 } else if (body !== undefined) {112 opt.headers['Content-Type'] = 'application/json';113 opt.body = JSON.stringify(body);114 }115 let res;116 try {117 res = await fetch(API + path, opt);118 } catch {119 throw Object.assign(new Error("Can't reach the Airwaves server."), { status: 0 });120 }121 if (res.status === 204) return null;122 const text = await res.text();123 let data = null;124 try { data = text ? JSON.parse(text) : null; } catch { data = null; }125 if (!res.ok) {126 const msg = (data && data.error) || text.trim().slice(0, 200) || `${res.status} ${res.statusText}`;127 throw Object.assign(new Error(msg), { status: res.status });128 }129 return data;130 }131132 const enc = (s) => encodeURIComponent(s);133 const fold = (s) => String(s).normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();134 const plural = (n, one, many) => `${Number(n).toLocaleString()} ${n === 1 ? one : many}`;135 const numParts = (n) => String(n).split('.').map((x) => parseInt(x, 10) || 0);136 const cmpNum = (a, b) => {137 const x = numParts(a), y = numParts(b);138 return (x[0] - y[0]) || ((x[1] || 0) - (y[1] || 0)) || String(a).localeCompare(String(b));139 };140 const findCh = (n) => (S.state ? S.state.channels.find((c) => c.number === n) : null) || null;141 // A channel number as written ("104.0" stays "104.0"), or '' when it isn't one: 1 to 999, a dot, 0 to 999.142 function parseNumber(n) {143 const m = /^(\d{1,3})\.(\d{1,3})$/.exec(String(n).trim());144 return m && +m[1] >= 1 ? `${m[1]}.${m[2]}` : '';145 }146 // A number's value, to tell whether two are the same however written ("1.05" is 1.5).147 const numKey = (n) => numParts(n).join('.');148 const squash = (s) => String(s || '').trim().replace(/\s+/g, ' ');149 const antennaAt = (n) => ((S.state && S.state.antenna) || []).find((a) => numKey(a.number) === numKey(n)) || null;150 // '#new' asks which kind; '#new-youtube' and '#new-jellyfin' are the editors.151 const isNewTarget = (t) => t === 'new' || t === 'new-youtube' || t === 'new-jellyfin';152 const compact = (n) => Number(n).toLocaleString(undefined, { notation: 'compact', maximumFractionDigits: 2 });153154 function fmtTime(t) {155 const d = t instanceof Date ? t : new Date(t);156 if (isNaN(d)) return '';157 return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });158 }159 function fmtRuntime(min) {160 min = Math.round(min);161 if (!min) return '';162 const hr = Math.floor(min / 60), m = min % 60;163 return hr ? `${hr}h ${String(m).padStart(2, '0')}m` : `${m} min`;164 }165 function pct(now) {166 const s = Date.parse(now.start), e = Date.parse(now.end), t = Date.now();167 if (!(e > s)) return 0;168 return Math.max(0, Math.min(100, ((t - s) / (e - s)) * 100));169 }170 function hostOf(url) {171 try { return new URL(url).host; } catch { return url || ''; }172 }173 function serverLabel() {174 const a = S.state && S.state.account;175 if (a && a.serverName) return a.serverName;176 if (a && a.server) return hostOf(a.server);177 return 'Jellyfin';178 }179 // The machine the admin page is served from, for "on nas" style hints.180 function hostLabel() {181 const n = location.hostname;182 if (!n || n === 'localhost' || /^[\d.]+$/.test(n) || n.includes(':')) return '';183 return n.split('.')[0];184 }185 const isTyping = (el) => !!(el && el.closest && el.closest('input:not([type=radio]):not([type=checkbox]), textarea, select, [contenteditable]'));186187 let toastTimer = 0;188 function toast(msg, err = false) {189 const t = $('#toast');190 t.textContent = msg;191 t.classList.toggle('err', err);192 t.classList.add('show');193 clearTimeout(toastTimer);194 toastTimer = setTimeout(() => t.classList.remove('show'), 3200);195 }196197 function confirmDialog({ title, body, ok = 'OK', cancel = 'Cancel', danger = false }) {198 const dlg = $('#dlg');199 $('#dlg-title').textContent = title;200 $('#dlg-body').textContent = body;201 $('#dlg-ok').textContent = ok;202 $('#dlg-cancel').textContent = cancel;203 $('#dlg-ok').className = 'btn' + (danger ? ' danger' : '');204 dlg.classList.toggle('danger', danger);205 dlg.returnValue = '';206 const prev = document.activeElement;207 return new Promise((resolve) => {208 dlg.addEventListener('close', () => {209 const yes = dlg.returnValue === 'ok';210 if (!yes && prev && prev.isConnected) prev.focus();211 resolve(yes);212 }, { once: true });213 dlg.showModal();214 $('#dlg-cancel').focus();215 });216 }217218 // ---------- Drafts ----------219 function suggestNumber() {220 const used = new Set((S.state ? S.state.channels : []).map((c) => numKey(c.number)));221 for (let n = 1; n < 1000; n++) if (!used.has(`1.${n}`)) return `1.${n}`;222 return '';223 }224 // Every channel's details: call sign, category, description, and whether it's on the air.225 function metaFrom(ch, kind) {226 return {227 callSign: (ch && ch.callSign) || '',228 category: (ch && ch.category) || (kind === 'weather' ? 'Weather' : 'Other'),229 description: (ch && ch.description) || '',230 enabled: !ch || ch.enabled !== false,231 sched: schedFrom(ch),232 };233 }234 // The schedule rides with the details (it's in channel.json too), on the channels that can have one.235 const metaSig = (d) => [squash(d.callSign), d.category, squash(d.description), d.enabled, schedulable(d) ? schedBody(d.sched) : null];236 function metaBody(d) {237 const b = { callSign: squash(d.callSign), category: d.category, description: squash(d.description), enabled: d.enabled };238 if (schedulable(d)) b.schedule = schedBody(d.sched);239 return b;240 }241242 function draftFrom(ch) {243 const c = (ch && ch.config) || {};244 const d = {245 kind: 'jellyfin',246 number: ch ? ch.number : suggestNumber(),247 name: ch ? ch.name || '' : '',248 order: c.order === 'aired' ? 'aired' : 'shuffle',249 maxBitrate: Number(c.maxBitrate) || 0,250 ...metaFrom(ch, 'jellyfin'),251 };252 for (const k of KINDS) d[k] = new Set(Array.isArray(c[k]) ? c[k] : []);253 return d;254 }255 // Folder and weather channels: only the number, name and details are theirs to edit.256 function metaDraftFrom(ch) {257 return { kind: ch.kind, number: ch.number, name: ch.name || '', ...metaFrom(ch, ch.kind) };258 }259 function sig(d) {260 if (d.kind === 'youtube') return ytSig(d);261 if (d.kind === 'folder' || d.kind === 'weather') return JSON.stringify([d.kind, d.number.trim(), d.name.trim(), metaSig(d)]);262 return JSON.stringify([d.number.trim(), d.name.trim(), d.order, d.maxBitrate, KINDS.map((k) => [...d[k]].sort()), metaSig(d)]);263 }264265 // A YouTube draft keeps its numbers as typed; sources are channel pages, in order.266 function ytDraftFrom(ch) {267 const y = { ...YT_DEFAULTS, ...((ch && ch.youtube) || {}) };268 return {269 kind: 'youtube',270 number: ch ? ch.number : suggestNumber(),271 name: ch ? ch.name || '' : '',272 sources: Array.isArray(y.channels) ? [...y.channels] : [],273 minMinutes: String(Number(y.minMinutes) || 0),274 maxMinutes: Number(y.maxMinutes) > 0 ? String(y.maxMinutes) : '',275 repeatDays: String(Number(y.repeatDays) || 0),276 maxHeight: Number(y.maxHeight) || YT_DEFAULTS.maxHeight,277 rerunMix: y.rerunMix || YT_DEFAULTS.rerunMix,278 maxAgeDays: Number(y.maxAgeDays) > 0 ? String(y.maxAgeDays) : '',279 deadAir: !!y.deadAir,280 // A playlist instead of sources: playlist is the link in playlist mode, '' otherwise; plLink281 // keeps what's typed across a switch of mode.282 ytMode: y.playlist ? 'playlist' : 'uploads',283 playlist: y.playlist || '',284 plLink: y.playlist || '',285 ...metaFrom(ch, 'youtube'),286 };287 }288 const ytNum = (s) => (String(s).trim() === '' ? NaN : Number(s));289 function ytSig(d) {290 return JSON.stringify(['youtube', d.number.trim(), d.name.trim(), d.ytMode, d.playlist, d.sources.map((s) => s.toLowerCase()),291 ytNum(d.minMinutes), ytNum(d.maxMinutes) || 0, ytNum(d.repeatDays), d.maxHeight, d.rerunMix, ytNum(d.maxAgeDays) || 0, d.deadAir, metaSig(d)]);292 }293 function ytBody(d) {294 // A playlist takes only the quality; the settings for picking uploads stay as they are.295 const youtube = d.ytMode === 'playlist' ? { channels: [], playlist: d.playlist, maxHeight: d.maxHeight } : {296 channels: [...d.sources], playlist: '', minMinutes: ytNum(d.minMinutes), maxMinutes: ytNum(d.maxMinutes) || 0,297 repeatDays: ytNum(d.repeatDays), maxHeight: d.maxHeight, rerunMix: d.rerunMix, maxAgeDays: ytNum(d.maxAgeDays) || 0,298 deadAir: d.deadAir,299 };300 return { kind: 'youtube', number: d.number.trim(), name: d.name.trim(), youtube, ...metaBody(d) };301 }302 // What keeps a YouTube draft from saving: no sources or playlist, or settings out of range.303 function ytProblems(d) {304 const p = { sources: '', settings: '' };305 if (d.ytMode === 'playlist') {306 if (!d.playlist) p.sources = "Paste the playlist's link.";307 else if (!YT_PLAYLIST.test(d.playlist)) p.sources = "That isn't a playlist's link: it has list= in it.";308 return p;309 }310 if (!d.sources.length) p.sources = 'Add a YouTube channel to play.';311 else if (d.sources.length > YT_MAX_SOURCES) p.sources = `Up to ${YT_MAX_SOURCES} YouTube channels.`;312 const lo = ytNum(d.minMinutes), hi = ytNum(d.maxMinutes), rep = ytNum(d.repeatDays);313 if (!(lo >= 0 && lo <= 600)) p.settings = 'The shortest is 0 to 600 minutes.';314 else if (String(d.maxMinutes).trim() !== '' && !(hi > lo && hi <= 1440)) p.settings = 'The longest should be more than the shortest, up to 1,440 minutes.';315 else if (!(rep >= 0 && rep <= 365)) p.settings = 'Repeats are 0 to 365 days apart.';316 else if (String(d.maxAgeDays).trim() !== '' && !(ytNum(d.maxAgeDays) >= 0 && ytNum(d.maxAgeDays) <= 3650)) p.settings = 'Only videos from the last 1 to 3,650 days; leave it empty for any age.';317 return p;318 }319 function bodyOf(d) {320 if (d.kind === 'youtube') return ytBody(d);321 if (d.kind === 'folder' || d.kind === 'weather') return { kind: d.kind, number: d.number.trim(), name: d.name.trim(), ...metaBody(d) };322 const b = { number: d.number.trim(), name: d.name.trim() };323 for (const k of KINDS) b[k] = [...d[k]];324 b.order = d.order;325 b.maxBitrate = d.maxBitrate;326 return Object.assign(b, metaBody(d));327 }328 const isNew = () => S.origNumber === 'new';329 const isDirty = () => !!S.draft && sig(S.draft) !== S.base;330 const newFilters = () => Object.fromEntries(KINDS.map((k) => [k, { q: '', genres: new Set(), onlySel: false }]));331332 function problems() {333 const d = S.draft, n = d.number.trim(), num = parseNumber(n), p = { number: '', name: '', taken: false, badName: false, meta: '', antenna: null };334 if (!n) p.number = 'Give it a number, like 1.6.';335 else if (!num) p.number = 'Use a channel number like 1.6, from 1.0 to 999.999.';336 else {337 const other = S.state.channels.find((c) => numKey(c.number) === numKey(num) && c.number !== S.origNumber);338 if (other) { p.number = `${num} is taken by ${other.name}.`; p.taken = true; }339 p.antenna = antennaAt(num);340 }341 const cs = squash(d.callSign);342 if (cs && ([...cs].length > MAX_CALL || !CALL_RE.test(cs))) p.meta = `The call sign is a short label of up to ${MAX_CALL} letters and digits, like TOON.`;343 else if ([...squash(d.description)].length > MAX_DESC) p.meta = `Keep the description to ${MAX_DESC.toLocaleString()} characters.`;344 const name = d.name.trim();345 if (!name) p.name = 'Give the channel a name.';346 else if (/[\/\\]/.test(name) || name.startsWith('.')) { p.name = 'Leave slashes out of the name, and don\'t start it with a dot.'; p.badName = true; }347 if (d.kind === 'youtube') p.yt = ytProblems(d);348 p.sched = schedulable(d) ? schedProblem(d) : '';349 return p;350 }351352 // ---------- Boot and state ----------353 function normalizeState(st) {354 st = st || {};355 st.account = st.account || { server: '', user: '', hasPassword: false, ok: false, error: '' };356 st.channels = Array.isArray(st.channels) ? st.channels : [];357 st.channels.sort((a, b) => cmpNum(a.number, b.number));358 st.categories = Array.isArray(st.categories) && st.categories.length ? st.categories : CATEGORIES;359 st.antenna = Array.isArray(st.antenna) ? st.antenna : [];360 return st;361 }362363 async function boot() {364 S.stateErr = null;365 renderChannels();366 try {367 S.state = normalizeState(await api('GET', '/state'));368 } catch (e) {369 S.stateErr = e;370 renderChannels();371 $('#main').replaceChildren(h('div', { class: 'empty' },372 h('p', { class: 'empty-line err' }, "Can't load the channels."),373 h('p', { class: 'help' }, e.message),374 h('button', { class: 'btn', type: 'button', onclick: boot }, 'Try again')));375 return;376 }377 renderAccount();378 renderListings();379 let t = hashTarget();380 if (!t) {381 // The first channel there is to edit; Jellyfin ones once there's an account.382 const first = S.state.channels.find((c) => (c.kind === 'jellyfin' && S.state.account.server) || c.kind === 'youtube');383 if (first) { t = first.number; history.replaceState(null, '', '#' + enc(t)); }384 }385 openTarget(t);386 if (!boot.timers) {387 boot.timers = true;388 setInterval(refreshState, 60000);389 setInterval(tick, 20000);390 document.addEventListener('visibilitychange', () => { if (!document.hidden && S.state) refreshState(); });391 }392 }393394 let stateReq = null;395 function refreshState() {396 if (stateReq) return stateReq;397 stateReq = (async () => {398 try {399 const prevAcct = JSON.stringify(S.state && S.state.account);400 const prevLs = JSON.stringify(S.state && S.state.listings);401 const prevMissing = JSON.stringify(S.ch && S.ch.unmatched);402 S.state = normalizeState(await api('GET', '/state'));403 S.stateErr = null;404 if (S.origNumber && !isNew()) S.ch = findCh(S.origNumber) || S.ch;405 else if (S.sel && !isNewTarget(S.sel)) S.ch = findCh(S.sel);406 renderChannels();407 if (JSON.stringify(S.state.account) !== prevAcct && !S.qc && !acctFormInUse()) renderAccount();408 if (JSON.stringify(S.state.listings) !== prevLs && !lsFormInUse()) renderListings();409 if (E.yt) {410 ytStateChanged();411 } else if (E.kicker) {412 updateKicker();413 if (E.grid && JSON.stringify(S.ch && S.ch.unmatched) !== prevMissing) renderPicks();414 } else if (S.ch && !isNewTarget(S.sel)) {415 renderDetail(S.ch);416 }417 if (E.logo) { renderLogo(); updateMeta(); }418 } catch {419 // Keep showing what we have; the next refresh tries again.420 } finally {421 stateReq = null;422 }423 })();424 return stateReq;425 }426427 // refreshFromElsewhere takes in a change made outside the page (an agent's tool, another tab):428 // the state again, the open channel followed to a new number, and its editor rebuilt from what's429 // saved, unless it has changes of its own, which are kept. hint is { from, to }, the numbers a430 // change moved a channel between, when known; without one, a renamed channel is found as the one431 // of its kind that appeared as it went.432 let elsewhereReq = Promise.resolve();433 function refreshFromElsewhere(hint) {434 elsewhereReq = elsewhereReq.then(() => followElsewhere(hint || {}), () => followElsewhere(hint || {}));435 return elsewhereReq;436 }437438 async function followElsewhere(hint) {439 if (!S.state) return refreshState();440 await stateReq;441 const before = S.state.channels;442 const open = S.origNumber && !isNew() ? S.origNumber : S.sel && !isNewTarget(S.sel) ? S.sel : null;443 const was = open ? before.find((c) => c.number === open) : null;444 let st;445 try {446 st = normalizeState(await api('GET', '/state'));447 } catch {448 return; // the next refresh tries again449 }450 const prevAcct = JSON.stringify(S.state.account);451 S.state = st;452 S.stateErr = null;453 if (JSON.stringify(st.account) !== prevAcct && !S.qc && !acctFormInUse()) renderAccount();454 if (!open) {455 renderChannels();456 if (!S.sel) renderEmpty(null);457 if (E.save) updateDirty(); // a new channel's number may be taken now458 return;459 }460 let now = findCh(open) ? open : null;461 if (!now && was) now = movedTo(was, before, hint);462 if (!now) {463 // Gone: deleted elsewhere, or a number the page didn't know yet.464 if (!was) { renderChannels(); if (findCh(open)) openTarget(open); return; }465 S.draft = null;466 history.replaceState(null, '', hashFor(null));467 openTarget(null);468 toast(`${was.number} ${was.name} was deleted elsewhere`);469 return;470 }471 if (now !== open) history.replaceState(null, '', hashFor(now));472 if (S.draft && isDirty()) {473 // Keep the edits, over what was saved elsewhere: what wasn't edited474 // here (the number, say) takes the new value, and the editor is built475 // again from the result, saving to where the channel is now.476 const ch = findCh(now);477 const old = draftFor(was), fresh = draftFor(ch), d = S.draft;478 for (const k of Object.keys(fresh)) if (sameValue(d[k], old[k])) d[k] = fresh[k];479 S.base = sig(fresh);480 S.origNumber = now;481 S.sel = now;482 S.ch = ch;483 if (d.kind === 'youtube') buildYtEditor();484 else if (d.kind === 'jellyfin') buildEditor();485 else buildMetaEditor();486 renderChannels();487 setTitle();488 loadSchedule(++S.schedToken);489 toast(now !== open ? `${open} moved to ${now} elsewhere; your unsaved changes are kept` : 'Changed elsewhere; your unsaved changes are kept');490 return;491 }492 const tab = S.tab;493 openTarget(now);494 if (E.tabs && tab && S.draft && S.draft.kind === 'jellyfin') setTab(tab);495 }496497 // draftFor is the draft a saved channel opens with.498 const draftFor = (ch) => (ch.kind === 'youtube' ? ytDraftFrom(ch) : ch.kind === 'jellyfin' ? draftFrom(ch) : metaDraftFrom(ch));499 const sameValue = (a, b) => JSON.stringify(a instanceof Set ? [...a].sort() : a) === JSON.stringify(b instanceof Set ? [...b].sort() : b);500501 // movedTo finds where a channel went: the hint's number, or the one channel of its kind that's502 // new to the state (the weather channel by its kind alone).503 function movedTo(was, before, hint) {504 if (hint.to && hint.to !== was.number && (!hint.from || hint.from === was.number) && findCh(hint.to)) return hint.to;505 const known = new Set(before.map((c) => c.number));506 const fresh = S.state.channels.filter((c) => c.kind === was.kind && (was.kind === 'weather' || !known.has(c.number)));507 if (fresh.length === 1) return fresh[0].number;508 const named = fresh.filter((c) => c.name === was.name);509 return named.length === 1 ? named[0].number : null;510 }511512 // For the agent tools' page script (webmcp.js), and anything else that changes channels.513 window.airwavesAdmin = { refresh: refreshFromElsewhere };514515 function tick() {516 let ended = false;517 for (const i of document.querySelectorAll('.ch-prog i')) {518 const now = { start: i.dataset.start, end: i.dataset.end };519 i.style.width = pct(now) + '%';520 if (Date.parse(now.end) <= Date.now()) ended = true;521 }522 if (ended) refreshState();523 if (E.sched) renderSchedule();524 }525526 // ---------- Navigation ----------527 function hashTarget() {528 try { return decodeURIComponent(location.hash.slice(1)) || null; } catch { return null; }529 }530 const hashFor = (t) => (t ? '#' + enc(t) : location.pathname + location.search);531532 async function leaveOK() {533 if (!isDirty()) return true;534 const what = isNew() ? 'the new channel' : `${S.ch ? S.ch.number + ' ' + S.ch.name : S.origNumber}`;535 return confirmDialog({536 title: 'Discard changes?',537 body: `Your changes to ${what} haven't been saved.`,538 ok: 'Discard changes', cancel: 'Keep editing', danger: true,539 });540 }541542 async function go(t) {543 if (t === S.sel) return;544 if (!(await leaveOK())) return;545 history.pushState(null, '', hashFor(t));546 openTarget(t);547 }548549 function linkNav(e) {550 if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;551 e.preventDefault();552 go(e.currentTarget.dataset.number);553 }554555 window.addEventListener('popstate', async () => {556 if (!S.state) return;557 const t = hashTarget();558 if (t === S.sel) return;559 if (!(await leaveOK())) { history.pushState(null, '', hashFor(S.sel)); return; }560 openTarget(t);561 });562563 window.addEventListener('beforeunload', (e) => {564 if (isDirty()) { e.preventDefault(); e.returnValue = ''; }565 });566567 function openTarget(t) {568 S.schedToken++;569 S.refreshing = false;570 S.sel = t;571 E = {};572 S.view = null;573 S.touched = {}; S.serverErr = {}; S.saveMsg = null; S.saving = false;574 S.detailSig = '';575 S.logoMsg = false; S.logoBusy = '';576 clearTimeout(S.yt.timer);577 S.yt.view = null;578 S.yt.wasBusy = false;579 if (t === 'new') {580 S.ch = null;581 S.origNumber = null;582 S.draft = null;583 renderChooser();584 } else if (t === 'new-jellyfin') {585 S.ch = null;586 S.origNumber = 'new';587 S.draft = draftFrom(null);588 S.base = sig(S.draft);589 S.filters = newFilters();590 buildEditor();591 } else if (t === 'new-youtube') {592 S.ch = null;593 S.origNumber = 'new';594 S.draft = ytDraftFrom(null);595 S.base = sig(S.draft);596 buildYtEditor();597 } else if (t === 'backups') {598 S.ch = null;599 S.origNumber = null;600 S.draft = null;601 renderBackupsPage();602 } else {603 const ch = t ? findCh(t) : null;604 S.ch = ch;605 if (ch && ch.kind === 'jellyfin') {606 S.origNumber = ch.number;607 S.draft = draftFrom(ch);608 S.base = sig(S.draft);609 S.filters = newFilters();610 S.tab = KINDS.find((k) => S.draft[k].size) || 'series';611 buildEditor();612 loadSchedule(S.schedToken);613 } else if (ch && ch.kind === 'youtube') {614 S.origNumber = ch.number;615 S.draft = ytDraftFrom(ch);616 S.base = sig(S.draft);617 S.yt.wasBusy = ytBusy(ch);618 buildYtEditor();619 loadSchedule(S.schedToken);620 ytWatch();621 } else if (ch && (ch.kind === 'folder' || ch.kind === 'weather')) {622 S.origNumber = ch.number;623 S.draft = metaDraftFrom(ch);624 S.base = sig(S.draft);625 buildMetaEditor();626 loadSchedule(S.schedToken);627 } else {628 S.origNumber = null;629 S.draft = null;630 if (ch) renderDetail(ch);631 else renderEmpty(t);632 }633 }634 renderChannels();635 setTitle();636 }637638 function setTitle() {639 let t = 'Airwaves channels';640 if (isNewTarget(S.sel)) t = 'New channel - ' + t;641 else if (S.sel === 'backups') t = 'Backups - ' + t;642 else if (S.ch) t = `${S.ch.number} ${S.ch.name} - ${t}`;643 document.title = t;644 }645646 // ---------- Side: channel list ----------647 function renderChannels() {648 renderBackupLine();649 const nav = $('#channels');650 if (!S.state) {651 if (S.stateErr) {652 nav.replaceChildren(h('div', { class: 'side-msg err' }, "Can't load the channels: ", S.stateErr.message));653 } else {654 nav.replaceChildren(h('p', { class: 'side-msg' }, 'Loading channels'));655 }656 return;657 }658 const focused = document.activeElement && document.activeElement.closest && document.activeElement.closest('#channels [data-number]');659 const focusNum = focused ? focused.dataset.number : null;660 const items = S.state.channels.map(chItem);661 if (isNewTarget(S.sel) && S.draft) items.push(newDraftItem());662 else {663 items.push(h('a', { class: 'ch-add', href: '#new', 'data-number': 'new', 'aria-current': S.sel === 'new' ? 'page' : null, onclick: linkNav },664 h('b', { 'aria-hidden': 'true' }, '+'), 'New channel'));665 }666 nav.replaceChildren(667 h('div', { class: 'chlist-head' }, h('span', { class: 'label' }, 'Lineup'),668 h('span', { class: 'label', text: plural(S.state.channels.length, 'channel', 'channels') })),669 ...items);670 if (focusNum) {671 const el = nav.querySelector(`[data-number="${CSS.escape(focusNum)}"]`);672 if (el) el.focus();673 }674 }675676 function chItem(c) {677 const cur = c.number === S.sel;678 const meta = [h('span', { class: 'kind k-' + c.kind, text: CHANNEL_KIND[c.kind] || c.kind })];679 if (c.enabled === false) meta.push(h('span', { class: 'off', title: 'Off the air: not in the lineup or the app', text: 'off' }));680 if (c.antenna) meta.push(h('span', { class: 'ant', title: `Also ${c.number} ${c.antenna} on the antenna; this channel takes its place for HDHomeRun players`, text: `antenna ${c.antenna}` }));681 if (c.kind === 'folder') meta.push(h('span', { text: plural(c.videos || 0, 'video', 'videos') }));682 if (c.kind === 'jellyfin') {683 if (c.items == null && !c.error) meta.push(h('span', { class: 'wait', text: 'loading' }));684 else if (c.items != null) meta.push(h('span', { text: plural(c.items, 'video', 'videos') }));685 if (c.unmatched && c.unmatched.length) meta.push(h('span', { class: 'miss', text: `${c.unmatched.length} missing` }));686 }687 if (c.kind === 'youtube') {688 const busy = ytBusy(c);689 if (!busy || c.videos) meta.push(h('span', { text: plural(c.videos || 0, 'video', 'videos') }));690 if (busy) meta.push(h('span', { class: 'wait', text: 'listing' }));691 const failed = (c.sources || []).filter((s) => s.failed).length;692 if (failed) meta.push(h('span', { class: 'miss', text: `${failed} not listed` }));693 }694 const body = [h('span', { class: 'ch-name' }, c.callSign ? h('b', { class: 'ch-cs', text: c.callSign }) : null, c.name), h('span', { class: 'ch-meta' }, meta)];695 if (c.error) {696 body.push(h('span', { class: 'ch-err', title: c.error, text: c.error }));697 } else if (c.now && c.now.title) {698 body.push(h('span', { class: 'ch-now', title: [c.now.title, c.now.subtitle].filter(Boolean).join(': ') },699 c.now.title, c.now.subtitle ? h('em', null, ', ' + c.now.subtitle) : null));700 if (c.now.start && c.now.end) {701 body.push(h('span', { class: 'ch-prog', 'aria-hidden': 'true' },702 h('i', { style: { width: pct(c.now) + '%' }, 'data-start': c.now.start, 'data-end': c.now.end })));703 }704 }705 return h('a', {706 class: 'ch' + (cur && isDirty() ? ' dirty' : '') + (c.enabled === false ? ' is-off' : ''), href: '#' + enc(c.number), 'data-number': c.number,707 'aria-current': cur ? 'page' : null, onclick: linkNav,708 },709 h('span', { class: 'ch-num', text: c.number }),710 h('span', { class: 'ch-body' }, body),711 h('span', { class: 'ch-dirty', title: 'Unsaved changes' }));712 }713714 function newDraftItem() {715 return h('a', { class: 'ch', href: '#' + S.sel, 'data-number': S.sel, 'aria-current': 'page', onclick: linkNav },716 h('span', { class: 'ch-num', text: S.draft.number.trim() || '?' }),717 h('span', { class: 'ch-body' },718 h('span', { class: 'ch-name', text: S.draft.name.trim() || 'New channel' }),719 h('span', { class: 'ch-meta' }, h('span', { class: 'kind k-new' }, 'Not created yet'))));720 }721722 $('#channels').addEventListener('keydown', (e) => {723 if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;724 const links = [...$('#channels').querySelectorAll('a[data-number]')];725 const i = links.indexOf(document.activeElement);726 if (i < 0) return;727 e.preventDefault();728 const n = links[Math.max(0, Math.min(links.length - 1, i + (e.key === 'ArrowDown' ? 1 : -1)))];729 n.focus();730 });731732 // ---------- Side: guide listings ----------733 // The guide's Schedules Direct account: a line while it works, the form734 // with none, when it stopped, or on Change. An update is followed every735 // few seconds until it's done.736 function lsFormInUse() {737 const box = $('#listings');738 return box.contains(document.activeElement) && isTyping(document.activeElement);739 }740741 const lsDate = (t) => new Date(t).toLocaleDateString([], { month: 'short', day: 'numeric' });742743 function renderListings() {744 const box = $('#listings');745 const l = S.state && S.state.listings;746 box.hidden = !l;747 if (!l) { box.replaceChildren(); return; }748 const hadFocus = box.contains(document.activeElement);749 const configured = !!l.user;750 if (l.updating) followListings();751 if (configured && !l.stopped && !S.lsOpen) {752 const what = l.updating ? 'Fetching the listings'753 : l.ok ? `${l.stations} stations through ${lsDate(l.through)}`754 : l.error ? `Not updated: ${l.error}` : 'Waiting for the first update';755 const dot = l.ok && !l.updating ? '' : l.error && !l.updating ? ' bad' : ' wait';756 box.replaceChildren(h('div', { class: 'acct-line' },757 h('span', { class: 'dot' + dot, 'aria-hidden': 'true' }),758 h('span', { class: 'who' },759 h('b', { text: 'Schedules Direct' }),760 h('span', { text: `${l.user} | ${what}`, title: l.expires ? `Account until ${lsDate(l.expires)}` : '' })),761 h('button', { class: 'btn ghost small', type: 'button', id: 'ls-change', onclick: () => { S.lsOpen = true; renderListings(); $('#ls-user').focus(); } }, 'Change')));762 if (hadFocus) $('#ls-change').focus();763 return;764 }765 let cls = 'acct-box', title = 'Guide listings', lead;766 if (!configured) {767 cls += ' setup';768 lead = h('p', { class: 'lead' }, "Sign in to Schedules Direct for two weeks of listings, with cast, first air dates and ratings. Until then the guide uses Gracenote's public listings.");769 } else if (l.stopped) {770 cls += ' fail';771 title = 'Listings stopped';772 lead = h('p', { class: 'lead err' }, h('b', { text: l.stopped }), ' Nothing more is asked of Schedules Direct until you save the account again.');773 } else {774 lead = h('p', { class: 'lead' }, `Signed in to Schedules Direct as ${l.user}.`);775 }776 const msg = h('p', { class: 'acct-msg' + (S.lsMsg ? ' ' + S.lsMsg.cls : ''), role: 'status', text: S.lsMsg ? S.lsMsg.text : '' });777 const form = h('form', { class: 'acct-form', onsubmit: saveListings },778 h('label', { class: 'field' }, h('span', null, 'User name'),779 h('input', { class: 'input', name: 'user', id: 'ls-user', value: l.user || '', autocomplete: 'username', spellcheck: 'false', autocapitalize: 'off' })),780 h('label', { class: 'field' }, h('span', null, 'Password'),781 h('input', { class: 'input', name: 'password', type: 'password', autocomplete: 'current-password', placeholder: configured ? 'saved' : '' })),782 configured ? h('p', { class: 'help' }, 'Leave the password empty to keep the saved one. Only its SHA-1, which Schedules Direct takes, is kept on the server.')783 : h('p', { class: 'help' }, 'Only the password\'s SHA-1, which Schedules Direct takes, is kept on the server.'),784 h('div', { class: 'acct-actions' },785 h('button', { class: 'btn', type: 'submit', id: 'ls-submit', disabled: S.lsBusy }, S.lsBusy ? 'Saving' : 'Save and fetch'),786 configured && !l.stopped ? h('button', { class: 'btn ghost', type: 'button', onclick: () => { S.lsOpen = false; S.lsMsg = null; renderListings(); } }, 'Cancel') : null,787 configured ? h('button', { class: 'btn ghost danger', type: 'button', onclick: removeListings }, S.lsRemove ? 'Press again to remove' : 'Remove') : null),788 msg);789 box.replaceChildren(h('div', { class: cls }, h('h2', null, title), lead, form));790 if (hadFocus) {791 const el = box.querySelector('#ls-submit:not([disabled]), input');792 if (el) el.focus();793 }794 }795796 async function saveListings(e) {797 e.preventDefault();798 const f = e.currentTarget;799 const body = { user: f.user.value.trim(), password: f.password.value };800 if (!body.user) { S.lsMsg = { text: 'Enter the Schedules Direct user name.', cls: 'err' }; renderListings(); return; }801 S.lsBusy = true;802 renderListings();803 try {804 S.state.listings = await api('PUT', '/listings', body);805 S.lsOpen = false;806 S.lsMsg = null;807 toast('Saved. Fetching two weeks of listings from Schedules Direct.');808 } catch (err) {809 S.lsMsg = { text: err.message, cls: 'err' };810 }811 S.lsBusy = false;812 renderListings();813 followListings();814 }815816 async function removeListings() {817 if (!S.lsRemove) {818 S.lsRemove = true;819 renderListings();820 setTimeout(() => { S.lsRemove = false; if (!lsFormInUse()) renderListings(); }, 4000);821 return;822 }823 S.lsRemove = false;824 try {825 S.state.listings = await api('DELETE', '/listings');826 S.lsOpen = false;827 toast("Removed. The guide goes back to Gracenote's public listings.");828 } catch (err) {829 toast(err.message, true);830 }831 renderListings();832 }833834 // followListings reads the listings' state every few seconds while an835 // update runs.836 let lsTimer = 0;837 function followListings() {838 if (lsTimer) return;839 lsTimer = setTimeout(async () => {840 lsTimer = 0;841 try {842 const st = await api('GET', '/state');843 const was = JSON.stringify(S.state.listings);844 S.state.listings = st.listings;845 if (JSON.stringify(st.listings) !== was && !lsFormInUse()) renderListings();846 if (st.listings && (st.listings.updating || (st.listings.user && !st.listings.ok && !st.listings.error && !st.listings.stopped))) followListings();847 } catch {848 followListings();849 }850 }, 3000);851 }852853 // ---------- Side: account ----------854 function acctFormInUse() {855 const box = $('#account');856 return box.contains(document.activeElement) && isTyping(document.activeElement);857 }858859 function renderAccount() {860 const box = $('#account');861 if (!S.state) { box.replaceChildren(); return; }862 const a = S.state.account;863 const configured = !!a.server;864 const failing = configured && !a.ok;865 const open = S.acctOpen || !configured || failing || !!S.qc;866 const hadFocus = box.contains(document.activeElement);867868 if (!open) {869 box.replaceChildren(h('div', { class: 'acct-line' },870 h('span', { class: 'dot', 'aria-hidden': 'true' }),871 h('span', { class: 'who' },872 h('b', { text: a.serverName || hostOf(a.server) }),873 h('span', { text: `${a.user} on ${hostOf(a.server)}` })),874 h('button', { class: 'btn ghost small', type: 'button', id: 'acct-change', onclick: () => openAccount() }, 'Change'),875 S.acctNote ? h('p', { class: 'acct-note', role: 'status', text: S.acctNote }) : null));876 if (hadFocus) $('#acct-change').focus();877 return;878 }879880 const d = S.acctDraft || (S.acctDraft = { server: a.server || '', user: a.user || '' });881 let cls = 'acct-box', title = 'Jellyfin account', lead;882 if (!configured) {883 cls += ' setup';884 title = 'Connect Jellyfin';885 lead = h('p', { class: 'lead' }, 'Sign in to a Jellyfin server to build channels from its library.');886 } else if (failing) {887 cls += ' fail';888 title = "Can't sign in";889 lead = h('p', { class: 'lead err' }, h('b', { text: a.error || 'The saved sign-in stopped working.' }),890 ' Sign in again to keep the Jellyfin channels playing.');891 } else {892 lead = h('p', { class: 'lead' }, `Signed in to ${a.serverName || hostOf(a.server)} as ${a.user}.`);893 }894895 const msg = h('p', { class: 'acct-msg' + (S.acctMsg ? ' ' + S.acctMsg.cls : ''), id: 'acct-msg', role: 'status', text: S.acctMsg ? S.acctMsg.text : '' });896 const cancel = configured && a.ok897 ? h('button', { class: 'btn ghost', type: 'button', onclick: closeAccount }, 'Cancel')898 : null;899900 let body;901 if (S.qc) {902 body = qcView();903 } else {904 const server = h('label', { class: 'field' }, h('span', null, 'Server'),905 h('input', {906 class: 'input', name: 'server', id: 'acct-server', value: d.server, placeholder: 'https://jellyfin.example.com',907 inputmode: 'url', autocomplete: 'url', spellcheck: 'false', autocapitalize: 'off',908 oninput: (e) => { d.server = e.target.value; },909 }));910 if (S.acctMode === 'code') {911 body = h('form', { class: 'acct-form', onsubmit: (e) => { e.preventDefault(); startQC(); } },912 server,913 h('div', { class: 'acct-actions' },914 h('button', { class: 'btn', type: 'submit', id: 'acct-submit', disabled: S.acctBusy }, S.acctBusy ? 'Getting a code' : 'Sign in with a code'),915 cancel),916 msg,917 h('p', { class: 'help' }, 'Or ', h('button', { class: 'linkish', type: 'button', onclick: () => setAcctMode('password') }, 'use a password instead'), '.'));918 } else {919 body = h('form', { class: 'acct-form', onsubmit: saveAccount },920 server,921 h('label', { class: 'field' }, h('span', null, 'User'),922 h('input', {923 class: 'input', name: 'user', id: 'acct-user', value: d.user, autocomplete: 'username', spellcheck: 'false', autocapitalize: 'off',924 oninput: (e) => { d.user = e.target.value; },925 })),926 h('label', { class: 'field' }, h('span', null, 'Password'),927 h('input', {928 class: 'input', name: 'password', id: 'acct-password', type: 'password', autocomplete: 'current-password',929 placeholder: a.hasPassword ? 'saved' : '', 'aria-describedby': a.hasPassword ? 'acct-pw-help' : null,930 })),931 a.hasPassword ? h('p', { class: 'help', id: 'acct-pw-help' }, 'Leave it empty to keep the saved one.') : null,932 h('div', { class: 'acct-actions' },933 h('button', { class: 'btn', type: 'submit', id: 'acct-submit', disabled: S.acctBusy }, S.acctBusy ? 'Testing' : 'Save and test'),934 cancel),935 msg,936 h('p', { class: 'help' }, 'Or ', h('button', { class: 'linkish', type: 'button', onclick: () => setAcctMode('code') }, 'sign in with a code'), '.'));937 }938 }939 box.replaceChildren(h('div', { class: cls }, h('h2', null, title), lead, body));940 if (hadFocus) {941 const f = box.querySelector('#qc-code, #acct-submit:not([disabled]), input');942 if (f) f.focus();943 }944 }945946 function qcView() {947 const q = S.qc;948 const kids = [949 h('span', { class: 'label' }, `Code for ${hostOf(q.server)}`),950 h('div', { class: 'qc-code', id: 'qc-code', tabindex: '-1', 'aria-label': 'Code ' + q.code.split('').join(' ') }, q.code),951 h('p', { class: 'help' }, "In a Jellyfin app you're signed in to, open Quick Connect (in your user settings) and enter this code."),952 ];953 const actions = [];954 if (q.state === 'waiting') {955 kids.push(h('p', { class: 'qc-wait', role: 'status' }, 'Waiting for approval'));956 } else if (q.state === 'expired') {957 kids.push(h('p', { class: 'acct-msg err', role: 'status' }, 'That code expired.'));958 actions.push(h('button', { class: 'btn', type: 'button', onclick: () => startQC(q.server) }, 'Get a new code'));959 } else {960 kids.push(h('p', { class: 'acct-msg err', role: 'status' }, `Lost track of the sign-in: ${q.error}`));961 actions.push(h('button', { class: 'btn', type: 'button', onclick: () => startQC(q.server) }, 'Get a new code'));962 }963 actions.push(h('button', { class: 'btn ghost', type: 'button', onclick: cancelQC }, 'Cancel'));964 return h('div', { class: 'qc' + (q.state === 'waiting' ? '' : ' stale') }, kids, h('div', { class: 'acct-actions' }, actions));965 }966967 function openAccount(mode) {968 S.acctOpen = true;969 if (mode) S.acctMode = mode;970 S.acctNote = '';971 renderAccount();972 const el = $('#account input, #account button');973 if (el) { el.focus(); $('#account').scrollIntoView({ block: 'nearest' }); }974 }975 function closeAccount() {976 S.acctOpen = false; S.acctDraft = null; S.acctMsg = null;977 cancelQC(true);978 renderAccount();979 }980 function setAcctMode(m) {981 S.acctMode = m; S.acctMsg = null;982 renderAccount();983 const el = m === 'password' ? ($('#acct-server').value ? $('#acct-user') : $('#acct-server')) : $('#acct-submit');984 if (el) el.focus();985 }986 function setAcctMsg(text, cls) {987 S.acctMsg = text ? { text, cls } : null;988 const m = $('#acct-msg');989 if (m) { m.textContent = text || ''; m.className = 'acct-msg' + (cls ? ' ' + cls : ''); }990 }991992 async function startQC(server) {993 server = (server || (S.acctDraft && S.acctDraft.server) || '').trim();994 if (!server) {995 setAcctMsg('Enter the server address first.', 'err');996 const f = $('#acct-server');997 if (f) f.focus();998 return;999 }1000 S.acctBusy = true;1001 S.acctMsg = null;1002 if (!S.qc) renderAccount();1003 try {1004 const r = await api('POST', '/account/quickconnect', { server });1005 S.qc = { id: r.id, code: String(r.code), server, state: 'waiting', fails: 0, token: ++S.qcToken };1006 if (S.acctDraft) S.acctDraft.server = server;1007 S.acctBusy = false;1008 renderAccount();1009 const c = $('#qc-code');1010 if (c) c.focus();1011 pollQC(S.qc.token);1012 } catch (e) {1013 S.acctBusy = false;1014 S.qc = null;1015 S.acctMsg = { text: e.message, cls: 'err' };1016 renderAccount();1017 }1018 }10191020 function pollQC(token) {1021 setTimeout(async () => {1022 const q = S.qc;1023 if (!q || q.token !== token || q.state !== 'waiting') return;1024 try {1025 const r = await api('GET', `/account/quickconnect/${enc(q.id)}`);1026 if (S.qc !== q) return;1027 q.fails = 0;1028 if (r && r.state === 'done' && r.account) { accountSaved(r.account); return; }1029 if (r && r.state === 'expired') { q.state = 'expired'; renderAccount(); return; }1030 pollQC(token);1031 } catch (e) {1032 if (S.qc !== q) return;1033 if (e.status === 404 || e.status === 410) { q.state = 'expired'; renderAccount(); return; }1034 if (++q.fails >= 4) { q.state = 'error'; q.error = e.message; renderAccount(); return; }1035 pollQC(token);1036 }1037 }, 2000);1038 }10391040 function cancelQC(quiet) {1041 S.qc = null;1042 S.qcToken++;1043 if (quiet !== true) renderAccount();1044 }10451046 async function saveAccount(e) {1047 e.preventDefault();1048 const f = e.currentTarget;1049 const body = { server: f.server.value.trim(), user: f.user.value.trim(), password: f.password.value };1050 if (!body.server || !body.user) {1051 setAcctMsg(!body.server ? 'Enter the server address.' : 'Enter the user name.', 'err');1052 (body.server ? f.user : f.server).focus();1053 return;1054 }1055 S.acctBusy = true;1056 const btn = $('#acct-submit');1057 btn.disabled = true;1058 btn.textContent = 'Testing';1059 setAcctMsg('Signing in to ' + hostOf(body.server), '');1060 try {1061 const acct = await api('PUT', '/account', body);1062 S.acctBusy = false;1063 accountSaved(acct);1064 } catch (err) {1065 S.acctBusy = false;1066 btn.disabled = false;1067 btn.textContent = 'Save and test';1068 setAcctMsg(err.message, 'err');1069 }1070 }10711072 let noteTimer = 0;1073 function accountSaved(acct) {1074 const before = S.state.account;1075 S.state.account = acct || before;1076 S.qc = null;1077 S.qcToken++;1078 if (acct && acct.ok) {1079 S.acctOpen = false;1080 S.acctDraft = null;1081 S.acctMsg = null;1082 S.acctNote = `Signed in to ${acct.serverName || hostOf(acct.server)} as ${acct.user}.`;1083 clearTimeout(noteTimer);1084 noteTimer = setTimeout(() => { S.acctNote = ''; if (!S.acctOpen && !S.qc) renderAccount(); }, 15000);1085 renderAccount();1086 toast(S.acctNote);1087 // A different server or user means a different library.1088 S.lib = {};1089 if (E.grid) { loadLib(S.tab); renderFilters(); renderGrid(); updateLibStatus(); renderPicks(); }1090 else if (!S.sel) renderEmpty(null);1091 refreshState();1092 } else {1093 // The box headline already shows the account's error; only fill in when there isn't one.1094 S.acctMsg = acct && acct.error ? null : { text: "Couldn't sign in.", cls: 'err' };1095 renderAccount();1096 }1097 }10981099 // ---------- Main: empty and read-only views ----------1100 function renderEmpty(missing) {1101 const a = S.state.account;1102 const kids = [];1103 if (missing) {1104 kids.push(h('p', { class: 'empty-line' }, `There's no channel ${missing} in the lineup.`));1105 } else if (!a.server) {1106 kids.push(h('p', { class: 'empty-line' }, 'Connect a Jellyfin account, then build channels from its library.'),1107 h('p', { class: 'help' }, 'Sign in on the left. A Quick Connect code is the fastest way. Channels of YouTube uploads need no account.'));1108 } else if (!S.state.channels.some((c) => c.kind === 'jellyfin' || c.kind === 'youtube')) {1109 kids.push(h('p', { class: 'empty-line' }, `No channels of your own yet. Make one from YouTube channels or ${serverLabel()}'s library.`));1110 } else {1111 kids.push(h('p', { class: 'empty-line' }, 'Pick a channel on the left, or make a new one.'));1112 }1113 kids.push(!a.server && !missing1114 ? h('div', { class: 'row' },1115 h('button', { class: 'btn', type: 'button', onclick: () => openAccount() }, 'Sign in'),1116 h('button', { class: 'btn ghost', type: 'button', onclick: () => go('new-youtube') }, 'New YouTube channel'))1117 : h('button', { class: 'btn', type: 'button', onclick: () => go('new') }, 'New channel'));1118 $('#main').replaceChildren(h('div', { class: 'empty' }, kids));1119 }11201121 // New channel: which kind.1122 function renderChooser() {1123 const a = S.state.account;1124 const card = (t, kind, title, body, note) => h('a', { class: 'kindcard', href: '#' + t, 'data-number': t, onclick: linkNav },1125 h('span', { class: 'kind k-' + kind, text: CHANNEL_KIND[kind] }),1126 h('b', null, title), h('p', null, body), note ? h('small', null, note) : null);1127 $('#main').replaceChildren(h('div', { class: 'chooser' },1128 h('h1', null, 'New channel'),1129 h('p', { class: 'help' }, 'What should it play?'),1130 h('div', { class: 'kinds' },1131 card('new-youtube', 'youtube', 'Uploads from YouTube channels',1132 'Pick one or more channels, like Boiler Room for DJ sets. Their uploads take turns, and new ones air first. Or play a playlist in order.',1133 'Streams from YouTube as it airs; nothing is downloaded.'),1134 card('new-jellyfin', 'jellyfin', `From ${a.server ? serverLabel() + "'s" : 'a Jellyfin'} library`,1135 'Series, movies, collections and playlists, shuffled or in order.',1136 a.server ? '' : 'Needs a Jellyfin account; connect one on the left.'))));1137 const first = $('#main .kindcard');1138 if (first) first.focus();1139 }11401141 // A channel of a kind this page doesn't know.1142 function renderDetail(ch) {1143 const sigNow = JSON.stringify(ch);1144 if (sigNow === S.detailSig) return;1145 S.detailSig = sigNow;1146 $('#main').replaceChildren(h('article', { class: 'detail' },1147 h('header', { class: 'detail-head' },1148 h('div', { class: 'detail-num', text: ch.number }),1149 h('h1', { class: 'detail-name', text: ch.name }),1150 h('div', { class: 'detail-kicker' }, h('span', { class: 'kind k-' + ch.kind, text: CHANNEL_KIND[ch.kind] || ch.kind }))),1151 h('p', null, 'This channel has nothing to edit here.')));1152 }11531154 // ---------- Main: Jellyfin editor ----------1155 function radios(name, opts, cur, onchange, label) {1156 return h('div', { class: 'seg', role: 'radiogroup', 'aria-label': label },1157 opts.map((o) => h('label', null,1158 h('input', { type: 'radio', name, value: String(o.v), checked: o.v === cur, onchange }),1159 h('span', { text: o.label }))));1160 }1161 function bitrateOpts(cur) {1162 const list = [...BITRATES];1163 if (cur && !list.includes(cur)) list.push(cur);1164 list.sort((a, b) => a - b);1165 return list.map((v) => ({ v, label: v ? `${v} Mbps` : 'Original' }));1166 }11671168 // The number, name, actions and kicker both editors share.1169 function editorHead() {1170 const d = S.draft;1171 const fresh = isNew();1172 E.num = h('input', {1173 class: 'in-num', id: 'f-number', value: d.number, autocomplete: 'off', spellcheck: 'false', inputmode: 'decimal',1174 'aria-describedby': 'f-number-err', oninput: onField, onblur: () => { S.touched.number = true; updateDirty(); },1175 });1176 E.name = h('input', {1177 class: 'in-name', id: 'f-name', value: d.name, placeholder: 'Name this channel', autocomplete: 'off', maxlength: '48',1178 'aria-describedby': 'f-name-err', oninput: onField, onblur: () => { S.touched.name = true; updateDirty(); },1179 });1180 E.numErr = h('p', { class: 'f-err', id: 'f-number-err' });1181 E.nameErr = h('p', { class: 'f-err', id: 'f-name-err' });1182 E.status = h('span', { class: 'ed-status', role: 'status' });1183 E.del = h('button', { class: 'btn ghost danger', type: 'button', hidden: fresh || d.kind === 'folder' || d.kind === 'weather', onclick: deleteChannel }, 'Delete');1184 E.save = h('button', { class: 'btn', type: 'button', onclick: save, 'aria-keyshortcuts': 'Meta+S Control+S' }, fresh ? 'Create channel' : 'Save changes');1185 E.kicker = h('div', { class: 'ed-kicker' });11861187 return h('header', { class: 'ed-head' },1188 h('div', { class: 'f-num' }, h('label', { class: 'label', for: 'f-number' }, 'Number'), E.num, E.numErr),1189 h('div', { class: 'f-name' }, h('label', { class: 'label', for: 'f-name' }, 'Name'), E.name, E.nameErr),1190 h('div', { class: 'ed-actions' }, E.status, E.del, E.save),1191 E.kicker);1192 }11931194 // ---------- Main: details (every kind) ----------1195 // Call sign, category, description, on the air, and the logo, at the top of every channel's page.1196 function metaSection() {1197 const d = S.draft;1198 const field = (label, id, input, cls) => h('div', { class: 'mfield ' + cls }, h('label', { class: 'label', for: id }, label), input);1199 E.call = h('input', {1200 class: 'input', id: 'f-call', value: d.callSign, autocomplete: 'off', spellcheck: 'false', maxlength: String(MAX_CALL + 4),1201 placeholder: d.kind === 'weather' ? 'WX' : 'TOON', 'aria-describedby': 'f-meta-err',1202 oninput: (e) => { S.draft.callSign = e.target.value; S.serverErr.meta = ''; changed(); renderLogo(); },1203 });1204 const cats = (S.state && S.state.categories) || CATEGORIES;1205 E.cat = h('select', {1206 class: 'input select', id: 'f-cat', onchange: (e) => { S.draft.category = e.target.value; changed(); },1207 }, cats.map((c) => h('option', { value: c }, c)));1208 E.cat.value = cats.includes(d.category) ? d.category : 'Other';1209 E.on = h('input', {1210 type: 'checkbox', role: 'switch', id: 'f-on', checked: d.enabled, 'aria-label': 'On the air', 'aria-describedby': 'f-on-help',1211 onchange: (e) => { S.draft.enabled = e.target.checked; changed(); updateMeta(); },1212 });1213 E.onHelp = h('span', { class: 'sw-help', id: 'f-on-help' });1214 E.desc = h('textarea', {1215 class: 'input', id: 'f-desc', rows: '2', maxlength: String(MAX_DESC + 50), 'aria-describedby': 'f-meta-err',1216 placeholder: 'What the channel plays. Shown in guides, and when nothing is listed.',1217 oninput: (e) => { S.draft.description = e.target.value; S.serverErr.meta = ''; changed(); fitDesc(); },1218 });1219 E.desc.value = d.description;1220 E.metaErr = h('p', { class: 'f-err meta-err', id: 'f-meta-err', role: 'alert' });1221 E.antenna = h('p', { class: 'meta-warn', id: 'f-antenna', role: 'status' });12221223 E.logo = h('div', {1224 class: 'logo-tile', id: 'logo-tile', role: 'img', title: LOGO_HINT,1225 ondragover: (e) => { if (canLogo()) { e.preventDefault(); E.logo.classList.add('drop'); } },1226 ondragleave: () => E.logo.classList.remove('drop'),1227 ondrop: (e) => {1228 E.logo.classList.remove('drop');1229 if (!canLogo()) return;1230 e.preventDefault();1231 const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];1232 if (f) uploadLogo(f);1233 },1234 });1235 E.logoFile = h('input', {1236 type: 'file', id: 'logo-file', class: 'visually-hidden', accept: LOGO_TYPES.join(','), tabindex: '-1',1237 onchange: (e) => { const f = e.target.files[0]; e.target.value = ''; if (f) uploadLogo(f); },1238 });1239 E.logoUp = h('button', { class: 'btn small ghost', type: 'button', id: 'logo-upload', title: LOGO_HINT, onclick: () => E.logoFile.click() }, 'Upload');1240 E.logoSrc = h('button', { class: 'linkish', type: 'button', id: 'logo-source', onclick: logoFromSource });1241 E.logoDel = h('button', { class: 'linkish', type: 'button', id: 'logo-remove', onclick: removeLogo }, 'Remove');1242 E.logoMsg = h('p', { class: 'logo-msg', role: 'status' });1243 const box = h('div', { class: 'logo-box' }, E.logo,1244 h('div', { class: 'logo-actions' }, h('span', { class: 'label' }, 'Logo'), E.logoUp, E.logoSrc, E.logoDel, E.logoFile, E.logoMsg));12451246 const sec = h('section', { class: 'ed-meta', 'aria-label': 'Channel details' }, box,1247 h('div', { class: 'mfields' },1248 field('Call sign', 'f-call', E.call, 'call'),1249 field('Category', 'f-cat', E.cat, 'cat'),1250 h('div', { class: 'mfield on' }, h('span', { class: 'label' }, 'On the air'),1251 h('label', { class: 'switch' }, E.on, h('i', { 'aria-hidden': 'true' }), E.onHelp)),1252 field('Description', 'f-desc', E.desc, 'desc'),1253 E.metaErr, E.antenna));1254 S.logoBusy = '';1255 renderLogo();1256 updateMeta();1257 requestAnimationFrame(fitDesc);1258 return sec;1259 }12601261 // The description grows to three lines as it's typed.1262 function fitDesc() {1263 if (!E.desc || !E.desc.isConnected) return;1264 E.desc.style.height = 'auto';1265 E.desc.style.height = Math.min(E.desc.scrollHeight + 2, 84) + 'px';1266 }12671268 // The channel's logo as saved; a new channel gets one once it's created.1269 const canLogo = () => !!(S.draft && !isNew() && S.ch && !S.logoBusy);1270 function renderLogo() {1271 if (!E.logo) return;1272 const ch = S.ch, d = S.draft;1273 const label = squash(d.callSign) || squash(d.name) || '?';1274 E.logo.replaceChildren();1275 E.logo.classList.toggle('has', !!(ch && ch.logo));1276 if (ch && ch.logo) {1277 E.logo.append(h('img', { src: ch.logo, alt: '', draggable: 'false', onerror: (e) => e.target.remove() }));1278 E.logo.setAttribute('aria-label', `${ch.name}'s logo`);1279 } else {1280 E.logo.append(h('span', { class: 'ph-call', 'aria-hidden': 'true', text: [...label].slice(0, MAX_CALL).join('') }));1281 E.logo.setAttribute('aria-label', 'No logo yet');1282 }1283 const fresh = isNew(), busy = !!S.logoBusy;1284 E.logoUp.disabled = fresh || busy;1285 E.logoUp.textContent = S.logoBusy === 'upload' ? 'Uploading' : (ch && ch.logo ? 'Replace' : 'Upload');1286 E.logoDel.hidden = fresh || !(ch && ch.logo);1287 E.logoDel.disabled = busy;1288 let src = '';1289 if (!fresh && d.kind === 'youtube' && ch && ch.youtube && ((ch.youtube.channels || []).length || ch.youtube.playlist)) src = 'Use the YouTube avatar';1290 if (!fresh && d.kind === 'jellyfin' && S.state.account.server && ch && ch.config && KINDS.some((k) => (ch.config[k] || []).length)) src = `Use a poster from ${serverLabel()}`;1291 E.logoSrc.hidden = !src;1292 E.logoSrc.disabled = busy;1293 E.logoSrc.textContent = S.logoBusy === 'source' ? 'Fetching the picture' : src;1294 if (fresh) setLogoMsg('Add a logo once the channel is created.');1295 else if (!S.logoMsg) setLogoMsg('');1296 }1297 function setLogoMsg(text, cls) {1298 if (!E.logoMsg) return;1299 E.logoMsg.textContent = text || '';1300 E.logoMsg.className = 'logo-msg' + (cls ? ' ' + cls : '');1301 }13021303 async function logoCall(kind, method, path, body) {1304 if (!canLogo()) return;1305 const num = S.origNumber;1306 S.logoBusy = kind;1307 S.logoMsg = true;1308 setLogoMsg(kind === 'source' ? 'Fetching the picture' : kind === 'upload' ? 'Uploading' : 'Removing', '');1309 renderLogo();1310 try {1311 const ch = await api(method, `/channels/${enc(num)}${path}`, body);1312 S.logoBusy = '';1313 if (S.origNumber !== num || !ch) return;1314 logoSaved(ch);1315 setLogoMsg(kind === 'remove' ? 'Logo removed.' : 'Logo saved.', 'ok');1316 } catch (e) {1317 S.logoBusy = '';1318 if (S.origNumber !== num) return;1319 setLogoMsg(e.message, 'err');1320 }1321 renderLogo();1322 }1323 // The server's answer to a logo change: the channel as saved, so only the logo is taken from it.1324 function logoSaved(ch) {1325 const list = S.state.channels;1326 const i = list.findIndex((c) => c.number === S.origNumber);1327 const merged = Object.assign({}, S.ch, { logo: ch.logo || undefined });1328 if (!ch.logo) delete merged.logo;1329 if (i >= 0) list[i] = merged;1330 S.ch = merged;1331 renderChannels();1332 }1333 function uploadLogo(file) {1334 if (file.type && !LOGO_TYPES.includes(file.type)) { setLogoMsg('The logo should be a PNG, JPEG, SVG or WebP image.', 'err'); S.logoMsg = true; return; }1335 if (file.size > LOGO_MAX) { setLogoMsg('The logo should be under 2 MB.', 'err'); S.logoMsg = true; return; }1336 logoCall('upload', 'PUT', '/logo', file);1337 }1338 const logoFromSource = () => logoCall('source', 'POST', '/logo/source');1339 async function removeLogo() {1340 const ok = await confirmDialog({1341 title: 'Remove the logo?', body: 'Guides show the call sign or name in its place.', ok: 'Remove logo', danger: true,1342 });1343 if (ok) logoCall('remove', 'DELETE', '/logo');1344 }13451346 // What the details say beside the fields: on the air or not, and an antenna channel with the same number.1347 function updateMeta() {1348 if (!E.onHelp) return;1349 const d = S.draft;1350 E.onHelp.textContent = d.enabled ? 'In the lineup and the app' : 'Off: hidden from the lineup and the app';1351 const p = problems();1352 const a = p.antenna;1353 E.antenna.textContent = a1354 ? `${a.number} is also ${a.name} on the antenna. This channel takes its place for HDHomeRun players.`1355 : '';1356 }13571358 function syncMetaInputs() {1359 const d = S.draft;1360 if (E.call.value !== d.callSign) E.call.value = d.callSign;1361 if (E.desc.value !== d.description) E.desc.value = d.description;1362 E.cat.value = d.category;1363 E.on.checked = d.enabled;1364 updateMeta();1365 renderLogo();1366 }13671368 // ---------- Main: when it airs (folder, Jellyfin and YouTube playlist channels) ----------1369 // A channel that plays in order loops around the clock, or airs on a schedule (vchan.Schedule,1370 // in channel.json): from a start date, beginning with one of its videos, in daily blocks of so1371 // many videos or until a time, off the air between.1372 const DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];1373 const DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];1374 const MAX_BLOCKS = 12;1375 const AIRS = [1376 { v: false, label: 'Around the clock' },1377 { v: true, label: 'On a schedule' },1378 ];1379 const schedulable = (d) => !!d && (d.kind === 'folder' || d.kind === 'jellyfin' || (d.kind === 'youtube' && d.ytMode === 'playlist'));1380 const isoDay = (t) => `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, '0')}-${String(t.getDate()).padStart(2, '0')}`;1381 const CLOCK_RE = /^([01]?\d|2[0-3]):([0-5]\d)$/;1382 const minutesOf = (hm) => { const m = CLOCK_RE.exec(hm || ''); return m ? +m[1] * 60 + +m[2] : -1; };13831384 // The draft's schedule from a channel's: null around the clock. Each block keeps both ends as typed.1385 function schedFrom(ch) {1386 const s = ch && ch.schedule;1387 if (!s) return null;1388 const [date, time] = String(s.start || '').split('T');1389 return {1390 start: date || '', time: time || '', first: Math.max(1, Math.floor(Number(s.first) || 1)),1391 blocks: (Array.isArray(s.blocks) ? s.blocks : []).map((b) => {1392 const days = (Array.isArray(b.days) ? b.days : []).map((x) => String(x).trim().toLowerCase());1393 return {1394 days: DAYS.map((x) => !days.length || days.includes(x)),1395 at: b.at || '', end: b.until ? 'until' : 'count', count: b.until ? '4' : String(b.count || ''), until: b.until || '',1396 };1397 }),1398 };1399 }1400 // A draft's schedule as the server takes it: every day is no days, and first 1 is left out.1401 function schedBody(s) {1402 if (!s) return null;1403 const out = { start: s.time ? `${s.start}T${s.time}` : s.start };1404 if (s.first > 1) out.first = s.first;1405 out.blocks = s.blocks.map((b) => {1406 const o = {};1407 if (!b.days.every(Boolean)) o.days = DAYS.filter((_, k) => b.days[k]);1408 o.at = b.at;1409 if (b.end === 'until') o.until = b.until;1410 else o.count = String(b.count).trim() === '' ? 0 : Number(b.count);1411 return o;1412 });1413 return out;1414 }1415 function newSched(d) {1416 const many = schedNoun(d) === 'movie' ? '2' : '4';1417 return { start: isoDay(new Date()), time: '', first: 1, blocks: [{ days: DAYS.map(() => true), at: '20:00', end: 'count', count: many, until: '' }] };1418 }14191420 // What keeps a schedule from saving, as the server would say it (vchan.Schedule.Check), or ''.1421 function schedProblem(d) {1422 const s = d.sched;1423 if (!s) return '';1424 const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s.start || '');1425 const day = m && new Date(+m[1], +m[2] - 1, +m[3]);1426 if (!day || day.getMonth() !== +m[2] - 1) return 'Pick the day it starts.';1427 if (s.time && minutesOf(s.time) < 0) return 'Give the start a time like 20:00, or none.';1428 if (s.blocks.length > MAX_BLOCKS) return `Up to ${MAX_BLOCKS} blocks a day.`;1429 const many = schedNoun(d) + 's';1430 for (const [i, b] of s.blocks.entries()) {1431 const n = s.blocks.length > 1 ? `Block ${i + 1}` : 'The block';1432 if (!b.days.some(Boolean)) return `${n} needs a day to air.`;1433 if (minutesOf(b.at) < 0) return `${n} needs a time to start, like 20:00.`;1434 if (b.end === 'until' && minutesOf(b.until) < 0) return `${n} needs a time to stop, like 23:30.`;1435 const c = Number(b.count);1436 if (b.end !== 'until' && !(Number.isInteger(c) && c >= 1 && c <= 100)) return `${n} airs 1 to 100 ${many}.`;1437 }1438 return '';1439 }14401441 // The videos as listed for this channel, once loaded.1442 const loadedItems = () => (S.items && S.items.number === S.origNumber && S.items.status === 'ok' ? S.items.list : null);1443 function loadItems() {1444 const num = S.origNumber;1445 if (!num || num === 'new' || !E.schedEd || !S.draft || !S.draft.sched) return;1446 if (S.items && S.items.number === num && S.items.status !== 'error') return;1447 S.items = { number: num, status: 'loading', list: [] };1448 renderFirstPicker();1449 api('GET', `/channels/${enc(num)}/items`).then((r) => {1450 if (S.items && S.items.number === num) S.items = { number: num, status: 'ok', list: Array.isArray(r && r.items) ? r.items : [] };1451 }, (e) => {1452 if (S.items && S.items.number === num) S.items = { number: num, status: 'error', list: [], error: e.message };1453 }).then(() => {1454 if (S.origNumber !== num || !E.schFirst) return;1455 renderFirstPicker();1456 updateSched();1457 });1458 }14591460 // What the channel's videos are called: episodes, movies, or videos.1461 function schedNoun(d) {1462 const list = loadedItems();1463 if (list && list.length) {1464 if (list.every((it) => it.episode)) return 'episode';1465 if (d.kind === 'jellyfin' && list.every((it) => !it.episode)) return 'movie';1466 return 'video';1467 }1468 if (d.kind === 'jellyfin') {1469 const only = (k) => d[k] && d[k].size && KINDS.every((o) => o === k || !d[o].size);1470 if (only('series')) return 'episode';1471 if (only('movies')) return 'movie';1472 }1473 return 'video';1474 }1475 // A video as the picker and the summary name it: "S1 E4", with its series when the channel1476 // has more than one, else its title.1477 function itemName(it, oneShow, full) {1478 const se = it.season && it.episode ? `S${it.season} E${it.episode}` : '';1479 if (!se) return it.title || 'Untitled';1480 const head = oneShow ? se : `${it.title} ${se}`;1481 return full && it.subtitle ? `${head}: ${it.subtitle}` : head;1482 }1483 const oneShow = (list) => list.every((it) => it.title === list[0].title);14841485 function fmtClock(hm) {1486 const m = minutesOf(hm);1487 return m < 0 ? '' : fmtTime(new Date(2000, 0, 1, Math.floor(m / 60), m % 60));1488 }1489 function fmtDay(iso) {1490 const [y, mo, d] = iso.split('-').map(Number);1491 const t = new Date(y, mo - 1, d);1492 return t.toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric', year: y === new Date().getFullYear() ? undefined : 'numeric' });1493 }1494 const listJoin = (l) => (l.length < 2 ? l.join('') : `${l.slice(0, -1).join(', ')} and ${l[l.length - 1]}`);1495 function daysPhrase(days, at) {1496 const on = DAYS.filter((_, k) => days[k]);1497 const evening = at >= 17 * 60 || at < 4 * 60;1498 if (on.length === 7) return evening ? 'nightly' : 'daily';1499 if (on.join() === 'mon,tue,wed,thu,fri') return evening ? 'weeknights' : 'weekdays';1500 if (on.join() === 'sat,sun') return 'on weekends';1501 if (on.length === 1) return `on ${DAY_NAMES[DAYS.indexOf(on[0])]}s`;1502 return 'on ' + listJoin(on.map((x) => DAY_NAMES[DAYS.indexOf(x)].slice(0, 3)));1503 }15041505 // The schedule in a line: "4 episodes nightly at 8:00 PM, starting Sun, Oct 4 with S1 E1".1506 function schedSummary(d) {1507 const s = d.sched;1508 if (!s) return 'Loops day and night on a fixed clock; tuning in joins whatever is on.';1509 const noun = schedNoun(d);1510 const list = loadedItems();1511 const it = list && list[s.first - 1];1512 const first = it ? itemName(it, oneShow(list), false) : s.first === 1 ? `the first ${noun}` : `${noun} ${s.first}`;1513 const started = s.start < isoDay(new Date()) ? 'started' : 'starting';1514 const from = `${started} ${fmtDay(s.start)}${s.time ? ' at ' + fmtClock(s.time) : ''} with ${first}`;1515 if (!s.blocks.length) return `Around the clock, ${from}.`;1516 const blocks = s.blocks.map((b) => {1517 const days = daysPhrase(b.days, minutesOf(b.at));1518 return b.end === 'until'1519 ? `${days} from ${fmtClock(b.at)} to ${fmtClock(b.until)}`1520 : `${plural(Number(b.count), noun, noun + 's')} ${days} at ${fmtClock(b.at)}`;1521 });1522 const text = `${blocks.join('; ')}, ${from}.`;1523 return text[0].toUpperCase() + text.slice(1);1524 }15251526 // The Airs row: around the clock or on a schedule, and the schedule's editor. Nothing for a1527 // channel that can't have one.1528 function scheduleSection() {1529 const d = S.draft;1530 if (!schedulable(d)) return null;1531 E.schedSum = h('p', { class: 'sch-sum', id: 'sch-sum', 'aria-live': 'polite' });1532 E.schedEd = h('div', { class: 'sch-ed' });1533 E.schedHint = h('p', { class: 'sch-hint', hidden: true },1534 'Shuffled, its episodes air in no order. For a series, ',1535 h('button', { class: 'linkish', type: 'button', onclick: playInOrder }, 'In order'),1536 ' plays them as they aired.');1537 E.schedErr = h('p', { class: 'f-err sch-err', id: 'sch-err', role: 'alert' });1538 E.schedStash = null;1539 const sec = h('section', { class: 'ed-sched', 'aria-labelledby': 'airs-l' },1540 h('span', { class: 'opt-label', id: 'airs-l' }, 'Airs'),1541 h('div', { class: 'sch-main' },1542 h('div', { class: 'sch-top' }, radios('airs', AIRS, !!d.sched, onAirs, 'When it airs'), E.schedSum),1543 E.schedEd, E.schedHint, E.schedErr));1544 renderSchedEditor();1545 loadItems();1546 return sec;1547 }15481549 function onAirs(e) {1550 const d = S.draft;1551 if (e.target.value === 'true') d.sched = E.schedStash || newSched(d);1552 else { E.schedStash = d.sched; d.sched = null; }1553 S.serverErr.sched = '';1554 renderSchedEditor();1555 loadItems();1556 changed();1557 }1558 function schedChanged() {1559 S.serverErr.sched = '';1560 changed();1561 }1562 function playInOrder() {1563 S.draft.order = 'aired';1564 const r = $('input[name=order][value=aired]');1565 if (r) r.checked = true;1566 if (E.orderHelp) updateHelp();1567 changed();1568 }15691570 // Builds the schedule's fields from the draft, keeping focus on the field that had it.1571 function renderSchedEditor() {1572 if (!E.schedEd) return;1573 const s = S.draft.sched;1574 const focused = document.activeElement && E.schedEd.contains(document.activeElement) ? document.activeElement.id : '';1575 E.schedEd.hidden = !s;1576 if (!s) { E.schedEd.replaceChildren(); E.schFirst = null; updateSched(); return; }1577 const sc = () => S.draft.sched;1578 E.schStart = h('input', {1579 type: 'date', class: 'input sch-date', id: 'sch-start', value: s.start, 'aria-label': 'Starts on', required: true,1580 oninput: (e) => { sc().start = e.target.value; schedChanged(); },1581 });1582 E.schTime = h('input', {1583 type: 'time', class: 'input sch-time', id: 'sch-time', value: s.time, 'aria-label': 'Starts at (optional; empty starts with the first block that day)',1584 title: 'Optional: empty starts with the first block that day', oninput: (e) => { sc().time = e.target.value; schedChanged(); },1585 });1586 E.schFirst = h('select', {1587 class: 'input select sch-first', id: 'sch-first', 'aria-label': 'Begins with',1588 onchange: (e) => { sc().first = Math.max(1, Number(e.target.value) || 1); schedChanged(); },1589 });1590 renderFirstPicker();1591 E.schNouns = [];1592 const rows = s.blocks.map((b, i) => blockRow(b, i));1593 E.schedEd.replaceChildren(1594 h('div', { class: 'sch-line' },1595 h('label', { class: 'sch-l', for: 'sch-start' }, 'Starts'), E.schStart, h('label', { class: 'sch-l', for: 'sch-time' }, 'at'), E.schTime,1596 h('label', { class: 'sch-l', for: 'sch-first' }, 'with'), E.schFirst),1597 rows.length ? h('ol', { class: 'sch-blocks', 'aria-label': 'When it airs each day' }, rows) : null,1598 h('div', { class: 'sch-line' },1599 h('button', { class: 'btn small ghost', type: 'button', id: 'sch-add', disabled: s.blocks.length >= MAX_BLOCKS, onclick: addBlock }, 'Add a block'),1600 h('span', { class: 'help' }, s.blocks.length1601 ? 'Off the air between blocks. A block that runs into the next pushes it back.'1602 : 'With no blocks it airs around the clock from the start.')));1603 if (focused) { const el = document.getElementById(focused); if (el) el.focus(); }1604 updateSched();1605 }16061607 // One daily block: its days, when it starts, and how it ends.1608 function blockRow(b, i) {1609 const n = i + 1;1610 const bk = () => S.draft.sched.blocks[i];1611 const days = h('div', { class: 'sch-days', role: 'group', 'aria-label': `Block ${n}: days` },1612 DAYS.map((x, k) => h('button', {1613 class: 'gchip', type: 'button', id: `sch-${i}-${x}`, 'aria-pressed': b.days[k], title: DAY_NAMES[k] + 's',1614 onclick: (e) => { const on = !bk().days[k]; bk().days[k] = on; e.currentTarget.setAttribute('aria-pressed', String(on)); schedChanged(); },1615 }, DAY_NAMES[k].slice(0, 3))));1616 const at = h('input', {1617 type: 'time', class: 'input sch-time', id: `sch-${i}-at`, value: b.at, 'aria-label': `Block ${n}: starts at`,1618 oninput: (e) => { bk().at = e.target.value; schedChanged(); },1619 });1620 const end = h('select', {1621 class: 'input select sch-end', id: `sch-${i}-end`, 'aria-label': `Block ${n}: ends after a number, or at a time`,1622 onchange: (e) => { bk().end = e.target.value; renderSchedEditor(); schedChanged(); },1623 }, h('option', { value: 'count' }, 'for'), h('option', { value: 'until' }, 'until'));1624 end.value = b.end;1625 const stop = b.end === 'until'1626 ? h('input', {1627 type: 'time', class: 'input sch-time', id: `sch-${i}-until`, value: b.until, 'aria-label': `Block ${n}: stops at`,1628 title: 'Nothing new starts from then; what is on finishes. Earlier than the start is the next day.',1629 oninput: (e) => { bk().until = e.target.value; schedChanged(); },1630 })1631 : [h('input', {1632 class: 'input n', id: `sch-${i}-count`, value: b.count, inputmode: 'numeric', autocomplete: 'off', 'aria-label': `Block ${n}: how many`,1633 oninput: (e) => { bk().count = e.target.value; schedChanged(); },1634 }), (E.schNouns[i] = h('span', { class: 'sch-noun' }))];1635 return h('li', { class: 'sch-block' }, days, h('span', { class: 'sch-l' }, 'at'), at, end, stop,1636 h('button', { class: 'x', type: 'button', 'aria-label': `Remove block ${n}`, title: 'Remove', onclick: () => removeBlock(i) }, '×'));1637 }16381639 function addBlock() {1640 const s = S.draft.sched;1641 if (s.blocks.length >= MAX_BLOCKS) return;1642 const last = s.blocks[s.blocks.length - 1];1643 const at = last && minutesOf(last.at) >= 0 ? (minutesOf(last.at) + 180) % 1440 : 20 * 60;1644 s.blocks.push({1645 days: last ? [...last.days] : DAYS.map(() => true), at: `${String(Math.floor(at / 60)).padStart(2, '0')}:${String(at % 60).padStart(2, '0')}`,1646 end: 'count', count: last && last.end === 'count' ? last.count : '2', until: '',1647 });1648 renderSchedEditor();1649 const el = document.getElementById(`sch-${s.blocks.length - 1}-at`);1650 if (el) el.focus();1651 schedChanged();1652 }1653 function removeBlock(i) {1654 S.draft.sched.blocks.splice(i, 1);1655 renderSchedEditor();1656 const el = document.getElementById('sch-add');1657 if (el) el.focus();1658 schedChanged();1659 }16601661 // The begin-with picker: every video in the order it airs, once they're known. A new channel1662 // begins with its first.1663 function renderFirstPicker() {1664 const sel = E.schFirst, s = S.draft && S.draft.sched;1665 if (!sel || !s) return;1666 const noun = schedNoun(S.draft), list = loadedItems();1667 const opts = [];1668 if (list && list.length) {1669 const one = oneShow(list);1670 for (const it of list) {1671 const name = itemName(it, one, true);1672 opts.push(h('option', { value: String(it.n) }, `${it.n}. ${name.length > 90 ? name.slice(0, 89) + '…' : name}`));1673 }1674 }1675 if (s.first > opts.length) {1676 const why = isNew() ? '' : S.items && S.items.status === 'loading' ? ' (loading the list)' : '';1677 if (!opts.length && s.first > 1) opts.push(h('option', { value: '1' }, `The first ${noun}`));1678 opts.push(h('option', { value: String(s.first) }, s.first === 1 ? `The first ${noun}${why}` : `${noun[0].toUpperCase() + noun.slice(1)} ${s.first}${why}`));1679 }1680 sel.replaceChildren(...opts);1681 sel.value = String(s.first);1682 sel.disabled = isNew();1683 sel.title = isNew() ? `A new channel begins with its first ${noun}; pick another once it's created.` : '';1684 }16851686 // The summary, the nouns after counts, the order hint and what's wrong, as the draft has them.1687 function updateSched(p) {1688 if (!E.schedSum || !S.draft) return;1689 const d = S.draft;1690 const msg = S.serverErr.sched || (p ? p.sched : schedProblem(d));1691 E.schedSum.textContent = msg && d.sched ? '' : schedSummary(d);1692 E.schedSum.classList.toggle('on', !!d.sched);1693 E.schedErr.textContent = d.sched ? msg : '';1694 // Shuffled episodes on a schedule are rarely what's meant.1695 E.schedHint.hidden = !(d.kind === 'jellyfin' && d.sched && d.order === 'shuffle' && d.series && d.series.size);1696 if (d.sched) {1697 const noun = schedNoun(d);1698 d.sched.blocks.forEach((b, i) => {1699 const el = E.schNouns && E.schNouns[i];1700 if (el) el.textContent = Number(b.count) === 1 ? noun : noun + 's';1701 });1702 }1703 }17041705 // After a save: the fields as the server has them, and the list again, which the save may change.1706 function schedSaved() {1707 if (!E.schedEd) return;1708 S.items = null;1709 renderSchedEditor();1710 loadItems();1711 }17121713 // Folder and weather channels: the number, name and details, what plays, and the next airings.1714 function buildMetaEditor() {1715 E = { metaOnly: true };1716 const head = editorHead();1717 const meta = metaSection();1718 E.info = h('section', { class: 'meta-info', 'aria-label': 'What plays' });1719 E.schedHead = h('span');1720 E.sched = h('div', { class: 'sched-body' });1721 const aside = h('aside', { class: 'aside', 'aria-label': 'Schedule' },1722 h('section', { class: 'sched', 'aria-labelledby': 'sched-h' },1723 h('h2', { id: 'sched-h' }, 'Next airings', E.schedHead), E.sched));1724 $('#main').replaceChildren(h('div', { class: 'ed meta' }, head, meta, scheduleSection(), h('div', { class: 'ed-body' }, E.info, aside)));1725 renderMetaInfo();1726 sizeNum();1727 updateKicker();1728 renderSchedule();1729 updateDirty();1730 }17311732 // What a folder or the weather channel plays, and where its files are.1733 function renderMetaInfo() {1734 const d = S.draft, ch = S.ch;1735 const host = hostLabel();1736 const on = host ? ` on ${host}` : '';1737 const info = [];1738 if (d.kind === 'folder') {1739 const dir = (S.state.channelsDir || '/channels').replace(/\/+$/, '') + '/' + (ch.folder || `${ch.number} ${ch.name}`);1740 info.push(1741 h('p', null, 'Plays the videos in its folder in file name order, looping. To change what\'s on, add or remove videos in this folder', on, ':'),1742 h('code', { class: 'path', title: 'Click to select' }, dir),1743 h('p', { class: 'help' }, 'New files are picked up within about a minute. Saving a new number or name renames the folder; the details and logo are kept in it.'),1744 h('details', null,1745 h('summary', null, 'Fill it with yt-dlp'),1746 h('code', { class: 'path' }, `cd "${dir}"\nyt-dlp -S "res:1080" --merge-output-format mp4 --write-info-json \\\n -o "%(title)s [%(id)s].%(ext)s" "<video or playlist URL>"`)));1747 } else {1748 const dir = S.state.musicDir || '/music';1749 info.push(1750 h('p', null, 'The local forecast in WeatherStar 4000 style, over looping music. Its number, name and details are kept in the channels folder, in .weather.json.'),1751 h('p', null, 'To play your own music under it, put MP3s in this folder', on, ':'),1752 h('code', { class: 'path', title: 'Click to select' }, dir));1753 }1754 E.info.replaceChildren(...info);1755 }17561757 function metaKicker() {1758 const k = E.kicker, ch = S.ch;1759 k.replaceChildren(h('span', { class: 'kind k-' + S.draft.kind, text: CHANNEL_KIND[S.draft.kind] }));1760 if (!ch) return;1761 if (ch.kind === 'folder') k.append(h('span', { text: plural(ch.videos || 0, 'video', 'videos') }));1762 else k.append(h('span', null, 'Built in'));1763 }17641765 function buildEditor() {1766 const d = S.draft;1767 const fresh = isNew();1768 E = {};1769 const head = editorHead();1770 const meta = metaSection();17711772 E.orderHelp = h('p', { class: 'help', id: 'order-help' });1773 E.rateHelp = h('p', { class: 'help', id: 'rate-help' });1774 const opts = h('div', { class: 'ed-opts' },1775 h('div', { class: 'opt-group' },1776 h('span', { class: 'opt-label', id: 'order-l' }, 'Order'),1777 radios('order', ORDERS, d.order, (e) => { S.draft.order = e.target.value; changed(); updateHelp(); }, 'Order'),1778 E.orderHelp),1779 h('div', { class: 'opt-group' },1780 h('span', { class: 'opt-label', id: 'rate-l' }, 'Bitrate'),1781 radios('rate', bitrateOpts(d.maxBitrate), d.maxBitrate, (e) => { S.draft.maxBitrate = Number(e.target.value); changed(); updateHelp(); }, 'Bitrate'),1782 E.rateHelp));17831784 E.tabs = {};1785 const tablist = h('div', { class: 'tabs', role: 'tablist', 'aria-label': 'Library', onkeydown: tabKeys },1786 KINDS.map((k) => (E.tabs[k] = h('button', {1787 class: 'tab', role: 'tab', type: 'button', id: 'tab-' + k, 'aria-controls': 'lib-panel', onclick: () => setTab(k),1788 }, KIND[k].label, h('span', { class: 'n' })))));1789 E.libStatus = h('span', { class: 'lib-status', 'aria-live': 'polite' });17901791 E.search = h('input', {1792 class: 'input', type: 'search', id: 'search', autocomplete: 'off', spellcheck: 'false', 'aria-keyshortcuts': '/',1793 oninput: onSearch, onkeydown: searchKeys,1794 });1795 E.picked = h('button', {1796 class: 'gchip sel-only', type: 'button', 'aria-pressed': false, title: 'Show only what this channel plays',1797 onclick: () => { const f = S.filters[S.tab]; f.onlySel = !f.onlySel; renderFilters(); renderGrid(); },1798 }, 'Picked', h('span', { class: 'n' }));1799 E.clear = h('button', { class: 'gchip clear', type: 'button', hidden: true, onclick: clearFilters }, 'Clear filters');1800 E.chips = h('div', { class: 'gchips', role: 'group', 'aria-label': 'Genres' });1801 E.fcount = h('span', { class: 'fcount', 'aria-live': 'polite' });1802 E.grid = h('div', { class: 'grid', onclick: gridClick, onkeydown: gridKeys, onfocusin: gridFocus });1803 E.grid.addEventListener('error', (e) => { if (e.target.tagName === 'IMG') e.target.remove(); }, true);1804 E.gmsg = h('div', { class: 'gmsg' });1805 E.sentinel = h('div', { class: 'sentinel', 'aria-hidden': 'true' });1806 E.scroller = h('div', { class: 'scroller', id: 'lib-panel', role: 'tabpanel', onscroll: queueMore }, E.gmsg, E.grid, E.sentinel);18071808 E.picksCount = h('span', { class: 'n' });1809 E.picks = h('div', { class: 'picks-body' });1810 E.schedHead = h('span');1811 E.sched = h('div', { class: 'sched-body' });1812 const aside = h('aside', { class: 'aside', 'aria-label': 'Picks and schedule' },1813 h('section', { class: 'picks', 'aria-labelledby': 'picks-h' },1814 h('h2', { id: 'picks-h' }, 'On this channel', E.picksCount), E.picks),1815 h('section', { class: 'sched', 'aria-labelledby': 'sched-h' },1816 h('h2', { id: 'sched-h' }, 'Next airings', E.schedHead), E.sched));18171818 const ed = h('div', { class: 'ed' }, head, meta, opts, scheduleSection(),1819 h('div', { class: 'ed-tabs' }, tablist, E.libStatus),1820 h('div', { class: 'ed-body' },1821 h('section', { class: 'lib', 'aria-label': 'Library' },1822 h('div', { class: 'fbar' },1823 h('div', { class: 'fbar-row' },1824 h('div', { class: 'search' }, E.search, h('kbd', { 'aria-hidden': 'true' }, '/')),1825 E.picked, E.clear, E.fcount),1826 E.chips),1827 E.scroller),1828 aside));1829 $('#main').replaceChildren(ed);18301831 sizeNum();1832 updateHelp();1833 updateKicker();1834 updateTabs();1835 setTab(S.tab);1836 renderPicks();1837 renderSchedule();1838 updateDirty();1839 if (fresh) E.name.focus();1840 }18411842 function sizeNum() {1843 E.num.style.width = (Math.max(3, E.num.value.length) + 0.4) + 'ch';1844 }18451846 function updateHelp() {1847 const d = S.draft;1848 const o = ORDERS.find((x) => x.v === d.order) || ORDERS[0];1849 E.orderHelp.textContent = o.help;1850 E.rateHelp.textContent = d.maxBitrate1851 ? `${serverLabel()} transcodes anything above ${d.maxBitrate} Mbps before sending it.`1852 : 'Sends the original files as they are; nothing is transcoded on Jellyfin.';1853 }18541855 function updateKicker() {1856 const k = E.kicker;1857 if (!k) return;1858 if (E.yt) { ytKicker(); return; }1859 if (E.metaOnly) { metaKicker(); return; }1860 k.replaceChildren();1861 if (isNew()) {1862 k.append(h('span', { class: 'kind k-new' }, 'New Jellyfin channel'), h('span', null, 'Goes on the air when you create it'));1863 return;1864 }1865 const ch = S.ch;1866 k.append(h('span', { class: 'kind k-jellyfin' }, 'Jellyfin'));1867 if (!ch) return;1868 if (ch.error) k.append(h('span', { class: 'err', title: ch.error, text: ch.error }));1869 else if (ch.items == null) k.append(h('span', null, 'Loading from Jellyfin'));1870 else k.append(h('span', { text: plural(ch.items, 'video', 'videos') + ' in rotation' }));1871 if (ch.now && ch.now.title) {1872 k.append(h('span', { class: 'now', title: [ch.now.title, ch.now.subtitle].filter(Boolean).join(': ') },1873 h('b', null, 'Now'), ch.now.title,1874 ch.now.subtitle ? h('em', null, ', ' + ch.now.subtitle) : null,1875 ch.now.end ? ` until ${fmtTime(ch.now.end)}` : null));1876 }1877 }18781879 function onField(e) {1880 const d = S.draft;1881 if (e.target === E.num) {1882 d.number = E.num.value;1883 S.serverErr.number = '';1884 sizeNum();1885 updateMeta();1886 } else {1887 d.name = E.name.value;1888 S.serverErr.name = '';1889 if (!squash(d.callSign)) renderLogo();1890 }1891 if (isNew()) {1892 const li = $(`#channels .ch[data-number="${S.sel}"]`);1893 if (li) {1894 $('.ch-num', li).textContent = d.number.trim() || '?';1895 $('.ch-name', li).textContent = d.name.trim() || 'New channel';1896 }1897 }1898 changed();1899 }19001901 function changed() {1902 S.saveMsg = null;1903 updateDirty();1904 }19051906 function updateDirty() {1907 if (!E.save) return;1908 const d = S.draft, fresh = isNew(), dirty = isDirty(), p = problems();1909 const numMsg = S.serverErr.number || ((S.touched.number || p.taken) ? p.number : '');1910 const nameMsg = S.serverErr.name || ((S.touched.name || p.badName) ? p.name : '');1911 E.numErr.textContent = numMsg;1912 E.nameErr.textContent = nameMsg;1913 E.num.setAttribute('aria-invalid', String(!!numMsg));1914 E.name.setAttribute('aria-invalid', String(!!nameMsg));1915 const yt = p.yt || { sources: '', settings: '' };1916 if (E.optErr) E.optErr.textContent = yt.settings;1917 const metaMsg = S.serverErr.meta || p.meta;1918 if (E.metaErr) {1919 E.metaErr.textContent = metaMsg;1920 E.call.setAttribute('aria-invalid', String(!!metaMsg && /call sign/i.test(metaMsg)));1921 E.desc.setAttribute('aria-invalid', String(!!metaMsg && /description/i.test(metaMsg)));1922 }1923 updateSched(p);1924 E.save.disabled = S.saving || !!p.number || !!p.name || !!p.meta || !!p.sched || !!yt.sources || !!yt.settings || (!dirty && !fresh);19251926 let text = '', cls = '';1927 if (S.saving) text = fresh ? 'Creating' : 'Saving';1928 else if (S.saveMsg) ({ text, cls } = S.saveMsg);1929 else if (fresh && !d.name.trim()) text = 'Name the channel to create it.';1930 else if (yt.sources) text = fresh && d.ytMode !== 'playlist' ? 'Add a YouTube channel to create it.' : yt.sources;1931 else if (fresh) { text = 'Not created yet'; cls = 'dirty'; }1932 else if (dirty) { text = 'Unsaved changes'; cls = 'dirty'; }1933 E.status.textContent = text;1934 E.status.className = 'ed-status' + (cls ? ' ' + cls : '');19351936 if (!fresh) {1937 const li = $(`#channels .ch[data-number="${CSS.escape(S.origNumber)}"]`);1938 if (li) li.classList.toggle('dirty', dirty);1939 }1940 }19411942 // ---------- Tabs, library, filters ----------1943 function tabKeys(e) {1944 const i = KINDS.indexOf(S.tab);1945 let n = -1;1946 if (e.key === 'ArrowRight') n = (i + 1) % KINDS.length;1947 else if (e.key === 'ArrowLeft') n = (i + KINDS.length - 1) % KINDS.length;1948 else if (e.key === 'Home') n = 0;1949 else if (e.key === 'End') n = KINDS.length - 1;1950 if (n < 0) return;1951 e.preventDefault();1952 setTab(KINDS[n]);1953 E.tabs[KINDS[n]].focus();1954 }19551956 function setTab(k) {1957 S.tab = k;1958 for (const kk of KINDS) {1959 const on = kk === k;1960 E.tabs[kk].setAttribute('aria-selected', String(on));1961 E.tabs[kk].tabIndex = on ? 0 : -1;1962 }1963 E.scroller.setAttribute('aria-labelledby', 'tab-' + k);1964 E.search.value = S.filters[k].q;1965 E.search.placeholder = `Search ${KIND[k].many}`;1966 E.search.setAttribute('aria-label', `Search ${KIND[k].many}`);1967 const L = S.lib[k];1968 if (!L || L.status === 'error') loadLib(k);1969 renderFilters();1970 renderGrid();1971 updateLibStatus();1972 }19731974 function updateTabs() {1975 for (const k of KINDS) {1976 const n = S.draft[k].size;1977 const s = $('.n', E.tabs[k]);1978 s.textContent = n;1979 s.classList.toggle('some', n > 0);1980 E.tabs[k].setAttribute('aria-label', `${KIND[k].label}, ${n} picked`);1981 }1982 $('.n', E.picked).textContent = S.draft[S.tab].size;1983 }19841985 function updateLibStatus() {1986 const k = S.tab, L = S.lib[k];1987 let t = '';1988 if (!L || L.status === 'loading') t = `Loading ${KIND[k].many} from ${serverLabel()}`;1989 else if (L.status === 'error') t = `Couldn't load ${KIND[k].many}`;1990 else t = `${plural(L.items.length, KIND[k].one, KIND[k].many)} on ${serverLabel()}`;1991 E.libStatus.textContent = t;1992 }19931994 function loadLib(k, force) {1995 const cur = S.lib[k];1996 if (cur && !force && cur.status !== 'error') return cur.promise;1997 const L = { status: 'loading', items: [], byKey: new Map(), genres: [], error: null };1998 S.lib[k] = L;1999 L.promise = api('GET', `/library/${k}`).then((r) => {2000 const items = Array.isArray(r && r.items) ? r.items : [];2001 const counts = new Map();2002 for (const it of items) {2003 it.genres = Array.isArray(it.genres) ? it.genres : [];2004 it.name = it.name || it.key || '';2005 it._s = fold([it.name, it.key, it.year].filter(Boolean).join(' '));2006 for (const g of it.genres) counts.set(g, (counts.get(g) || 0) + 1);2007 }2008 L.items = items;2009 L.byKey = new Map(items.map((it) => [it.key, it]));2010 L.genres = [...counts].map(([name, count]) => ({ name, count }))2011 .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));2012 L.status = 'ok';2013 }, (err) => {2014 L.status = 'error';2015 L.error = err;2016 }).then(() => {2017 if (S.lib[k] !== L || !E.grid) return;2018 if (k === S.tab) { renderFilters(); renderGrid(); }2019 updateLibStatus();2020 renderPicks();2021 });2022 return L.promise;2023 }20242025 function renderFilters() {2026 const k = S.tab, L = S.lib[k], f = S.filters[k];2027 const ready = !!(L && L.status === 'ok');2028 E.picked.hidden = !ready;2029 E.picked.setAttribute('aria-pressed', String(f.onlySel));2030 $('.n', E.picked).textContent = S.draft[k].size;2031 E.clear.hidden = !(f.q.trim() || f.genres.size || f.onlySel);2032 if (E.clear.hidden && document.activeElement === E.clear) E.search.focus();2033 const focused = document.activeElement && E.chips.contains(document.activeElement) ? document.activeElement.dataset.f : null;2034 E.chips.replaceChildren();2035 E.chips.hidden = !ready || !L.genres.length;2036 if (!ready) return;2037 const shown = S.genresOpen ? L.genres : L.genres.filter((g, i) => i < TOP_GENRES || f.genres.has(g.name));2038 for (const g of shown) {2039 E.chips.append(h('button', {2040 class: 'gchip', type: 'button', 'data-f': 'g:' + g.name, 'aria-pressed': f.genres.has(g.name),2041 onclick: () => toggleGenre(g.name),2042 }, g.name, h('span', { class: 'n', text: g.count.toLocaleString() })));2043 }2044 if (L.genres.length > TOP_GENRES) {2045 E.chips.append(h('button', {2046 class: 'gchip more', type: 'button', 'data-f': ':more', 'aria-expanded': S.genresOpen,2047 onclick: () => { S.genresOpen = !S.genresOpen; renderFilters(); },2048 }, S.genresOpen ? 'Fewer genres' : `${L.genres.length - TOP_GENRES} more genres`));2049 }2050 if (focused) {2051 const el = E.chips.querySelector(`[data-f="${CSS.escape(focused)}"]`);2052 if (el) el.focus();2053 }2054 }20552056 function toggleGenre(g) {2057 const set = S.filters[S.tab].genres;2058 if (set.has(g)) set.delete(g); else set.add(g);2059 renderFilters();2060 renderGrid();2061 }20622063 function clearFilters() {2064 const f = S.filters[S.tab];2065 f.q = ''; f.genres.clear(); f.onlySel = false;2066 E.search.value = '';2067 E.search.focus();2068 renderFilters();2069 renderGrid();2070 }20712072 let searchTimer = 0;2073 function onSearch() {2074 clearTimeout(searchTimer);2075 searchTimer = setTimeout(() => {2076 const f = S.filters[S.tab];2077 const had = !!f.q.trim();2078 f.q = E.search.value;2079 if (had !== !!f.q.trim()) renderFilters();2080 renderGrid();2081 }, 90);2082 }20832084 function searchKeys(e) {2085 if (e.key === 'Escape') {2086 if (E.search.value) {2087 e.preventDefault();2088 E.search.value = '';2089 onSearch();2090 } else {2091 E.search.blur();2092 }2093 } else if (e.key === 'ArrowDown') {2094 const c = E.grid.querySelector('.card[tabindex="0"]') || E.grid.querySelector('.card');2095 if (c) { e.preventDefault(); c.focus(); }2096 } else if (e.key === 'Enter') {2097 e.preventDefault();2098 clearTimeout(searchTimer);2099 S.filters[S.tab].q = E.search.value;2100 renderFilters();2101 renderGrid();2102 }2103 }21042105 function filteredItems() {2106 const k = S.tab, L = S.lib[k], f = S.filters[k], picked = S.draft[k];2107 const terms = fold(f.q).split(/\s+/).filter(Boolean);2108 const g = f.genres;2109 return L.items.filter((it) =>2110 (!f.onlySel || picked.has(it.key)) &&2111 (!g.size || it.genres.some((x) => g.has(x))) &&2112 (!terms.length || terms.every((t) => it._s.includes(t))));2113 }21142115 function describeFilters(k) {2116 const f = S.filters[k];2117 const parts = [];2118 if (f.q.trim()) parts.push(`matching “${f.q.trim()}”`);2119 if (f.genres.size) parts.push('in ' + [...f.genres].join(' or '));2120 if (f.onlySel) parts.push('among the picks');2121 return `No ${KIND[k].many} ${parts.join(' ')}.`;2122 }21232124 function renderGrid() {2125 const k = S.tab, L = S.lib[k];2126 E.grid.replaceChildren();2127 E.gmsg.replaceChildren();2128 E.fcount.textContent = '';2129 S.view = null;2130 E.scroller.scrollTop = 0;2131 if (!L || L.status === 'loading') {2132 E.gmsg.append(h('p', null, `Loading ${KIND[k].many} from ${serverLabel()}. Big libraries take a few seconds.`));2133 for (let i = 0; i < 14; i++) E.grid.append(h('div', { class: 'skel', 'aria-hidden': 'true' }));2134 return;2135 }2136 if (L.status === 'error') {2137 const a = S.state.account;2138 E.gmsg.append(2139 h('p', { class: 'big err' }, `Couldn't load ${KIND[k].many} from ${serverLabel()}.`),2140 h('p', null, L.error.message),2141 h('div', { class: 'row' },2142 h('button', { class: 'btn', type: 'button', onclick: () => { loadLib(k, true); renderGrid(); updateLibStatus(); } }, 'Try again'),2143 !a.ok ? h('button', { class: 'btn ghost', type: 'button', onclick: () => openAccount() }, 'Check the account') : null));2144 return;2145 }2146 const list = filteredItems();2147 S.view = { kind: k, list, shown: 0 };2148 E.fcount.textContent = list.length === L.items.length2149 ? plural(L.items.length, KIND[k].one, KIND[k].many)2150 : `${list.length.toLocaleString()} of ${L.items.length.toLocaleString()}`;2151 if (!list.length) {2152 if (!L.items.length) {2153 E.gmsg.append(h('p', { class: 'big' }, `No ${KIND[k].many} on ${serverLabel()}.`), h('p', null, KIND[k].empty));2154 } else {2155 E.gmsg.append(h('p', { class: 'big' }, 'Nothing matches.'), h('p', null, describeFilters(k)),2156 h('div', { class: 'row' }, h('button', { class: 'btn ghost', type: 'button', onclick: clearFilters }, 'Clear filters')));2157 }2158 return;2159 }2160 appendChunk();2161 }21622163 function metaFor(k, it) {2164 const m = [];2165 if (it.year) m.push(String(it.year));2166 if (k === 'series' && it.count) m.push(`${it.count.toLocaleString()} ep${it.count === 1 ? '' : 's'}`);2167 else if (k === 'movies' && it.runtimeMinutes) m.push(fmtRuntime(it.runtimeMinutes));2168 else if ((k === 'collections' || k === 'playlists') && it.count != null) m.push(plural(it.count, 'item', 'items'));2169 return m;2170 }21712172 function card(it, k, on) {2173 const poster = h('span', { class: 'poster' }, h('span', { class: 'ph', 'aria-hidden': 'true', text: it.name }));2174 if (it.image) poster.append(h('img', { src: it.image, alt: '', loading: 'lazy', decoding: 'async', draggable: 'false' }));2175 poster.append(h('span', { class: 'tick', 'aria-hidden': 'true' }));2176 if (it.overview) poster.append(h('span', { class: 'ov', 'aria-hidden': 'true', text: it.overview }));2177 const meta = metaFor(k, it);2178 return h('button', {2179 class: 'card', type: 'button', tabindex: '-1', 'data-key': it.key, 'aria-pressed': on,2180 'aria-description': it.overview || null,2181 }, poster, h('span', { class: 'cname', text: it.name }),2182 meta.length ? h('span', { class: 'cmeta' }, meta.map((x) => h('span', { text: x }))) : null);2183 }21842185 function appendChunk() {2186 const v = S.view;2187 if (!v || v.shown >= v.list.length) return;2188 const end = Math.min(v.list.length, v.shown + CHUNK);2189 const picked = S.draft[v.kind];2190 const frag = document.createDocumentFragment();2191 for (let i = v.shown; i < end; i++) frag.append(card(v.list[i], v.kind, picked.has(v.list[i].key)));2192 const first = v.shown === 0;2193 E.grid.append(frag);2194 v.shown = end;2195 if (first && E.grid.firstElementChild) E.grid.firstElementChild.tabIndex = 0;2196 queueMore();2197 }21982199 let moreQueued = false;2200 function queueMore() {2201 if (moreQueued) return;2202 moreQueued = true;2203 requestAnimationFrame(() => {2204 moreQueued = false;2205 const v = S.view;2206 if (!v || v.shown >= v.list.length || !E.sentinel || !E.sentinel.isConnected) return;2207 const top = E.sentinel.getBoundingClientRect().top;2208 const bottom = Math.min(window.innerHeight, E.scroller.getBoundingClientRect().bottom);2209 if (top < bottom + 1200) appendChunk();2210 });2211 }2212 window.addEventListener('scroll', queueMore, { passive: true });2213 window.addEventListener('resize', queueMore);22142215 function gridClick(e) {2216 const c = e.target.closest('.card');2217 if (c && S.view) toggleCard(c);2218 }22192220 function toggleCard(c) {2221 const k = S.view.kind, key = c.dataset.key, set = S.draft[k];2222 if (set.has(key)) set.delete(key); else set.add(key);2223 c.setAttribute('aria-pressed', String(set.has(key)));2224 picksChanged();2225 }22262227 function gridFocus(e) {2228 const c = e.target.closest('.card');2229 if (!c) return;2230 const prev = E.grid.querySelector('.card[tabindex="0"]');2231 if (prev && prev !== c) prev.tabIndex = -1;2232 c.tabIndex = 0;2233 }22342235 function gridKeys(e) {2236 const c = e.target.closest('.card');2237 if (!c) return;2238 const cards = E.grid.children;2239 const i = Array.prototype.indexOf.call(cards, c);2240 const cols = Math.max(1, getComputedStyle(E.grid).gridTemplateColumns.split(' ').length);2241 let n;2242 switch (e.key) {2243 case 'ArrowRight': n = i + 1; break;2244 case 'ArrowLeft': n = i - 1; break;2245 case 'ArrowDown': n = i + cols; break;2246 case 'ArrowUp': n = i - cols; break;2247 case 'PageDown': n = i + cols * 3; break;2248 case 'PageUp': n = i - cols * 3; break;2249 case 'Home': n = 0; break;2250 case 'End': n = cards.length - 1; break;2251 default: return;2252 }2253 e.preventDefault();2254 if (n < 0 && (e.key === 'ArrowUp' || e.key === 'PageUp') && i < cols) { E.search.focus(); return; }2255 if (n >= cards.length && S.view && S.view.shown < S.view.list.length) appendChunk();2256 n = Math.max(0, Math.min(cards.length - 1, n));2257 const t = cards[n];2258 if (t && t !== c) { t.focus(); t.scrollIntoView({ block: 'nearest' }); }2259 }22602261 // ---------- Picks ----------2262 function isMissing(k, key) {2263 if (S.ch && Array.isArray(S.ch.unmatched) && S.ch.unmatched.includes(key)) return true;2264 const L = S.lib[k];2265 return !!(L && L.status === 'ok' && !L.byKey.has(key));2266 }22672268 function picksChanged() {2269 updateTabs();2270 renderPicks();2271 changed();2272 }22732274 function renderPicks() {2275 if (!E.picks) return;2276 const d = S.draft, box = E.picks;2277 const buttons = [...box.querySelectorAll('button')];2278 const focusIdx = buttons.indexOf(document.activeElement);2279 box.replaceChildren();2280 let total = 0;2281 const missing = [];2282 for (const k of KINDS) for (const key of d[k]) { total++; if (isMissing(k, key)) missing.push([k, key]); }2283 E.picksCount.textContent = total;2284 if (missing.length) {2285 box.append(h('div', { class: 'missing-note', role: 'note' },2286 h('span', null, missing.length === 12287 ? `1 pick is no longer on ${serverLabel()}.`2288 : `${missing.length} picks are no longer on ${serverLabel()}.`),2289 h('button', { class: 'btn small danger', type: 'button', onclick: () => dropKeys(missing) },2290 missing.length === 1 ? 'Drop it' : `Drop all ${missing.length}`)));2291 }2292 if (!total) {2293 box.append(h('p', { class: 'picks-empty' }, 'Nothing picked yet. Click a poster to add it to this channel.'));2294 }2295 for (const k of KINDS) {2296 if (!d[k].size) continue;2297 const L = S.lib[k];2298 const rows = [...d[k]].map((key) => {2299 const it = L && L.byKey ? L.byKey.get(key) : null;2300 const miss = isMissing(k, key);2301 const name = it ? it.name : key;2302 return h('li', { class: 'pick' + (miss ? ' missing' : '') },2303 h('span', { class: 'pk-name', title: key }, name,2304 miss ? h('small', null, 'not on Jellyfin') : (it && it.year ? h('small', { text: it.year }) : null)),2305 miss2306 ? h('button', { class: 'drop', type: 'button', 'aria-label': `Drop ${name}`, onclick: () => dropKeys([[k, key]]) }, 'Drop')2307 : h('button', { class: 'x', type: 'button', 'aria-label': `Remove ${name}`, title: 'Remove', onclick: () => dropKeys([[k, key]]) }, '×'));2308 });2309 box.append(h('div', { class: 'pk-group' },2310 h('h3', null, KIND[k].label, h('span', { text: d[k].size })),2311 h('ul', null, rows)));2312 }2313 if (focusIdx >= 0) {2314 const now = box.querySelectorAll('button');2315 const t = now[Math.min(focusIdx, now.length - 1)];2316 if (t) t.focus();2317 }2318 }23192320 function dropKeys(list) {2321 for (const [k, key] of list) {2322 S.draft[k].delete(key);2323 if (S.view && S.view.kind === k) {2324 const c = E.grid.querySelector(`.card[data-key="${CSS.escape(key)}"]`);2325 if (c) c.setAttribute('aria-pressed', 'false');2326 }2327 }2328 picksChanged();2329 }23302331 function syncCards() {2332 if (!S.view) return;2333 const set = S.draft[S.view.kind];2334 for (const c of E.grid.querySelectorAll('.card')) c.setAttribute('aria-pressed', String(set.has(c.dataset.key)));2335 }23362337 // ---------- Main: YouTube editor ----------2338 const ytKey = (ref) => String(ref || '').toLowerCase();2339 function handleOf(ref) {2340 const m = /^(@[^\s/]+)$/.exec(ref) || /youtube\.com\/(@[^/?#]+)/i.exec(ref);2341 if (!m) return '';2342 try { return decodeURIComponent(m[1]); } catch { return m[1]; }2343 }2344 const ytBusySrc = (s) => s.listing || (!s.listed && !s.failed);2345 const ytBusy = (ch) => !!(ch && Array.isArray(ch.sources) && ch.sources.some(ytBusySrc));23462347 // What's known of a source: the server's status for it, and what a search or lookup found.2348 function ytInfo(ref) {2349 const key = ytKey(ref);2350 const st = (S.ch && Array.isArray(S.ch.sources) && S.ch.sources.find((s) => ytKey(s.channel) === key || ytKey(s.url) === key)) || null;2351 const lk = S.yt.info.get(key);2352 const d = (lk && lk.data) || {};2353 return {2354 ref, st,2355 id: (st && st.id) || d.id || '',2356 name: (st && st.name) || d.name || handleOf(ref) || String(ref).replace(/^https?:\/\/(www\.)?youtube\.com\//i, ''),2357 handle: (st && st.handle) || d.handle || handleOf(ref),2358 image: d.image || (st && st.image) || '',2359 total: Number(d.videos) || 0,2360 looking: !!(lk && lk.status === 'loading'),2361 lookErr: lk && lk.status === 'error' ? lk.error : '',2362 };2363 }23642365 function avatar(name, image, small) {2366 const el = h('span', { class: 'av' + (small ? ' sm' : ''), 'aria-hidden': 'true', text: [...String(name || '?').trim()][0] || '?' });2367 if (image) {2368 el.append(h('img', { src: image, alt: '', loading: 'lazy', decoding: 'async', referrerpolicy: 'no-referrer', draggable: 'false',2369 onerror: (e) => e.target.remove() }));2370 }2371 return el;2372 }23732374 // Looks up sources two at a time, for their avatars, rough sizes, and whether they can play.2375 const ytQueue = [];2376 let ytLooking = 0;2377 function ytLookup(ref) {2378 const key = ytKey(ref), cur = S.yt.info.get(key);2379 if (cur && (cur.full || cur.status === 'loading' || cur.status === 'error')) return;2380 S.yt.info.set(key, { status: 'loading', data: cur && cur.data });2381 ytQueue.push(ref);2382 ytPump();2383 }2384 function ytPump() {2385 while (ytLooking < 2 && ytQueue.length) {2386 const ref = ytQueue.shift(), key = ytKey(ref), before = S.yt.info.get(key);2387 ytLooking++;2388 api('GET', `/youtube/channel?u=${enc(ref)}`).then((c) => {2389 const got = { status: 'ok', data: c, full: true };2390 S.yt.info.set(key, got);2391 if (c && c.url) S.yt.info.set(ytKey(c.url), got);2392 }, (e) => {2393 S.yt.info.set(key, { status: 'error', data: before && before.data, error: e.message });2394 }).finally(() => {2395 ytLooking--;2396 if (E.yt) { ytRenderSources(); ytRenderResults(); renderSchedule(); }2397 ytPump();2398 });2399 }2400 }24012402 function buildYtEditor() {2403 const d = S.draft;2404 const fresh = isNew();2405 E = { yt: true };2406 const head = editorHead();2407 const meta = metaSection();2408 const pl = d.ytMode === 'playlist';2409 const find = pl ? ytPlaylistPanel() : ytFindPanel();24102411 E.picksCount = h('span', { class: 'n' });2412 E.picks = h('div', { class: 'picks-body' });2413 E.schedHead = h('span');2414 E.sched = h('div', { class: 'sched-body' });2415 const aside = h('aside', { class: 'aside', 'aria-label': pl ? 'Playlist and schedule' : 'Channels and schedule' },2416 h('section', { class: 'picks', 'aria-labelledby': 'picks-h' },2417 h('h2', { id: 'picks-h' }, 'On this channel', E.picksCount), E.picks),2418 h('section', { class: 'sched', 'aria-labelledby': 'sched-h' },2419 h('h2', { id: 'sched-h' }, 'Next airings', E.schedHead), E.sched));24202421 $('#main').replaceChildren(h('div', { class: 'ed yt' }, head, meta, ytOpts(), scheduleSection(), h('div', { class: 'ed-body' }, find, aside)));2422 sizeNum();2423 ytHelp();2424 updateKicker();2425 ytRenderSources();2426 ytRenderResults();2427 renderSchedule();2428 updateDirty();2429 if (pl && YT_PLAYLIST.test(d.playlist)) ytPlLookup(d.playlist);2430 if (!pl) for (const ref of d.sources) ytLookup(ref);2431 if (fresh) E.name.focus();2432 }24332434 // The search for YouTube channels to add, beside a channel of uploads.2435 function ytFindPanel() {2436 E.search = h('input', {2437 class: 'input', type: 'search', id: 'search', placeholder: 'Search YouTube channels', autocomplete: 'off', spellcheck: 'false',2438 'aria-label': 'Search YouTube channels, or paste a channel link', 'aria-keyshortcuts': '/', oninput: ytOnInput, onkeydown: ytSearchKeys,2439 });2440 E.ytCount = h('span', { class: 'fcount', 'aria-live': 'polite' });2441 E.ytMsg = h('div', { class: 'gmsg', 'aria-live': 'polite' });2442 E.ytRes = h('ul', { class: 'ytres', 'aria-label': 'YouTube channels found' });2443 return h('section', { class: 'lib ytfind', 'aria-label': 'Find YouTube channels' },2444 h('form', { class: 'fbar', role: 'search', onsubmit: (e) => { e.preventDefault(); ytSearch(E.search.value); } },2445 h('div', { class: 'fbar-row' },2446 h('div', { class: 'search' }, E.search, h('kbd', { 'aria-hidden': 'true' }, '/')),2447 h('button', { class: 'btn', type: 'submit' }, 'Search'),2448 E.ytCount),2449 h('p', { class: 'help' }, 'Or paste a channel link, like youtube.com/@boilerroom, or its @handle.')),2450 h('div', { class: 'scroller' }, E.ytMsg, E.ytRes));2451 }24522453 // Switches what the channel plays. The editor is built again: the panels, the settings and the2454 // Airs row differ.2455 function ytSetMode(mode) {2456 const d = S.draft;2457 if (d.ytMode === mode) return;2458 d.ytMode = mode;2459 d.playlist = mode === 'playlist' ? d.plLink.trim() : '';2460 S.yt.view = null;2461 buildYtEditor();2462 const r = $(`input[name=ytmode][value="${mode}"]`);2463 if (r) r.focus();2464 }24652466 function ytOpts() {2467 const d = S.draft;2468 const num = (key, label, extra) => h('input', {2469 class: 'input n', id: 'yt-' + key, value: d[key], inputmode: 'numeric', autocomplete: 'off', 'aria-label': label, ...extra,2470 oninput: (e) => { S.draft[key] = e.target.value; changed(); },2471 });2472 E.ytMin = num('minMinutes', 'Shortest, in minutes');2473 E.ytMax = num('maxMinutes', 'Longest, in minutes', { placeholder: 'any' });2474 E.ytRepeat = num('repeatDays', 'Days before a video airs again');2475 E.ytAge = num('maxAgeDays', 'Only videos from the last so many days', { placeholder: 'any' });2476 E.ytMix = h('select', { class: 'input select', id: 'yt-reruns', 'aria-labelledby': 'reruns-l', onchange: (e) => { S.draft.rerunMix = e.target.value; changed(); ytHelp(); } },2477 YT_RERUNS.map((o) => h('option', { value: o.v }, o.label)));2478 E.ytMix.value = d.rerunMix;2479 const heights = [...YT_HEIGHTS];2480 if (!heights.includes(d.maxHeight)) heights.push(d.maxHeight);2481 heights.sort((a, b) => a - b);2482 E.mixHelp = h('p', { class: 'help' });2483 E.qualHelp = h('p', { class: 'help' });2484 E.runHelp = h('p', { class: 'help' });2485 E.optErr = h('p', { class: 'f-err opt-err', role: 'alert' });2486 E.modeHelp = h('p', { class: 'help' });2487 // Uploads' settings keep their pairs below a row of their own.2488 const plays = h('div', { class: 'opt-group' + (d.ytMode === 'uploads' ? ' plays' : '') },2489 h('span', { class: 'opt-label' }, 'Plays'),2490 radios('ytmode', YT_MODES, d.ytMode, (e) => ytSetMode(e.target.value), 'What it plays'),2491 E.modeHelp);2492 const quality = h('div', { class: 'opt-group' },2493 h('span', { class: 'opt-label' }, 'Quality'),2494 radios('quality', heights.map((v) => ({ v, label: `${v}p` })), d.maxHeight, (e) => { S.draft.maxHeight = Number(e.target.value); changed(); ytHelp(); }, 'Quality'),2495 E.qualHelp);2496 if (d.ytMode === 'playlist') return h('div', { class: 'ed-opts yt-opts' }, plays, quality, E.optErr);2497 return h('div', { class: 'ed-opts yt-opts' }, plays,2498 h('div', { class: 'opt-group' },2499 h('span', { class: 'opt-label' }, 'Length'),2500 h('div', { class: 'opt-line' }, E.ytMin, h('span', null, 'to'), E.ytMax, h('span', null, 'minutes')),2501 h('p', { class: 'help' }, 'Shorter and longer videos stay off the air; leave the longest empty for no limit. DJ sets: 20 to 240.')),2502 h('div', { class: 'opt-group' },2503 h('span', { class: 'opt-label' }, 'Age'),2504 h('div', { class: 'opt-line' }, h('span', null, 'only videos from the last'), E.ytAge, h('span', null, 'days')),2505 h('p', { class: 'help' }, 'Older uploads stay off the air, first runs and reruns alike; leave it empty for any age.')),2506 h('div', { class: 'opt-group' },2507 h('span', { class: 'opt-label' }, 'Repeats'),2508 h('div', { class: 'opt-line' }, h('span', null, 'after'), E.ytRepeat, h('span', null, 'days')),2509 h('p', { class: 'help' }, 'How long before a video airs again, when the channels have enough of them.')),2510 h('div', { class: 'opt-group' },2511 h('span', { class: 'opt-label' }, 'Run out'),2512 radios('runout', YT_RUNOUT, d.deadAir, (e) => { S.draft.deadAir = e.target.value === 'true'; changed(); ytHelp(); }, 'When everything has aired'),2513 E.runHelp),2514 h('div', { class: 'opt-group' },2515 h('span', { class: 'opt-label', id: 'reruns-l' }, 'Reruns'),2516 E.ytMix,2517 E.mixHelp),2518 quality,2519 E.optErr);2520 }25212522 function ytHelp() {2523 const d = S.draft;2524 E.modeHelp.textContent = (YT_MODES.find((o) => o.v === d.ytMode) || YT_MODES[0]).help;2525 if (d.ytMode === 'uploads') {2526 E.mixHelp.textContent = (YT_RERUNS.find((o) => o.v === d.rerunMix) || YT_RERUNS[1]).help;2527 E.runHelp.textContent = YT_RUNOUT[d.deadAir ? 1 : 0].help;2528 }2529 E.qualHelp.textContent = d.maxHeight >= 1080 ? 'The sharpest picture, and the most bandwidth.'2530 : d.maxHeight >= 720 ? 'The most YouTube is asked for. Suits most TVs.'2531 : 'Lighter on bandwidth; fine on small screens.';2532 }25332534 // After a save, the settings as the server has them.2535 function ytSyncSettings() {2536 const d = S.draft;2537 if (E.ytPl && E.ytPl.value !== d.plLink) E.ytPl.value = d.plLink;2538 for (const [el, key] of [[E.ytMin, 'minMinutes'], [E.ytMax, 'maxMinutes'], [E.ytRepeat, 'repeatDays'], [E.ytAge, 'maxAgeDays']]) {2539 if (el && el.value !== d[key]) el.value = d[key];2540 }2541 if (E.ytMix) E.ytMix.value = d.rerunMix;2542 const r = $(`input[name=quality][value="${d.maxHeight}"]`);2543 if (r) r.checked = true;2544 const run = $(`input[name=runout][value="${d.deadAir}"]`);2545 if (run) run.checked = true;2546 ytHelp();2547 }25482549 function ytKicker() {2550 const k = E.kicker;2551 k.replaceChildren();2552 if (isNew()) {2553 k.append(h('span', { class: 'kind k-new' }, 'New YouTube channel'), h('span', null, 'Goes on the air when you create it'));2554 return;2555 }2556 const ch = S.ch;2557 k.append(h('span', { class: 'kind k-youtube' }, 'YouTube'));2558 if (!ch) return;2559 const busy = ytBusy(ch);2560 if (ch.error) k.append(h('span', { class: 'err', title: ch.error, text: ch.error }));2561 else if (busy) {2562 const since = (ch.sources || []).map((s) => s.listingSince).filter(Boolean).sort()[0];2563 k.append(h('span', { class: 'upd' }, since ? `Listing videos since ${fmtTime(since)}` : 'Listing videos'));2564 }2565 if (!ch.error && (ch.videos || !busy)) {2566 const all = ch.videos || 0, on = ch.items || 0;2567 if (ch.youtube && ch.youtube.playlist) k.append(h('span', { text: `${plural(on, 'video', 'videos')} in order` }));2568 else k.append(h('span', { text: on === all ? `${plural(all, 'video', 'videos')} in rotation` : `${on.toLocaleString()} of ${plural(all, 'video', 'videos')} in rotation` }));2569 }2570 if (ch.now && ch.now.title) {2571 k.append(h('span', { class: 'now', title: [ch.now.title, ch.now.subtitle].filter(Boolean).join(': ') },2572 h('b', null, 'Now'), ch.now.title,2573 ch.now.subtitle ? h('em', null, ', ' + ch.now.subtitle) : null,2574 ch.now.end ? ` until ${fmtTime(ch.now.end)}` : null));2575 }2576 }25772578 // The state changed: sources' listings, what's on. When listing ends, the schedule fills in.2579 function ytStateChanged() {2580 const busy = ytBusy(S.ch);2581 ytKicker();2582 ytRenderSources();2583 if (S.yt.wasBusy && !busy && !isNew()) loadSchedule(S.schedToken);2584 else renderSchedule();2585 S.yt.wasBusy = busy;2586 ytWatch();2587 }25882589 // While a source is being listed, the state is read every few seconds.2590 function ytWatch() {2591 clearTimeout(S.yt.timer);2592 if (!E.yt || isNew() || !ytBusy(S.ch)) return;2593 S.yt.timer = setTimeout(() => { if (E.yt) refreshState(); }, YT_POLL);2594 }25952596 function ytListingMsg() {2597 if (S.draft.ytMode === 'playlist') {2598 const lk = S.yt.pls.get(S.draft.playlist), n = (lk && lk.data && lk.data.videos) || 0;2599 const mins = Math.round(n / YT_LIST_RATE / 60);2600 return mins > 1 ? `Listing the playlist's ${n.toLocaleString()} videos; this takes about ${mins} minutes.` : 'Listing the playlist from YouTube; this takes a few seconds.';2601 }2602 const busy = ((S.ch && S.ch.sources) || []).filter(ytBusySrc).map((s) => ytInfo(s.channel));2603 const total = busy.reduce((n, i) => n + i.total, 0);2604 const big = busy.reduce((a, i) => (!a || i.total > a.total ? i : a), null);2605 const secs = total / YT_LIST_RATE;2606 const how = !total ? 'this takes a minute or so, longer for big channels'2607 : secs < 90 ? 'this takes about a minute' : `this takes about ${Math.round(secs / 60)} minutes`;2608 if (big && big.total >= 1000) return `Listing videos; ${big.name} has about ${big.total.toLocaleString()}, ${how}.`;2609 return `Listing videos from YouTube; ${how}.`;2610 }26112612 function ytNothingOn() {2613 const ch = S.ch, srcs = (ch && ch.sources) || [];2614 if (S.draft.ytMode === 'playlist') {2615 if (!S.draft.playlist) return "Nothing scheduled. Paste a playlist's link and save.";2616 if (ytBusy(ch)) return ytListingMsg();2617 if (ch && ch.videos && !ch.items) return "None of the playlist's videos can play yet: private, deleted, members-only and upcoming ones are left out.";2618 if (S.refreshing) return 'Building the schedule.';2619 if (srcs.length && srcs.every((s) => s.failed)) return "Couldn't list the playlist, so nothing's scheduled yet. The server tries again within the hour.";2620 return 'Nothing scheduled yet. The server may still be loading this channel.';2621 }2622 if (!S.draft.sources.length) return 'Nothing scheduled. Add a YouTube channel and save.';2623 if (ytBusy(ch)) return ytListingMsg();2624 // Known from the channel's state at once, without waiting on the schedule.2625 const age = Number(ch && ch.youtube && ch.youtube.maxAgeDays) || 0;2626 if (ch && ch.videos && !ch.items && age) return `Nothing from the last ${plural(age, 'day', 'days')} fits, so the channel is off the air until a new upload does. Allow older videos to fill the schedule.`;2627 if (ch && ch.videos && !ch.items) return "None of the videos fit the length limits. Widen them to fill the schedule.";2628 if (S.refreshing) return 'Building the schedule.';2629 if (srcs.length && srcs.every((s) => s.failed)) return "Couldn't list the channels' videos, so nothing's scheduled yet. The server tries again within the hour.";2630 return 'Nothing scheduled yet. The server may still be loading this channel.';2631 }26322633 function ytRenderSources() {2634 if (!E.picks) return;2635 if (S.draft.ytMode === 'playlist') { ytRenderPlaylist(); return; }2636 const d = S.draft, box = E.picks;2637 const buttons = [...box.querySelectorAll('button')];2638 const focusIdx = buttons.indexOf(document.activeElement);2639 E.picksCount.textContent = d.sources.length;2640 if (!d.sources.length) {2641 box.replaceChildren(h('p', { class: 'picks-empty' }, 'Nothing added yet. Search for a YouTube channel, or paste its link.'));2642 } else {2643 box.replaceChildren(h('ul', { class: 'ysrc-list' }, d.sources.map((ref) => {2644 const i = ytInfo(ref), st = i.st;2645 let status = null, bad = false;2646 if (i.lookErr && !(st && st.listed)) {2647 bad = true;2648 status = h('span', { class: 'bad', title: i.lookErr, text: `Can't play: ${i.lookErr}` });2649 } else if (st && st.failed) {2650 bad = true;2651 status = h('span', { class: 'bad', title: st.error || '', text: st.error ? `Couldn't list: ${st.error}` : "Couldn't list its videos; trying again soon" });2652 } else if (st && ytBusySrc(st)) {2653 status = h('span', { class: 'upd' }, 'Listing');2654 } else if (st && st.listed) {2655 status = h('span', { title: `${(st.playable || 0).toLocaleString()} fit the length and age limits`, text: plural(st.videos || 0, 'video', 'videos') });2656 } else if (i.total) {2657 status = h('span', { text: `about ${plural(i.total, 'video', 'videos')}` });2658 } else if (i.looking) {2659 status = h('span', { class: 'wait', text: 'looking up' });2660 }2661 return h('li', { class: 'ysrc' + (bad ? ' bad' : '') },2662 avatar(i.name, i.image, true),2663 h('div', { class: 'ys-body' },2664 h('div', { class: 'ys-name', title: ref, text: i.name }),2665 h('div', { class: 'ys-meta' }, i.handle && i.handle !== i.name ? h('span', { text: i.handle }) : null, status)),2666 h('button', { class: 'x', type: 'button', 'aria-label': `Remove ${i.name}`, title: 'Remove', onclick: () => ytRemove(ref) }, '×'));2667 })));2668 }2669 if (focusIdx >= 0) {2670 const now = box.querySelectorAll('button');2671 const t = now[Math.min(focusIdx, now.length - 1)];2672 (t || E.search).focus();2673 }2674 }26752676 const ytFind = (c) => S.draft.sources.find((r) => ytKey(r) === ytKey(c.url) || (c.id && ytInfo(r).id === c.id));26772678 function ytAdd(c) {2679 const d = S.draft;2680 if (ytFind(c) || d.sources.length >= YT_MAX_SOURCES) return;2681 d.sources.push(c.url);2682 const cur = S.yt.info.get(ytKey(c.url));2683 if (!cur || !cur.full) S.yt.info.set(ytKey(c.url), { status: 'ok', data: c });2684 ytLookup(c.url);2685 // A new channel takes the name of the first channel added to it.2686 ytNameFrom(c.name);2687 ytSourcesChanged();2688 }26892690 // A new channel without a name takes one: its first channel's, or its playlist's.2691 function ytNameFrom(name) {2692 const d = S.draft;2693 if (!isNew() || d.name.trim() || !name) return;2694 d.name = String(name).replace(/[\/\\]/g, ' ').replace(/^[.\s]+/, '').trim().slice(0, 48);2695 E.name.value = d.name;2696 S.touched.name = false;2697 const li = $(`#channels .ch[data-number="${S.sel}"]`);2698 if (li) $('.ch-name', li).textContent = d.name || 'New channel';2699 if (!squash(d.callSign)) renderLogo();2700 }27012702 function ytRemove(ref) {2703 S.draft.sources = S.draft.sources.filter((r) => r !== ref);2704 ytSourcesChanged();2705 }27062707 function ytSourcesChanged() {2708 ytRenderSources();2709 ytRenderResults();2710 renderSchedule();2711 changed();2712 }27132714 let ytInputTimer = 0;2715 function ytOnInput() {2716 clearTimeout(ytInputTimer);2717 const v = E.search.value.trim();2718 if (!v) {2719 S.yt.token++;2720 S.yt.view = null;2721 ytRenderResults();2722 } else if (YT_LINK.test(v)) {2723 // A pasted link is looked up at once; words wait for Enter.2724 ytInputTimer = setTimeout(() => ytSearch(v), 250);2725 }2726 }27272728 function ytSearchKeys(e) {2729 if (e.key === 'Escape') {2730 if (E.search.value) {2731 e.preventDefault();2732 E.search.value = '';2733 ytOnInput();2734 } else {2735 E.search.blur();2736 }2737 } else if (e.key === 'ArrowDown') {2738 const b = E.ytRes.querySelector('button');2739 if (b) { e.preventDefault(); b.focus(); }2740 }2741 }27422743 async function ytSearch(q) {2744 q = String(q || '').trim();2745 clearTimeout(ytInputTimer);2746 if (!q) { S.yt.view = null; ytRenderResults(); return; }2747 const link = YT_LINK.test(q);2748 const key = (link ? 'u:' : 'q:') + q.toLowerCase();2749 const token = ++S.yt.token;2750 if (link && YT_PLAYLIST.test(q)) {2751 S.yt.view = { q, link, status: 'playlist', items: [] };2752 ytRenderResults();2753 return;2754 }2755 const cached = S.yt.searches.get(key);2756 if (cached) {2757 S.yt.view = { q, link, status: 'ok', items: cached };2758 ytRenderResults();2759 return;2760 }2761 S.yt.view = { q, link, status: 'loading', items: [] };2762 ytRenderResults();2763 try {2764 let items;2765 if (link) {2766 const c = await api('GET', `/youtube/channel?u=${enc(q)}`);2767 items = [c];2768 S.yt.info.set(ytKey(c.url), { status: 'ok', data: c, full: true });2769 } else {2770 const r = await api('GET', `/youtube/search?q=${enc(q)}`);2771 items = Array.isArray(r && r.channels) ? r.channels : [];2772 for (const c of items) {2773 const cur = S.yt.info.get(ytKey(c.url));2774 if (!cur || !cur.full) S.yt.info.set(ytKey(c.url), { status: 'ok', data: c });2775 }2776 }2777 S.yt.searches.set(key, items);2778 if (token !== S.yt.token || !E.yt) return;2779 S.yt.view = { q, link, status: 'ok', items };2780 } catch (e) {2781 if (token !== S.yt.token || !E.yt) return;2782 S.yt.view = { q, link, status: 'error', items: [], error: e.message };2783 // yt-dlp being installed: try again shortly.2784 if (e.status === 503 && /being installed/.test(e.message)) {2785 setTimeout(() => { if (token === S.yt.token && E.yt) ytSearch(q); }, 8000);2786 }2787 }2788 ytRenderResults();2789 }27902791 function ytRow(c) {2792 const added = !!ytFind(c);2793 const full = S.draft.sources.length >= YT_MAX_SOURCES;2794 const info = S.yt.info.get(ytKey(c.url));2795 const total = (info && info.data && info.data.videos) || c.videos;2796 const meta = [];2797 if (c.handle) meta.push(h('span', { text: c.handle }));2798 if (c.subscribers) meta.push(h('span', { text: `${compact(c.subscribers)} subscribers` }));2799 if (total) meta.push(h('span', { text: `about ${plural(total, 'video', 'videos')}` }));2800 return h('li', { class: 'ytrow' + (added ? ' added' : '') },2801 avatar(c.name, c.image),2802 h('div', { class: 'yr-body' },2803 h('div', { class: 'yr-name', dir: 'auto', text: c.name }),2804 meta.length ? h('div', { class: 'yr-meta' }, meta) : null,2805 c.description ? h('p', { class: 'yr-desc', dir: 'auto', text: c.description }) : null),2806 h('button', {2807 class: 'btn small' + (added ? ' ghost' : ''), type: 'button', 'aria-pressed': added, 'aria-label': `Add ${c.name}`,2808 disabled: !added && full, title: !added && full ? `A channel plays up to ${YT_MAX_SOURCES} YouTube channels` : null,2809 onclick: () => { const r = ytFind(c); if (r) ytRemove(r); else ytAdd(c); },2810 }, added ? 'Added' : 'Add'));2811 }28122813 function ytTry(q) {2814 E.search.value = q;2815 ytSearch(q);2816 }28172818 function ytRenderResults() {2819 if (!E.ytRes) return;2820 const v = S.yt.view;2821 const rows = [...E.ytRes.children];2822 const focusIdx = rows.findIndex((r) => r.contains(document.activeElement));2823 E.ytRes.replaceChildren();2824 E.ytMsg.replaceChildren();2825 E.ytCount.textContent = '';2826 if (!v) {2827 E.ytMsg.append(2828 h('p', { class: 'big' }, 'Find channels to play'),2829 h('p', null, 'Search YouTube by name or topic, like ',2830 h('button', { class: 'linkish', type: 'button', onclick: () => ytTry('boiler room') }, 'boiler room'), ' or ',2831 h('button', { class: 'linkish', type: 'button', onclick: () => ytTry('dj sets') }, 'dj sets'),2832 ", or paste a channel's link."),2833 h('p', null, "The channels' uploads take turns on the air, and new uploads air first. Nothing is downloaded: videos stream from YouTube as they air."));2834 return;2835 }2836 if (v.status === 'loading') {2837 E.ytMsg.append(h('p', null, v.link ? 'Looking up the channel on YouTube' : `Searching YouTube for “${v.q}”`));2838 for (let i = 0; i < (v.link ? 1 : 6); i++) {2839 E.ytRes.append(h('li', { class: 'ytrow skel-row', 'aria-hidden': 'true' }, h('span', { class: 'av' }), h('span', { class: 'skl' })));2840 }2841 return;2842 }2843 if (v.status === 'playlist') {2844 // A playlist's link, pasted to find channels: offer to play the playlist instead.2845 E.ytMsg.append(2846 h('p', { class: 'big' }, "That's a playlist's link."),2847 h('p', null, "This channel can play the playlist in its order, in place of channels' uploads."),2848 h('div', { class: 'row' }, h('button', { class: 'btn', type: 'button', onclick: () => { S.draft.plLink = v.q; ytSetMode('playlist'); } }, 'Play the playlist')));2849 return;2850 }2851 if (v.status === 'error') {2852 E.ytMsg.append(2853 h('p', { class: 'big err' }, v.link ? "Couldn't look up that channel." : "Couldn't search YouTube."),2854 h('p', null, v.error),2855 h('div', { class: 'row' }, h('button', { class: 'btn', type: 'button', onclick: () => ytSearch(v.q) }, 'Try again')));2856 return;2857 }2858 if (!v.items.length) {2859 E.ytMsg.append(h('p', { class: 'big' }, 'No channels found.'),2860 h('p', null, `Nothing on YouTube matches “${v.q}”. Try other words, or paste the channel's link.`));2861 return;2862 }2863 if (!v.link) E.ytCount.textContent = plural(v.items.length, 'channel', 'channels');2864 for (const c of v.items) E.ytRes.append(ytRow(c));2865 if (focusIdx >= 0) {2866 const b = E.ytRes.children[Math.min(focusIdx, E.ytRes.children.length - 1)];2867 if (b) b.querySelector('button').focus();2868 }2869 }28702871 // ---------- Main: YouTube playlist ----------2872 // A YouTube channel may play a playlist in its order instead of channels' uploads. Its link is2873 // looked up as it's pasted, for the playlist's title, its channel and how many videos it has.2874 function ytPlaylistPanel() {2875 E.ytPl = h('input', {2876 class: 'input', id: 'yt-playlist', value: S.draft.plLink, placeholder: 'youtube.com/playlist?list=...', autocomplete: 'off', spellcheck: 'false',2877 'aria-describedby': 'yt-pl-help', oninput: ytPlInput,2878 });2879 E.ytPlMsg = h('div', { class: 'gmsg', 'aria-live': 'polite' });2880 return h('section', { class: 'lib ytfind ytpl', 'aria-label': 'YouTube playlist' },2881 h('form', { class: 'fbar', onsubmit: (e) => { e.preventDefault(); ytPlLookup(E.ytPl.value, true); } },2882 h('label', { class: 'label', for: 'yt-playlist' }, "The playlist's link"),2883 h('div', { class: 'fbar-row' },2884 h('div', { class: 'search pl-link' }, E.ytPl),2885 h('button', { class: 'btn', type: 'submit' }, 'Look up')),2886 h('p', { class: 'help', id: 'yt-pl-help' }, 'Or the link of a video played from it: either has list= in it.')),2887 h('div', { class: 'scroller' }, E.ytPlMsg));2888 }28892890 let ytPlTimer = 0;2891 function ytPlInput() {2892 const d = S.draft;2893 d.plLink = E.ytPl.value;2894 d.playlist = d.plLink.trim();2895 clearTimeout(ytPlTimer);2896 if (YT_PLAYLIST.test(d.playlist)) ytPlTimer = setTimeout(() => ytPlLookup(d.playlist), 250);2897 ytRenderPlaylist();2898 renderSchedule();2899 changed();2900 }29012902 // Looks up a playlist once; again asks again after a failure.2903 function ytPlLookup(link, again) {2904 link = String(link || '').trim();2905 clearTimeout(ytPlTimer);2906 const cur = S.yt.pls.get(link);2907 if (!YT_PLAYLIST.test(link) || (cur && (cur.status !== 'error' || !again))) { ytRenderPlaylist(); return; }2908 S.yt.pls.set(link, { status: 'loading' });2909 ytRenderPlaylist();2910 api('GET', `/youtube/playlist?u=${enc(link)}`).then((p) => {2911 S.yt.pls.set(link, { status: 'ok', data: p || {} });2912 // A new channel takes the playlist's name.2913 if (E.ytPl && S.draft.playlist === link && p && isNew() && !S.draft.name.trim()) { ytNameFrom(p.title); changed(); }2914 }, (e) => {2915 S.yt.pls.set(link, { status: 'error', error: e.message });2916 // yt-dlp being installed: try again shortly.2917 if (e.status === 503 && /being installed/.test(e.message)) {2918 setTimeout(() => { if (E.ytPl && S.draft.playlist === link) ytPlLookup(link, true); }, 8000);2919 }2920 }).finally(() => {2921 if (E.ytPl) { ytRenderPlaylist(); renderSchedule(); }2922 });2923 }29242925 // A playlist's picture: its first video's thumbnail.2926 function plThumb(image, small) {2927 const el = h('span', { class: 'plthumb' + (small ? ' sm' : ''), 'aria-hidden': 'true' });2928 if (image) {2929 el.append(h('img', { src: image, alt: '', loading: 'lazy', decoding: 'async', referrerpolicy: 'no-referrer', draggable: 'false',2930 onerror: (e) => e.target.remove() }));2931 }2932 return el;2933 }29342935 function ytPlCard(p) {2936 const meta = [];2937 if (p.owner) meta.push(h('span', { text: p.owner }));2938 if (p.videos) meta.push(h('span', { text: plural(p.videos, 'video', 'videos') }));2939 return h('div', { class: 'plcard' }, plThumb(p.image),2940 h('div', { class: 'yr-body' },2941 h('div', { class: 'yr-name', dir: 'auto', text: p.title || 'Untitled playlist' }),2942 meta.length ? h('div', { class: 'yr-meta' }, meta) : null,2943 p.url ? h('a', { class: 'linkish', href: p.url, target: '_blank', rel: 'noopener noreferrer' }, 'Open on YouTube') : null));2944 }29452946 // The playlist: on the left, what a lookup of its link found, or why it won't do; on the right,2947 // the playlist as the server has it once saved, and how its listing is going.2948 function ytRenderPlaylist() {2949 const d = S.draft, link = d.playlist;2950 const lk = (link && S.yt.pls.get(link)) || null;2951 const found = lk && lk.status === 'ok' ? lk.data : null;2952 const cfg = S.ch && S.ch.youtube;2953 const st = cfg && cfg.playlist && ytListId(cfg.playlist) === ytListId(link) ? (S.ch.sources || [])[0] || null : null;2954 if (E.ytPlMsg) {2955 const how = [2956 h('p', null, "Its videos play from the top in the playlist's order, then again from the first. Videos added to it join within a day, or when you save."),2957 h('p', null, 'Under Airs, a schedule can air it only at set times, like four episodes a night from the first, each night picking up where the last left off.'),2958 h('p', null, 'Nothing is downloaded: videos stream from YouTube as they air.'),2959 ];2960 const box = E.ytPlMsg;2961 if (!link) {2962 box.replaceChildren(h('p', { class: 'big' }, 'Play a playlist in order'),2963 h('p', null, "Paste a playlist's link above: a season someone put together, a course, a band's live sets."), ...how);2964 } else if (!YT_PLAYLIST.test(link)) {2965 box.replaceChildren(h('p', { class: 'big err' }, "That isn't a playlist's link."),2966 h('p', null, "A playlist's link has list= in it, like youtube.com/playlist?list=PL..., and so does the link of a video played from a playlist."));2967 } else if (lk && lk.status === 'error') {2968 box.replaceChildren(h('p', { class: 'big err' }, "Couldn't look up that playlist."), h('p', null, lk.error),2969 h('div', { class: 'row' }, h('button', { class: 'btn', type: 'button', onclick: () => ytPlLookup(link, true) }, 'Try again')));2970 } else if (!found) {2971 box.replaceChildren(h('p', null, 'Looking up the playlist on YouTube'),2972 h('div', { class: 'plcard skel-row', 'aria-hidden': 'true' }, plThumb(''), h('span', { class: 'skl' })));2973 } else {2974 box.replaceChildren(ytPlCard(found), ...how);2975 }2976 }2977 E.picksCount.textContent = '';2978 if (!link) {2979 E.picks.replaceChildren(h('p', { class: 'picks-empty' }, "No playlist yet. Paste its link."));2980 return;2981 }2982 const name = (st && st.name) || (found && found.title) || link.replace(/^https?:\/\/(www\.)?/i, '');2983 const owner = (st && st.owner) || (found && found.owner) || '';2984 let status = null, bad = false;2985 if (st && st.failed) {2986 bad = true;2987 status = h('span', { class: 'bad', title: st.error || '', text: st.error ? `Couldn't list: ${st.error}` : "Couldn't list its videos; trying again soon" });2988 } else if (st && ytBusySrc(st)) {2989 status = h('span', { class: 'upd' }, 'Listing');2990 } else if (st && st.listed) {2991 const waiting = (st.videos || 0) - (st.playable || 0);2992 status = h('span', { title: waiting > 0 ? `${plural(waiting, 'more is', 'more are')} upcoming, live, or without a length yet` : null, text: plural(st.playable || 0, 'video', 'videos') });2993 } else if (lk && lk.status === 'error') {2994 bad = true;2995 status = h('span', { class: 'bad', title: lk.error, text: `Can't play: ${lk.error}` });2996 } else if (found && found.videos) {2997 status = h('span', { text: `about ${plural(found.videos, 'video', 'videos')}` });2998 } else if (lk && lk.status === 'loading') {2999 status = h('span', { class: 'wait', text: 'looking up' });3000 }3001 E.picks.replaceChildren(h('ul', { class: 'ysrc-list' }, h('li', { class: 'ysrc pl' + (bad ? ' bad' : '') },3002 plThumb((st && st.image) || (found && found.image), true),3003 h('div', { class: 'ys-body' },3004 h('div', { class: 'ys-name', dir: 'auto', title: link, text: name }),3005 h('div', { class: 'ys-meta' }, owner ? h('span', { text: owner }) : null, status)))));3006 }30073008 // ---------- Schedule ----------3009 async function loadSchedule(token) {3010 const num = S.origNumber;3011 if (!num || num === 'new') return;3012 if (S.sched.number !== num) S.sched = { number: num, status: 'loading', programs: [], error: '' };3013 renderSchedule();3014 try {3015 const r = await api('GET', `/channels/${enc(num)}/schedule`);3016 if (token !== S.schedToken || S.origNumber !== num) return;3017 S.sched = { number: num, status: 'ok', programs: Array.isArray(r && r.programs) ? r.programs : [], error: '' };3018 } catch (e) {3019 if (token !== S.schedToken || S.origNumber !== num) return;3020 S.sched = { number: num, status: 'error', programs: [], error: e.message };3021 }3022 renderSchedule();3023 }30243025 function startScheduleRefresh() {3026 const token = ++S.schedToken;3027 S.refreshing = true;3028 renderSchedule();3029 let i = 0;3030 const step = async () => {3031 if (token !== S.schedToken) return;3032 await Promise.all([loadSchedule(token), refreshState()]);3033 if (token !== S.schedToken) return;3034 i++;3035 if (i < SCHEDULE_POLLS.length) setTimeout(step, SCHEDULE_POLLS[i] - SCHEDULE_POLLS[i - 1]);3036 else { S.refreshing = false; renderSchedule(); }3037 };3038 setTimeout(step, SCHEDULE_POLLS[0]);3039 }30403041 const dayKey = (d) => `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;3042 function dayLabel(d) {3043 const t = new Date();3044 t.setDate(t.getDate() + 1);3045 if (dayKey(d) === dayKey(t)) return 'Tomorrow';3046 return d.toLocaleDateString([], { weekday: 'long' });3047 }30483049 function renderSchedule() {3050 if (!E.sched) return;3051 E.schedHead.replaceChildren(S.refreshing ? h('span', { class: 'upd' }, 'Updating') : '');3052 const box = E.sched;3053 const msg = (t, err) => h('p', { class: 'sched-msg' + (err ? ' err' : '') }, t);3054 if (isNew()) { box.replaceChildren(msg('The schedule shows up here once the channel is created.')); return; }3055 const s = S.sched;3056 if (s.number !== S.origNumber || s.status === 'loading' || s.status === 'idle') { box.replaceChildren(msg('Loading the schedule')); return; }3057 if (s.status === 'error') { box.replaceChildren(msg(`Couldn't load the schedule: ${s.error}`, true)); return; }3058 const now = Date.now();3059 const progs = s.programs.filter((p) => !(Date.parse(p.end) <= now));3060 if (!progs.length && E.yt) { box.replaceChildren(msg(ytNothingOn())); return; }3061 if (!progs.length && E.metaOnly) {3062 box.replaceChildren(msg(S.draft.kind === 'folder' ? 'Nothing scheduled: there are no playable videos in the folder yet.' : 'Nothing scheduled.'));3063 return;3064 }3065 if (!progs.length) {3066 const nothing = KINDS.every((k) => !S.draft[k].size);3067 box.replaceChildren(msg(S.refreshing3068 ? 'Building the schedule from the new picks.'3069 : nothing ? 'Nothing scheduled. Pick something to play and save.'3070 : 'Nothing scheduled yet. The server may still be loading this channel.'));3071 return;3072 }3073 const ol = h('ol', { class: 'prog-list' });3074 let day = dayKey(new Date());3075 for (const p of progs) {3076 const st = new Date(p.start);3077 if (!isNaN(st) && dayKey(st) !== day) {3078 day = dayKey(st);3079 ol.append(h('li', { class: 'prog-day' }, dayLabel(st)));3080 }3081 const on = Date.parse(p.start) <= now && now < Date.parse(p.end);3082 ol.append(h('li', { class: 'prog' + (on ? ' now' : '') + (p.offAir ? ' off' : '') },3083 h('time', { datetime: p.start }, on ? 'Now' : fmtTime(p.start)),3084 h('div', { class: 'pr-body' },3085 h('div', { class: 't', text: p.title || 'Untitled' }),3086 p.subtitle ? h('div', { class: 's', text: p.subtitle }) : null)));3087 }3088 if (E.yt && ytBusy(S.ch)) box.replaceChildren(msg(ytListingMsg()), ol);3089 else box.replaceChildren(ol);3090 }30913092 // ---------- Save and delete ----------3093 async function save() {3094 if (!E.save || E.save.disabled || S.saving) return;3095 const fresh = isNew();3096 const kind = S.draft.kind;3097 const yt = kind === 'youtube', metaOnly = kind === 'folder' || kind === 'weather';3098 const sent = sig(S.draft);3099 const body = bodyOf(S.draft);3100 const path = fresh ? 'new' : S.origNumber;3101 S.saving = true;3102 S.saveMsg = null;3103 updateDirty();3104 let saved;3105 try {3106 saved = await api('PUT', `/channels/${enc(path)}`, body);3107 } catch (e) {3108 S.saving = false;3109 if (e.status === 409) S.serverErr.number = e.message;3110 else if (e.status === 400 && /^the schedule/i.test(e.message)) S.serverErr.sched = e.message;3111 else if (e.status === 400 && /number/i.test(e.message)) S.serverErr.number = e.message;3112 else if (e.status === 400 && /call sign|category|description/i.test(e.message)) S.serverErr.meta = e.message;3113 else if (e.status === 400 && /name/i.test(e.message)) S.serverErr.name = e.message;3114 else S.saveMsg = { text: `Couldn't save: ${e.message}`, cls: 'err' };3115 updateDirty();3116 return;3117 }3118 const ch = saved && typeof saved === 'object' && saved.number ? saved : { number: body.number, name: body.name, ...metaBody(S.draft) };3119 ch.kind = ch.kind || kind;3120 if (metaOnly) ch.folder = ch.folder || (S.ch && S.ch.folder);3121 if (yt && !ch.youtube) ch.youtube = body.youtube;3122 if (kind === 'jellyfin' && !ch.config) {3123 ch.config = { order: body.order, maxBitrate: body.maxBitrate };3124 for (const k of KINDS) ch.config[k] = body[k];3125 }3126 const list = S.state.channels;3127 const i = fresh ? -1 : list.findIndex((c) => c.number === S.origNumber);3128 if (i >= 0) list[i] = ch; else list.push(ch);3129 list.sort((a, b) => cmpNum(a.number, b.number));31303131 S.saving = false;3132 S.ch = ch;3133 S.origNumber = ch.number;3134 S.sel = ch.number;3135 const fromServer = yt ? ytDraftFrom(ch) : metaOnly ? metaDraftFrom(ch) : draftFrom(ch);3136 // Keep anything typed while the request was in flight.3137 if (sig(S.draft) === sent) S.draft = fromServer;3138 S.base = sig(fromServer);3139 S.touched = {};3140 S.serverErr = {};3141 S.saveMsg = { text: fresh ? 'Created' : 'Saved', cls: 'ok' };3142 history.replaceState(null, '', hashFor(ch.number));31433144 if (E.num.value !== S.draft.number) { E.num.value = S.draft.number; sizeNum(); }3145 if (E.name.value !== S.draft.name) E.name.value = S.draft.name;3146 E.del.hidden = metaOnly;3147 E.save.textContent = 'Save changes';3148 syncMetaInputs();3149 fitDesc();3150 schedSaved();3151 if (yt) {3152 ytSyncSettings();3153 ytRenderSources();3154 ytRenderResults();3155 } else if (metaOnly) {3156 renderMetaInfo();3157 } else {3158 syncCards();3159 updateTabs();3160 renderPicks();3161 }3162 updateKicker();3163 renderChannels();3164 updateDirty();3165 setTitle();3166 toast(`${fresh ? 'Created' : 'Saved'} ${ch.number} ${ch.name}`);3167 S.sched = { number: null, status: 'idle', programs: [], error: '' };3168 startScheduleRefresh();3169 if (yt) {3170 S.yt.wasBusy = ytBusy(ch);3171 ytWatch();3172 }3173 }31743175 async function deleteChannel() {3176 const ch = S.ch;3177 if (!ch) return;3178 const ok = await confirmDialog({3179 title: `Delete ${ch.number} ${ch.name}?`,3180 body: ch.kind === 'youtube'3181 ? 'It comes off the lineup right away, with its list of videos and its schedule. Nothing changes on YouTube.'3182 : 'It comes off the lineup right away and its picks are lost. Nothing changes on the Jellyfin server.',3183 ok: 'Delete channel', cancel: 'Cancel', danger: true,3184 });3185 if (!ok) return;3186 E.del.disabled = true;3187 try {3188 await api('DELETE', `/channels/${enc(ch.number)}`);3189 } catch (e) {3190 E.del.disabled = false;3191 S.saveMsg = { text: `Couldn't delete: ${e.message}`, cls: 'err' };3192 updateDirty();3193 return;3194 }3195 S.state.channels = S.state.channels.filter((c) => c.number !== ch.number);3196 S.draft = null;3197 history.pushState(null, '', hashFor(null));3198 openTarget(null);3199 toast(`Deleted ${ch.number} ${ch.name}`);3200 const btn = $('#main .btn');3201 if (btn) btn.focus();3202 }32033204 // ---------- Backups ----------3205 // Copies of the channel setup the server keeps (internal/admin/backups.go and restore.go): a3206 // line under the account with the latest, and a page (#backups) to back up now, upload a3207 // backup, download, delete and restore, with a dialog that says what a restore changes first.3208 const BACKUPS = 'backups';3209 const BK_MAX = 16 << 20;3210 const BK_REASON = {3211 change: { label: 'After a change', help: 'Made by itself 30 seconds after changes here or by an agent' },3212 daily: { label: 'Daily', help: 'Made by itself once a day, when the setup changed some other way' },3213 restore: { label: 'Before a restore', help: 'The setup just before a restore: restore this to undo it' },3214 manual: { label: 'By hand', help: 'Kept until you delete it' },3215 uploaded: { label: 'Uploaded', help: 'Kept until you delete it' },3216 };3217 const BK_PARTS = { settings: 'settings', details: 'details', logo: 'logo', schedule: 'schedule' };3218 const BK = { list: null, err: null, folder: '', pending: false, keep: null, loading: null, timer: 0, busy: '', r: null };32193220 function bkAgo(t) {3221 const s = (Date.now() - Date.parse(t)) / 1000;3222 if (!(s >= 60)) return 'just now';3223 if (s < 3600) return `${Math.floor(s / 60)} min ago`;3224 if (s < 86400) return plural(Math.floor(s / 3600), 'hour', 'hours') + ' ago';3225 return plural(Math.floor(s / 86400), 'day', 'days') + ' ago';3226 }3227 function bkWhen(t) {3228 const d = new Date(t);3229 if (isNaN(d)) return '';3230 const now = new Date();3231 const day = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();3232 const days = Math.round((day(now) - day(d)) / 86400000);3233 const time = fmtTime(d);3234 if (days === 0) return `Today ${time}`;3235 if (days === 1) return `Yesterday ${time}`;3236 if (days > 1 && days < 7) return `${d.toLocaleDateString([], { weekday: 'long' })} ${time}`;3237 return `${d.toLocaleDateString([], { month: 'short', day: 'numeric', year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric' })}, ${time}`;3238 }3239 const bkSize = (n) => (n >= 1 << 20 ? `${(n / (1 << 20)).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`);3240 const bkReason = (b) => BK_REASON[b.reason] || { label: b.reason || 'Backup', help: '' };3241 const bkList = (names) => (names.length < 2 ? names.join('') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`);32423243 // What a backup holds, in a line.3244 function bkContents(b) {3245 const n = { jellyfin: 0, youtube: 0, folder: 0 };3246 for (const f of b.folders || []) n[f.kind] = (n[f.kind] || 0) + 1;3247 const kinds = [];3248 if (n.jellyfin) kinds.push(`${n.jellyfin} Jellyfin`);3249 if (n.youtube) kinds.push(`${n.youtube} YouTube`);3250 if (n.folder) kinds.push(plural(n.folder, 'folder', 'folders'));3251 if (b.weather) kinds.push('weather');3252 let s = plural((b.folders || []).length + (b.weather ? 1 : 0), 'channel', 'channels');3253 if (kinds.length) s += ` (${kinds.join(', ')})`;3254 if (b.account) s += ', the Jellyfin account';3255 return `${s}. ${bkSize(b.size || 0)}`;3256 }32573258 function loadBackups() {3259 if (BK.loading) return BK.loading;3260 BK.loading = (async () => {3261 try {3262 const r = await api('GET', '/backups');3263 BK.list = Array.isArray(r && r.backups) ? r.backups : [];3264 BK.folder = (r && r.folder) || '';3265 BK.pending = !!(r && r.pending);3266 BK.keep = (r && r.keep) || null;3267 BK.err = null;3268 } catch (e) {3269 BK.err = e;3270 } finally {3271 BK.loading = null;3272 }3273 renderBackupLine();3274 if (E.backups) renderBackupList();3275 bkTick();3276 })();3277 return BK.loading;3278 }3279 // The list is looked at again now and then: often while a backup is on its way or the page is open.3280 function bkTick() {3281 clearTimeout(BK.timer);3282 const soon = BK.pending || S.sel === BACKUPS;3283 BK.timer = setTimeout(() => { if (document.hidden) bkTick(); else loadBackups(); }, soon ? 6000 : 30000);3284 }32853286 // The side line: the latest backup, or that one is on its way.3287 function renderBackupLine() {3288 const box = $('#bk-side');3289 if (!box) return;3290 let state = '', text = 'Loading';3291 if (BK.err) {3292 state = 'err';3293 text = BK.err.status === 503 ? 'Off on this server' : "Can't list them";3294 } else if (BK.list) {3295 const latest = BK.list.find((b) => !b.error);3296 if (BK.pending) { state = 'wait'; text = 'Backing up your changes'; }3297 else if (latest) { state = 'ok'; text = `Latest ${bkAgo(latest.created)}`; }3298 else text = 'None yet';3299 }3300 const cur = S.sel === BACKUPS;3301 const had = box.contains(document.activeElement);3302 box.replaceChildren(h('a', {3303 class: 'bk-line', href: '#' + BACKUPS, 'data-number': BACKUPS, 'aria-current': cur ? 'page' : null, onclick: linkNav,3304 },3305 h('span', { class: 'bk-dot' + (state ? ' ' + state : ''), 'aria-hidden': 'true' }),3306 h('span', { class: 'bk-line-body' }, h('b', null, 'Backups'), h('span', { text })),3307 h('span', { class: 'bk-go', 'aria-hidden': 'true' })));3308 if (had) $('.bk-line', box).focus();3309 }33103311 // The page.3312 function renderBackupsPage() {3313 E = { backups: true };3314 E.bkLabel = h('input', {3315 class: 'input', id: 'bk-label', maxlength: '60', autocomplete: 'off', placeholder: 'A note, like "before the big reshuffle"',3316 });3317 E.bkNow = h('button', { class: 'btn', type: 'submit', id: 'bk-now' }, 'Back up now');3318 E.bkFile = h('input', {3319 type: 'file', id: 'bk-file', class: 'visually-hidden', tabindex: '-1', accept: '.gz,.tgz,application/gzip,application/x-gzip',3320 onchange: (e) => { const f = e.target.files[0]; e.target.value = ''; if (f) uploadBackup(f); },3321 });3322 E.bkUp = h('button', { class: 'btn ghost', type: 'button', id: 'bk-upload', onclick: () => E.bkFile.click() }, 'Upload a backup');3323 E.bkMsg = h('p', { class: 'bk-msg', role: 'status' });3324 E.bkList = h('div', { class: 'bk-listing' });3325 E.bkFoot = h('div', { class: 'bk-foot' });3326 $('#main').replaceChildren(h('div', { class: 'bk' },3327 h('header', { class: 'bk-head' },3328 h('h1', null, 'Backups'),3329 h('p', { class: 'bk-lead' }, "Copies of the channel setup: each channel's number, name, settings, details, logo and YouTube schedule, " +3330 'the weather channel, and the Jellyfin account. Videos are never in them. Restoring puts channels back as they were.')),3331 h('form', { class: 'bk-actions', onsubmit: backUpNow },3332 h('label', { class: 'label', for: 'bk-label' }, 'Back up now, with a note'),3333 h('div', { class: 'bk-row-in' }, E.bkLabel, E.bkNow, h('span', { class: 'bk-or' }, 'or'), E.bkUp, E.bkFile)),3334 E.bkMsg, E.bkList, E.bkFoot));3335 renderBackupList();3336 loadBackups();3337 }33383339 function setBkMsg(text, cls) {3340 if (!E.bkMsg) return;3341 E.bkMsg.textContent = text || '';3342 E.bkMsg.className = 'bk-msg' + (cls ? ' ' + cls : '');3343 }33443345 function renderBackupList() {3346 if (!E.backups) return;3347 const off = BK.err && BK.err.status === 503;3348 E.bkNow.disabled = E.bkUp.disabled = E.bkLabel.disabled = !!BK.busy || off;3349 E.bkNow.textContent = BK.busy === 'now' ? 'Backing up' : 'Back up now';3350 E.bkUp.textContent = BK.busy === 'upload' ? 'Uploading' : 'Upload a backup';3351 let body;3352 if (BK.err && !BK.list) {3353 body = h('div', { class: 'bk-empty' },3354 h('p', { class: 'empty-line err' }, off ? 'Backups are off on this server.' : "Can't list the backups."),3355 h('p', { class: 'help' }, BK.err.message));3356 } else if (!BK.list) {3357 body = h('p', { class: 'bk-empty help' }, 'Loading the backups');3358 } else if (!BK.list.length) {3359 body = h('div', { class: 'bk-empty' },3360 h('p', { class: 'empty-line' }, 'No backups yet.'),3361 h('p', { class: 'help' }, 'One is made 30 seconds after your next change, and daily. Or back up now.'));3362 } else {3363 const latest = BK.list.find((b) => !b.error);3364 body = h('ol', { class: 'bk-rows', 'aria-label': 'Backups, newest first' }, BK.list.map((b) => bkRow(b, b === latest)));3365 }3366 const had = E.bkList.contains(document.activeElement) ? document.activeElement : null;3367 const focusFile = had && had.closest('[data-file]') ? had.closest('[data-file]').dataset.file : null;3368 const focusAct = had ? had.dataset.act : null;3369 E.bkList.replaceChildren(body);3370 if (focusFile && focusAct) {3371 const el = E.bkList.querySelector(`[data-file="${CSS.escape(focusFile)}"] [data-act="${focusAct}"]`);3372 if (el) el.focus();3373 }3374 renderBackupFoot();3375 }33763377 function bkRow(b, latest) {3378 const r = bkReason(b);3379 const when = bkWhen(b.created);3380 const what = `the backup from ${when}`;3381 return h('li', { class: 'bk-item-row' + (latest ? ' latest' : '') + (b.error ? ' bad' : ''), 'data-file': b.file },3382 h('div', { class: 'bk-when' }, h('b', { text: when }), h('span', { text: bkAgo(b.created) })),3383 h('div', { class: 'bk-what' },3384 h('div', { class: 'bk-tags' },3385 h('span', { class: 'bk-tag r-' + b.reason, title: r.help, text: r.label }),3386 latest ? h('span', { class: 'bk-tag latest' }, 'Latest') : null,3387 b.older ? h('span', { class: 'bk-tag', title: 'Made by hand before Airwaves made its own backups; it restores the same' }, 'Older format') : null,3388 b.label ? h('span', { class: 'bk-note', text: b.label }) : null),3389 b.error3390 ? h('p', { class: 'bk-sum err' }, `Can't be read: ${b.error}`)3391 : h('p', { class: 'bk-sum' }, bkContents(b))),3392 h('div', { class: 'bk-acts' },3393 h('button', { class: 'btn small ghost', type: 'button', 'data-act': 'restore', disabled: !!b.error, 'aria-label': `Restore from ${what}`, onclick: (e) => openRestore(b, e.currentTarget) }, 'Restore'),3394 h('a', { class: 'btn small ghost', href: `${API}/backups/${enc(b.file)}`, download: b.file, 'data-act': 'download', 'aria-label': `Download ${what}` }, 'Download'),3395 h('button', { class: 'btn small ghost danger', type: 'button', 'data-act': 'delete', 'aria-label': `Delete ${what}`, onclick: () => deleteBackup(b) }, 'Delete')));3396 }33973398 function renderBackupFoot() {3399 const k = BK.keep || { recent: 30, days: 14, delaySeconds: 30 };3400 const folder = BK.folder || '/data/backups';3401 const host = location.hostname || 'the server';3402 E.bkFoot.replaceChildren(3403 h('p', null, `Automatic backups are made ${k.delaySeconds} seconds after a change, before every restore, and once a day when the setup changed some other way. ` +3404 `The newest ${k.recent} are kept, and the newest of each of the last ${k.days} days; older ones are removed. Backups you make or upload stay until you delete them.`),3405 h('p', null, 'They are kept in ', h('code', null, folder), " in the server's container (", h('code', null, '~/airwaves/data/backups'),3406 ' on the host, as scripts/deploy.sh sets it up), readable only by the server. To keep copies elsewhere, download them here, or copy them all off:'),3407 h('code', { class: 'path', title: 'Click to select' }, `rsync -a ${host}:airwaves/data/backups/ ./airwaves-backups/`));3408 }34093410 async function backUpNow(e) {3411 e.preventDefault();3412 if (BK.busy) return;3413 BK.busy = 'now';3414 setBkMsg('');3415 renderBackupList();3416 try {3417 const b = await api('POST', '/backups', { label: squash(E.bkLabel.value) });3418 E.bkLabel.value = '';3419 setBkMsg(`Backed up ${bkContents(b).replace(/\. [\d.]+ [KM]B$/, '')}.`, 'ok');3420 toast('Backed up');3421 } catch (err) {3422 setBkMsg(`Couldn't back up: ${err.message}`, 'err');3423 }3424 BK.busy = '';3425 renderBackupList();3426 await loadBackups();3427 const n = $('#bk-now');3428 if (n) n.focus();3429 }34303431 async function uploadBackup(file) {3432 if (BK.busy) return;3433 if (file.size > BK_MAX) { setBkMsg('A backup is under 16 MB; that file is too big to be one.', 'err'); return; }3434 BK.busy = 'upload';3435 setBkMsg(`Checking ${file.name}`);3436 renderBackupList();3437 let res = null;3438 try {3439 res = await api('POST', `/backups/upload?name=${enc(file.name)}`, file);3440 const skipped = (res.skipped || []).length;3441 setBkMsg((res.existing ? `${file.name} is here already.` : `Uploaded ${file.name}.`) +3442 (skipped === 1 ? " 1 file in it that isn't part of a channel setup was left out." : '') +3443 (skipped > 1 ? ` ${plural(skipped, 'file', 'files')} in it that aren't part of a channel setup were left out.` : ''), 'ok');3444 } catch (err) {3445 setBkMsg(`Couldn't take ${file.name}: ${err.message}`, 'err');3446 }3447 BK.busy = '';3448 renderBackupList();3449 await loadBackups();3450 if (res && res.backup) openRestore(res.backup, $('#bk-upload'));3451 }34523453 async function deleteBackup(b) {3454 const ok = await confirmDialog({3455 title: 'Delete this backup?',3456 body: `The backup from ${bkWhen(b.created)} (${bkReason(b).label.toLowerCase()}${b.label ? ', ' + b.label : ''}) is removed from the server for good.`,3457 ok: 'Delete backup', danger: true,3458 });3459 if (!ok) return;3460 try {3461 await api('DELETE', `/backups/${enc(b.file)}`);3462 toast('Backup deleted');3463 } catch (err) {3464 toast(`Couldn't delete it: ${err.message}`, true);3465 }3466 await loadBackups();3467 const first = $('.bk-rows [data-act="restore"]') || $('#bk-now');3468 if (first) first.focus();3469 }34703471 // ---------- Backups: restore ----------3472 function bkDialog() {3473 let dlg = $('#bk-dlg');3474 if (dlg) return dlg;3475 dlg = h('dialog', { id: 'bk-dlg', class: 'bk-dlg', 'aria-labelledby': 'bk-dlg-title' });3476 // Escape closes it as Cancel does, but not while a restore runs.3477 dlg.addEventListener('cancel', (e) => { e.preventDefault(); closeRestore(); });3478 dlg.addEventListener('close', () => { if (BK.r) closeRestore(); });3479 document.body.append(dlg);3480 return dlg;3481 }34823483 // closeRestore closes the dialog and puts focus back where it was opened from (the list may have3484 // been drawn again since), there and then: a dialog's close event can come late.3485 function closeRestore() {3486 const dlg = $('#bk-dlg'), r = BK.r;3487 if (r && r.busy) return;3488 BK.r = null;3489 if (dlg && dlg.open) dlg.close();3490 if (!r) return;3491 const again = $(`.bk-rows [data-file="${CSS.escape(r.b.file)}"] [data-act="restore"]`);3492 const to = r.back && r.back.isConnected ? r.back : again || $('#bk-now');3493 if (to) to.focus();3494 }34953496 function openRestore(b, back) {3497 const dlg = bkDialog();3498 BK.r = { b, back, mode: 'all', chosen: null, account: false, schedules: true, plan: null, err: null, token: 0, busy: false, done: null };3499 renderRestore();3500 if (!dlg.open) dlg.showModal();3501 const first = $('#bk-dlg input[name="bk-mode"]:checked') || $('#bk-dlg-cancel');3502 if (first) first.focus();3503 previewRestore();3504 }35053506 function restoreBody(r) {3507 const body = { schedules: r.schedules, account: r.account };3508 if (r.mode === 'chosen') body.channels = [...(r.chosen || [])];3509 return body;3510 }35113512 let bkPreviewTimer = 0;3513 function previewRestore(wait) {3514 const r = BK.r;3515 if (!r) return;3516 clearTimeout(bkPreviewTimer);3517 const token = ++r.token;3518 bkPreviewTimer = setTimeout(async () => {3519 try {3520 const plan = await api('POST', `/backups/${enc(r.b.file)}/restore`, { ...restoreBody(r), preview: true });3521 if (BK.r !== r || r.token !== token) return;3522 r.plan = plan;3523 r.err = null;3524 if (!r.chosen) r.chosen = new Set(plan.items.filter((x) => x.action === 'add' || x.action === 'change').map((x) => x.key));3525 } catch (e) {3526 if (BK.r !== r || r.token !== token) return;3527 r.err = e;3528 }3529 renderRestore();3530 }, wait || 0);3531 }35323533 const bkNum = (x) => (x.number ? `${x.number} ${x.name}` : x.name);35343535 // What restoring one channel does, in words.3536 function bkItemWhat(x) {3537 if (x.action === 'skip') return x.reason;3538 if (x.error) return `Didn't work: ${x.error}`;3539 const parts = (x.changes || []).filter((c) => BK_PARTS[c]).map((c) => BK_PARTS[c]);3540 if (x.action === 'add') {3541 if (x.empty) return 'Made again, empty: put its videos back in its folder.';3542 return 'Brought back' + (parts.includes('schedule') ? ', with its schedule.' : '.');3543 }3544 if (x.action === 'same') return 'As in the backup; nothing changes.';3545 const out = [];3546 const now = x.now;3547 if ((x.changes || []).includes('rename') && now) out.push(`Now ${now.number} ${now.name}: it gets its old number and name back.`);3548 else if (x.kind === 'weather' && now && (now.number !== x.number || now.name !== x.name)) out.push(`Now ${now.number} ${now.name}: it goes back to ${x.number} ${x.name}.`);3549 if ((x.changes || []).includes('kind') && now) out.push(`Now a ${CHANNEL_KIND[now.kind] || now.kind} channel: it turns back into a ${CHANNEL_KIND[x.kind] || x.kind} one.`);3550 const rest = parts.filter((p) => p !== 'schedule');3551 if (rest.length) out.push(`Its ${bkList(rest)} go back as they were.`);3552 if (parts.includes('schedule')) out.push('Its schedule comes back.');3553 return out.join(' ') || 'Changes go back as they were.';3554 }35553556 function renderRestore() {3557 const dlg = $('#bk-dlg'), r = BK.r;3558 if (!dlg || !r) return;3559 const b = r.b, plan = r.plan, done = r.done;3560 const reason = bkReason(b);3561 const items = plan ? plan.items : [];3562 const chosen = (x) => (r.mode === 'all' ? true : r.chosen && r.chosen.has(x.key));35633564 const head = [3565 h('h2', { id: 'bk-dlg-title' }, done ? 'Restored' : 'Restore channels'),3566 h('p', { class: 'bk-from' }, `From ${bkWhen(b.created)}, ${reason.label.toLowerCase()}`, b.label ? h('b', { text: `: ${b.label}` }) : null, '.'),3567 ];3568 const kids = [...head];3569 if (!done) {3570 kids.push(h('div', { class: 'bk-mode' },3571 radios('bk-mode', [{ v: 'all', label: 'Everything' }, { v: 'chosen', label: 'Chosen channels' }], r.mode, (e) => {3572 r.mode = e.target.value;3573 renderRestore();3574 previewRestore();3575 }, 'What to restore')));3576 }3577 let list;3578 if (r.err && !plan) {3579 list = h('p', { class: 'bk-sum err' }, r.err.message);3580 } else if (!plan) {3581 list = h('p', { class: 'bk-sum' }, 'Working out what a restore changes');3582 } else {3583 list = h('ul', { class: 'bk-items' + (r.mode === 'chosen' && !done ? ' choosing' : ''), 'aria-label': 'Channels in the backup' }, items.map((x) => {3584 const id = 'bk-x-' + items.indexOf(x);3585 const box = r.mode === 'chosen' && !done3586 ? h('input', {3587 type: 'checkbox', id, checked: chosen(x), 'aria-describedby': id + '-what',3588 onchange: (e) => { if (e.target.checked) r.chosen.add(x.key); else r.chosen.delete(x.key); renderRestore(); previewRestore(150); },3589 })3590 : null;3591 const state = done ? (x.error ? 'bad' : x.done ? 'did' : 'same') : (chosen(x) ? x.action : 'off');3592 return h('li', { class: 'bk-item a-' + state },3593 box || h('span', { class: 'bk-mark', 'aria-hidden': 'true' }),3594 h('label', { class: 'bk-item-name', for: box ? id : null },3595 h('span', { class: 'num', text: x.number || '' }),3596 h('span', { class: 'nm', text: x.name }),3597 h('span', { class: 'kind k-' + x.kind, text: CHANNEL_KIND[x.kind] || x.kind })),3598 h('p', { class: 'what', id: id + '-what', text: !chosen(x) ? 'Left as it is: not chosen.' : bkItemWhat(x) }));3599 }));3600 }3601 kids.push(list);36023603 if (plan && !done) {3604 const a = plan.account || {};3605 kids.push(h('div', { class: 'bk-opts' },3606 h('label', { class: 'bk-check' + (a.inBackup ? '' : ' off') },3607 h('input', {3608 type: 'checkbox', id: 'bk-acct', checked: r.account && a.inBackup, disabled: !a.inBackup,3609 onchange: (e) => { r.account = e.target.checked; previewRestore(); },3610 }),3611 h('span', null, h('b', null, 'The Jellyfin account'),3612 h('small', null, !a.inBackup ? 'Not in this backup.'3613 : a.same ? `${a.user} on ${hostOf(a.server)}, the same as now.`3614 : `${a.user} on ${hostOf(a.server)}, in place of ${S.state && S.state.account.server ? `${S.state.account.user} on ${hostOf(S.state.account.server)}` : 'none'} now.`))),3615 h('label', { class: 'bk-check' },3616 h('input', { type: 'checkbox', id: 'bk-sched', checked: r.schedules, onchange: (e) => { r.schedules = e.target.checked; previewRestore(); } }),3617 h('span', null, h('b', null, 'YouTube schedules'),3618 h('small', null, 'For channels that have none now, so their guide picks up where the backup left off. A channel on the air keeps its own.')))));3619 }36203621 kids.push(h('div', { class: 'bk-summary', role: 'status', 'aria-live': 'polite' }, plan ? restoreSummary(r) : null));36223623 const changes = plan ? items.filter((x) => chosen(x) && (x.action === 'add' || x.action === 'change')).length : 0;3624 const acct = plan && r.account && plan.account && plan.account.inBackup && !plan.account.same;3625 let okText = 'Restore';3626 if (changes) okText = `Restore ${plural(changes, 'channel', 'channels')}`;3627 else if (acct) okText = 'Restore the account';3628 const actions = done3629 ? [h('button', { class: 'btn', type: 'button', id: 'bk-dlg-cancel', onclick: closeRestore }, 'Close')]3630 : [h('button', { class: 'btn ghost', type: 'button', id: 'bk-dlg-cancel', disabled: r.busy, onclick: closeRestore }, 'Cancel'),3631 h('button', {3632 class: 'btn', type: 'button', id: 'bk-dlg-ok', disabled: r.busy || !plan || (!changes && !acct),3633 onclick: doRestore,3634 }, r.busy ? 'Restoring' : okText)];3635 kids.push(h('div', { class: 'dlg-actions' }, actions));36363637 const had = dlg.contains(document.activeElement) ? document.activeElement.id || (document.activeElement.name ? `[name="${document.activeElement.name}"]:checked` : '') : '';3638 dlg.replaceChildren(h('form', { method: 'dialog' }, kids));3639 if (had) {3640 const el = had.startsWith('[') ? dlg.querySelector('input' + had) : dlg.querySelector('#' + CSS.escape(had));3641 if (el) el.focus();3642 }3643 }36443645 // The summary under the list: what a restore will do, all told.3646 function restoreSummary(r) {3647 const plan = r.plan, done = r.done;3648 const items = plan.items;3649 const chosen = (x) => (r.mode === 'all' ? true : r.chosen && r.chosen.has(x.key));3650 const lines = [];3651 if (done) {3652 const did = items.filter((x) => x.done), bad = items.filter((x) => x.error);3653 if (did.length) lines.push(`Put back ${plural(did.length, 'channel', 'channels')}: ${bkList(did.map(bkNum))}.`);3654 if (plan.account && plan.account.done) lines.push(`The Jellyfin account is back: ${plan.account.user} on ${hostOf(plan.account.server)}.`);3655 if (bad.length) lines.push(h('span', { class: 'err' }, `${plural(bad.length, 'channel', 'channels')} didn't work: ${bkList(bad.map(bkNum))}.`));3656 if (plan.account && plan.account.error) lines.push(h('span', { class: 'err' }, `The account didn't work: ${plan.account.error}`));3657 if (plan.before) lines.push(`The setup as it was is backed up (${plan.before}); restore that to undo this.`);3658 return h('ul', null, lines.map((l) => h('li', null, l)));3659 }3660 const pick = (act) => items.filter((x) => chosen(x) && x.action === act);3661 const adds = pick('add'), changes = pick('change'), same = pick('same'), skips = pick('skip');3662 if (adds.length) {3663 const empty = adds.filter((x) => x.empty);3664 lines.push(`Brings back ${plural(adds.length, 'channel', 'channels')}: ${bkList(adds.map(bkNum))}.` +3665 (empty.length ? ` ${bkList(empty.map(bkNum))} ${empty.length === 1 ? 'comes' : 'come'} back empty, as videos aren't in backups.` : ''));3666 }3667 if (changes.length) {3668 const moved = changes.filter((x) => x.now && x.now.number !== x.number);3669 lines.push(`Puts ${plural(changes.length, 'channel', 'channels')} back as ${changes.length === 1 ? 'it was' : 'they were'}: ${bkList(changes.map(bkNum))}.` +3670 (moved.length ? ` ${bkList(moved.map((x) => `${x.now.number} ${x.now.name}`))} ${moved.length === 1 ? 'moves' : 'move'} back to ${moved.length === 1 ? 'its' : 'their'} old number.` : ''));3671 }3672 if (same.length) lines.push(`${plural(same.length, 'channel is', 'channels are')} already as in the backup.`);3673 if (skips.length) lines.push(h('span', { class: 'err' }, `Leaves out ${bkList(skips.map(bkNum))}: ${skips.length === 1 ? 'its number is' : 'their numbers are'} taken now.`));3674 const a = plan.account || {};3675 if (a.inBackup && r.account && !a.same) lines.push(`Signs Jellyfin in as ${a.user} on ${hostOf(a.server)}.`);3676 else if (a.inBackup && !a.same) lines.push('Keeps the Jellyfin account as it is now.');3677 if ((plan.others || []).length) {3678 lines.push(`Leaves alone ${plural(plan.others.length, 'channel', 'channels')} the backup doesn't have: ${bkList(plan.others.map((c) => `${c.number} ${c.name}`))}.`);3679 }3680 if (!adds.length && !changes.length && !(a.inBackup && r.account && !a.same)) lines.push('Nothing to restore: what is chosen is already as in the backup.');3681 else lines.push('First, the setup as it is now is backed up, so this can be undone. Videos are never touched.');3682 return h('ul', null, lines.map((l) => h('li', null, l)));3683 }36843685 async function doRestore() {3686 const r = BK.r;3687 if (!r || r.busy || !r.plan) return;3688 r.busy = true;3689 renderRestore();3690 let plan = null;3691 try {3692 plan = await api('POST', `/backups/${enc(r.b.file)}/restore`, restoreBody(r));3693 } catch (e) {3694 r.busy = false;3695 if (BK.r !== r) return;3696 r.err = e;3697 renderRestore();3698 toast(`Couldn't restore: ${e.message}`, true);3699 return;3700 }3701 r.busy = false;3702 if (BK.r !== r) return;3703 r.plan = plan;3704 r.done = true;3705 renderRestore();3706 const did = plan.items.filter((x) => x.done).length;3707 toast(plan.errors ? `Restored with ${plural(plan.errors, 'problem', 'problems')}` : `Restored ${plural(did, 'channel', 'channels')}`, !!plan.errors);3708 const c = $('#bk-dlg-cancel');3709 if (c) c.focus();3710 loadBackups();3711 refreshState();3712 }37133714 loadBackups();37153716 // ---------- Channel changes ----------3717 // How long tuning in took lately, by kind of channel: the server's time to a ready stream,3718 // and the app's from the key press to the picture, as the apps report it.3719 const TUNE_KINDS = { antenna: 'Antenna', folder: 'Folders', jellyfin: 'Jellyfin', youtube: 'YouTube', weather: 'Weather' };3720 async function loadTunes() {3721 let rows = null;3722 try {3723 const r = await api('GET', '/tunes');3724 rows = Array.isArray(r && r.tunes) ? r.tunes : [];3725 } catch { /* kept as it was */ }3726 if (rows) renderTunes(rows);3727 setTimeout(loadTunes, document.hidden ? 300000 : 60000);3728 }3729 function renderTunes(rows) {3730 const box = $('#tune-side');3731 if (!box) return;3732 const by = {};3733 for (const r of rows) (by[r.kind] = by[r.kind] || {})[r.measure] = r;3734 const kinds = Object.keys(TUNE_KINDS).filter((k) => by[k]);3735 box.hidden = !kinds.length;3736 const cell = (r) => h('td', { title: r ? `${r.count} lately, the slowest ${r.max.toFixed(1)} s` : null }, r ? `${r.p50.toFixed(1)} / ${r.p90.toFixed(1)}` : '-');3737 box.replaceChildren(3738 h('div', { class: 'tune-head' }, h('b', null, 'Channel changes'), h('span', { text: 'median / 90%, s' })),3739 h('table', { class: 'tune-table' },3740 h('thead', null, h('tr', null, h('td', null), h('th', { scope: 'col' }, 'Server'), h('th', { scope: 'col' }, 'App'))),3741 h('tbody', null, kinds.map((k) => h('tr', null, h('th', { scope: 'row' }, TUNE_KINDS[k]), cell(by[k].ready), cell(by[k]['first frame']))))));3742 }3743 loadTunes();37443745 // ---------- Global keys ----------3746 // The skip link would otherwise change the hash, which the router reads as a channel.3747 $('.skip').addEventListener('click', (e) => {3748 e.preventDefault();3749 const t = $('#search') || $('#main');3750 t.focus();3751 });37523753 document.addEventListener('keydown', (e) => {3754 if ($('#dlg').open) return;3755 if (e.key === '/' && !e.metaKey && !e.ctrlKey && !e.altKey && !isTyping(e.target) && E.search) {3756 e.preventDefault();3757 E.search.focus();3758 E.search.select();3759 } else if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === 's' && E.save) {3760 e.preventDefault();3761 save();3762 }3763 });37643765 boot();3766})();