Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c5a7b5155 | ||
|
|
b171f35be6 | ||
|
|
d813884074 |
No files matched your search
@@ -22,6 +22,7 @@ jobs:
|
||||
- run: node tools/tasks-test.js # repeating tasks and subtasks
|
||||
- run: node tools/push-test.js # reminders: when they are sent, never twice
|
||||
- run: node tools/users-test.js # accounts: private data, tab limits, invites, migration
|
||||
- run: node tools/cal-test.js # calendars: several per person, colors, sharing view/edit, subscribe links
|
||||
- run: node tools/plex-test.js # Plex sign-in stays signed in and repairs itself
|
||||
- run: node tools/player-test.js # music player: queue, shuffle/repeat, stall recovery
|
||||
- run: node tools/lyrics-test.js # lyrics: formats, wrong matches refused, remembered
|
||||
|
||||
@@ -36,14 +36,13 @@ e.g. hub.necloud.us. Settings: `SEAFILE_URL`, `ONLYOFFICE_URL` env vars in docke
|
||||
breadcrumb** to move them (hold Alt/Option to copy). **Drag a single file out to your desktop** to download it (Chrome and Edge only; browsers cannot drag out folders
|
||||
or several files). Also share links, image/PDF/video/audio preview, and New document/spreadsheet/presentation.
|
||||
- **Calendar**: month grid and agenda. Add, edit and delete events:
|
||||
- **NE Hub calendar**: events you create are stored in `/data/events.json` and published as a private subscribe link
|
||||
(Manage calendars -> copy link) so Outlook, Google or Apple Calendar can show them (read-only there).
|
||||
- **iCloud (two-way)**: connect with an Apple ID and an *app-specific password* (appleid.apple.com). Credentials are
|
||||
encrypted in `/data/icloud.json`; only `*.icloud.com` is contacted. Edits keep alarms and attendees intact.
|
||||
- **NE Hub calendars**: make as many as you like (Manage calendars -> New calendar), each with its own name and color (a picker; iCloud, linked and shared calendars can be recolored too). Events carry their calendar's color everywhere and you pick the calendar when adding one. Older events stay in the first calendar.
|
||||
- **Sharing**: a calendar is private until you share it. In its Edit sheet, choose per person whether they can **view** or **edit**; the server enforces it. Each calendar also has its own private subscribe link (copy or make a new one), and there is still one link for all of yours, so Outlook, Google or Apple Calendar can show them (read-only there).
|
||||
- **ICS links** (Google secret address, Outlook/M365 published calendar) are read-only.
|
||||
- **Tasks**: to-do lists stored in NE Hub (`/data/tasks.json`), with due dates, notes and completion. If the connected iCloud
|
||||
account exposes reminder lists over CalDAV they appear too and sync both ways. Apple hides Reminders lists that were upgraded
|
||||
for iOS 13+, so for most accounts only NE Hub lists are available.
|
||||
- **Plex PINs and editing**: a Plex Home profile that has a PIN in Plex (the owner's included) asks for it every time you switch to it; plex.tv checks it and five wrong guesses make the hub wait ten minutes. The **Edit** button on a movie, show, season or episode (only when you are the Plex server's owner) opens the fields Plex's own Edit dialog has: title, sort title, original title, release date, content rating, studio, tagline, summary and genres, plus choosing a poster or background (or adding one from an image link). Only changed fields are saved, and each is locked in Plex so a refresh does not undo it. The hub's Plex proxy allows just those edit requests (nothing else on the server can be changed).
|
||||
- **Settings sync**: your preferences (mail addresses, audiobooks sign-in (encrypted), Plex library, Office save location, calendar/task views, recent files) are saved on the server in `/data/settings.json` and shared by every device. The Seafile sign-in token is saved there too (encrypted), so Settings shows Seafile as signed in on every device. Only the music queue stays in the browser.
|
||||
- **Search**: libraries, file names (scans libraries if Seafile has no search index) and Plex artists/albums/tracks.
|
||||
- **Music**: Plex player. Sign in goes through plex.tv (no password handled), the server proxies your Plex server and adds the token itself, and a mini player keeps playing as you move between tabs; the current and next two tracks are downloaded in full so changing songs is instant and a slow or lost connection does not stop playback; the Home dashboard and the Music tab show a live Now Playing card (follows the song, seek bar, shuffle/repeat, previous/next, lyrics, queue) above the rest; the queue (play next / add to queue) and position are remembered. The player reports what is playing to Plex (play, pause, stop, position every 10 s), so it appears under Now Playing / Activity in the Plex dashboard as "NE Hub", under the Plex account you signed in with. **Plex Home users**: if your Plex account has a Plex Home (managed users, kids, guests), the Music tab shows "Playing as <name>" with a switch link; pick another person (and enter their PIN if they have one) and Music, playlists, ratings and the Plex dashboard follow that person. The account that signed in is kept so switching back needs no PIN. It is per hub person, remembered across restarts, and has only been tried against a fake plex.tv. **Lyrics and karaoke**: the Lyrics button (on the Now Playing card, in the full player, and in the wide mini player) opens a full-screen lyrics view that follows the song, tap a line to jump to it, A−/A+ change the size, and **Karaoke** shows just the current line big with the colour sweeping across it as it is sung (word by word when the source has word timing, otherwise across the line). Lyrics come from Plex first if the song has them (embedded, a `.lrc` file next to it, or Plex Pass), otherwise from the bundled **Lyrica** service (`lyrica` container; sources LRCLIB, Musixmatch via Lrcmux, NetEase, Megalobiz; YouTube auto-captions are never used, and an answer for a recording of a very different length is refused). Lookups send the artist and title to those public sources and are kept in `/data/lyrics` so a song is looked up once. If the timing is off, the − / + buttons shift it half a second at a time (remembered per song); "Wrong lyrics?" lets you search with a corrected artist or title. Remove the `lyrica` service and the hub's `LYRICS_API_URL` line to skip the online lookup (Plex lyrics still work). No vocal removal.
|
||||
|
||||
@@ -222,60 +222,173 @@ function expand(events, win) { // win: { from, to (ms), fd, td (local YYYY-MM-DD
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- The NE Hub calendar: events created in the app, stored here, and published as a subscribe feed.
|
||||
const EV_FILE = 'events.json', FEED_FILE = 'feed.json';
|
||||
// ---- NE Hub calendars: any number per person, stored here, shared by choice, and published as subscribe feeds.
|
||||
const EV_FILE = 'events.json', FEED_FILE = 'feed.json', NCAL_FILE = 'ncals.json', COLOR_FILE = 'calcolors.json';
|
||||
const readJ = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return d; } };
|
||||
const writeJ = (f, v) => atomicWrite(here(f), JSON.stringify(v, null, 2), { mode: 0o600 });
|
||||
const loadLocal = () => readJ(here(EV_FILE), []);
|
||||
const LOCAL = { id: 'local', name: 'NE Hub', color: '#4f7cff' };
|
||||
const HEX = /^#[0-9a-f]{6}$/i;
|
||||
const DEFAULT_COLOR = '#4f7cff', COLORS = [DEFAULT_COLOR, '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d'];
|
||||
const dirFor = id => users.dirOf(id);
|
||||
const myId = () => users.current().id;
|
||||
|
||||
/** A person's own NE Hub calendars. The first one is the original "NE Hub" calendar (id "local"), so older events stay in it. */
|
||||
function calsIn(dir) {
|
||||
const l = readJ(path.join(dir, NCAL_FILE), null);
|
||||
return Array.isArray(l) && l.length ? l : [{ id: 'local', name: 'NE Hub', color: DEFAULT_COLOR, shares: [] }];
|
||||
}
|
||||
const saveCals = v => writeJ(NCAL_FILE, v);
|
||||
const eventsIn = dir => readJ(path.join(dir, EV_FILE), []);
|
||||
const saveEvents = (dir, v) => atomicWrite(path.join(dir, EV_FILE), JSON.stringify(v, null, 2), { mode: 0o600 });
|
||||
const evCal = e => e.cal || 'local';
|
||||
const colorOverrides = () => readJ(here(COLOR_FILE), {});
|
||||
|
||||
/** Calendars other people shared with the current person: [{ id: "owner~calId", owner, ownerName, cal, mode }] */
|
||||
function sharedWithMe() {
|
||||
const me = myId(), out = [];
|
||||
for (const u of users.all()) {
|
||||
if (u.disabled || u.id === me) continue;
|
||||
for (const c of calsIn(dirFor(u.id))) {
|
||||
const sh = (c.shares || []).find(x => x.user === me);
|
||||
if (sh) out.push({ id: u.id + '~' + c.id, owner: u.id, ownerName: u.name, cal: c, mode: sh.mode === 'edit' ? 'edit' : 'view' });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Find the NE Hub calendar an id points at, with the current person's rights on it (null if none). */
|
||||
function resolveCal(id) {
|
||||
const me = myId();
|
||||
if (typeof id === 'string' && id.includes('~')) {
|
||||
const [owner, cid] = id.split('~');
|
||||
const u = users.get(owner);
|
||||
if (!u || u.disabled) return null;
|
||||
const c = calsIn(dirFor(owner)).find(x => x.id === cid);
|
||||
const sh = c && (c.shares || []).find(x => x.user === me);
|
||||
return sh ? { owner, dir: dirFor(owner), cal: c, cid, mode: sh.mode === 'edit' ? 'edit' : 'view' } : null;
|
||||
}
|
||||
const c = calsIn(dirFor(me)).find(x => x.id === id);
|
||||
return c ? { owner: me, dir: dirFor(me), cal: c, cid: c.id, mode: 'owner' } : null;
|
||||
}
|
||||
const canWrite = r => r && (r.mode === 'owner' || r.mode === 'edit');
|
||||
|
||||
const icsOf = (dir, ids) => ics.buildCalendar(eventsIn(dir).filter(e => !ids || ids.includes(evCal(e))).map(e => ({ uid: e.uid, fields: e.fields })));
|
||||
const localIcs = () => icsOf(dirFor(myId()));
|
||||
|
||||
function feedToken(reset) {
|
||||
let t = readJ(here(FEED_FILE), {}).token;
|
||||
if (!t || reset) { t = crypto.randomBytes(24).toString('hex'); writeJ(FEED_FILE, { token: t }); }
|
||||
return t;
|
||||
}
|
||||
const localIcs = () => ics.buildCalendar(loadLocal().map(e => ({ uid: e.uid, fields: e.fields })));
|
||||
function calToken(id, reset) {
|
||||
const cals = calsIn(users.dir()), c = cals.find(x => x.id === id);
|
||||
if (!c) throw new Error('Calendar not found');
|
||||
if (!c.token || reset) { c.token = crypto.randomBytes(24).toString('hex'); saveCals(cals); }
|
||||
return c.token;
|
||||
}
|
||||
|
||||
/** GET /cal/feed/<token>.ics : public on purpose (Outlook/Google/Apple fetch it without a session); the token is the secret.
|
||||
* Every person has their own token, and it only ever shows that person's own NE Hub events. */
|
||||
* A person's own token shows all their NE Hub calendars; a calendar's token shows just that one. */
|
||||
function feed(req, res) {
|
||||
const m = /^\/cal\/feed\/([a-f0-9]+)\.ics$/.exec(new URL(req.url, 'http://x').pathname);
|
||||
const got = Buffer.from(m ? m[1] : '');
|
||||
let owner = null;
|
||||
const same = t => { const want = Buffer.from(String(t || '')); return want.length && want.length === got.length && crypto.timingSafeEqual(want, got); };
|
||||
let body = null;
|
||||
if (m) for (const u of users.all()) {
|
||||
if (u.disabled) continue;
|
||||
const want = Buffer.from(String(readJ(path.join(users.dirOf(u.id), FEED_FILE), {}).token || ''));
|
||||
if (want.length && want.length === got.length && crypto.timingSafeEqual(want, got)) { owner = u; break; }
|
||||
const dir = dirFor(u.id);
|
||||
if (same(readJ(path.join(dir, FEED_FILE), {}).token)) { body = icsOf(dir); break; }
|
||||
const c = calsIn(dir).find(x => same(x.token));
|
||||
if (c) { body = icsOf(dir, [c.id]); break; }
|
||||
}
|
||||
if (!owner) { res.writeHead(404); return res.end('Not found'); }
|
||||
if (body == null) { res.writeHead(404); return res.end('Not found'); }
|
||||
res.writeHead(200, { 'content-type': 'text/calendar; charset=utf-8', 'cache-control': 'private, max-age=300' });
|
||||
res.end(users.runAs(owner, () => localIcs()));
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
// ---- HTTP API (called by server.js, behind the passkey gate)
|
||||
const readBody = req => new Promise((ok, no) => { const c = []; req.on('data', d => c.push(d)); req.on('end', () => ok(Buffer.concat(c).toString())); req.on('error', no); });
|
||||
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json' }); res.end(JSON.stringify(obj)); };
|
||||
const COLORS = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d'];
|
||||
|
||||
/** Every event in the window from all calendars (ICS links, iCloud, NE Hub): { events, errors } */
|
||||
/** Every event in the window from all calendars (ICS links, iCloud, NE Hub, shared): { events, errors } */
|
||||
async function collectEvents(win) {
|
||||
const all = [], errors = [];
|
||||
const all = [], errors = [], over = colorOverrides();
|
||||
await Promise.all(load().map(async c => {
|
||||
try { for (const e of expand(parseEvents(await getIcs(c.url)), win)) all.push({ ...e, cal: c.name, color: c.color }); }
|
||||
try { for (const e of expand(parseEvents(await getIcs(c.url)), win)) all.push({ ...e, cal: c.name, calId: c.id, color: c.color }); }
|
||||
catch (e) { errors.push(`${c.name}: ${e.message}`); }
|
||||
}));
|
||||
try {
|
||||
for (const r of await caldav.listRaw(win.from, win.to))
|
||||
for (const e of expand(parseEvents(r.ics), win)) all.push({ ...e, cal: r.name, calId: r.calId, color: r.color, editable: true, href: r.href, etag: r.etag });
|
||||
for (const e of expand(parseEvents(r.ics), win)) all.push({ ...e, cal: r.name, calId: r.calId, color: over[r.calId] || r.color, editable: true, href: r.href, etag: r.etag });
|
||||
} catch (e) { errors.push('iCloud: ' + e.message); }
|
||||
try { for (const e of expand(parseEvents(localIcs()), win)) all.push({ ...e, cal: LOCAL.name, calId: 'local', color: LOCAL.color, editable: true }); }
|
||||
catch (e) { errors.push('NE Hub: ' + e.message); }
|
||||
try {
|
||||
const mine = dirFor(myId()), evs = eventsIn(mine);
|
||||
const add = (dir, evs, c, id, extra) => {
|
||||
const ics_ = ics.buildCalendar(evs.filter(e => evCal(e) === c.id).map(e => ({ uid: e.uid, fields: e.fields })));
|
||||
for (const e of expand(parseEvents(ics_), win)) all.push({ ...e, cal: c.name, calId: id, color: over[id] || c.color || DEFAULT_COLOR, ...extra });
|
||||
};
|
||||
for (const c of calsIn(mine)) add(mine, evs, c, c.id, { editable: true });
|
||||
for (const sh of sharedWithMe()) add(dirFor(sh.owner), eventsIn(dirFor(sh.owner)), sh.cal, sh.id, { editable: sh.mode === 'edit', shared: true, owner: sh.ownerName });
|
||||
} catch (e) { errors.push('NE Hub: ' + e.message); }
|
||||
return { events: all, errors };
|
||||
}
|
||||
|
||||
async function handle(req, res) {
|
||||
const u = new URL(req.url, 'http://x');
|
||||
try {
|
||||
if (u.pathname === '/api/cal/list') return json(res, 200, [{ ...LOCAL, local: true }, ...caldav.enabled().map(c => ({ ...c, icloud: true })), ...load().map(({ id, name, color }) => ({ id, name, color }))]);
|
||||
if (u.pathname === '/api/cal/list') {
|
||||
const over = colorOverrides();
|
||||
return json(res, 200, [
|
||||
...calsIn(users.dir()).map(c => ({ id: c.id, name: c.name, color: c.color || DEFAULT_COLOR, local: true, shares: (c.shares || []).filter(x => users.get(x.user)) })),
|
||||
...sharedWithMe().map(s => ({ id: s.id, name: s.cal.name, color: over[s.id] || s.cal.color || DEFAULT_COLOR, local: true, shared: true, owner: s.ownerName, mode: s.mode })),
|
||||
...caldav.enabled().map(c => ({ ...c, color: over[c.id] || c.color, icloud: true })),
|
||||
...load().map(({ id, name, color }) => ({ id, name, color }))]);
|
||||
}
|
||||
if (u.pathname === '/api/cal/ncal' && req.method === 'POST') { // create
|
||||
const { name, color } = JSON.parse(await readBody(req));
|
||||
const cals = calsIn(users.dir()), nm = String(name || '').replace(/\s+/g, ' ').trim().slice(0, 40);
|
||||
if (!nm) throw new Error('Give the calendar a name');
|
||||
if (cals.length >= 30) throw new Error('That is plenty of calendars');
|
||||
const c = { id: crypto.randomBytes(5).toString('hex'), name: nm, color: HEX.test(color) ? color : COLORS[cals.length % COLORS.length], shares: [] };
|
||||
saveCals([...cals, c]);
|
||||
return json(res, 200, { ok: true, id: c.id });
|
||||
}
|
||||
if (u.pathname === '/api/cal/ncal/update' && req.method === 'POST') { // rename, recolor, share
|
||||
const b = JSON.parse(await readBody(req));
|
||||
const cals = calsIn(users.dir()), c = cals.find(x => x.id === b.id);
|
||||
if (!c) throw new Error('Calendar not found');
|
||||
if (b.name != null) { const nm = String(b.name).replace(/\s+/g, ' ').trim().slice(0, 40); if (!nm) throw new Error('Give the calendar a name'); c.name = nm; }
|
||||
if (b.color != null) { if (!HEX.test(b.color)) throw new Error('Pick a color'); c.color = b.color; }
|
||||
if (b.shares != null) {
|
||||
if (!Array.isArray(b.shares)) throw new Error('Bad share list');
|
||||
const seen = new Set();
|
||||
c.shares = b.shares.filter(x => x && x.user !== myId() && users.get(x.user) && !seen.has(x.user) && seen.add(x.user)).map(x => ({ user: x.user, mode: x.mode === 'edit' ? 'edit' : 'view' }));
|
||||
}
|
||||
saveCals(cals);
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
if (u.pathname === '/api/cal/ncal/delete' && req.method === 'POST') {
|
||||
const { id } = JSON.parse(await readBody(req));
|
||||
const cals = calsIn(users.dir());
|
||||
if (!cals.some(x => x.id === id)) throw new Error('Calendar not found');
|
||||
if (cals.length < 2) throw new Error('Keep at least one NE Hub calendar');
|
||||
saveCals(cals.filter(x => x.id !== id));
|
||||
saveEvents(users.dir(), eventsIn(users.dir()).filter(e => evCal(e) !== id));
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
if (u.pathname === '/api/cal/ncal/feed' && req.method === 'POST') { // one calendar's subscribe link (reset: true makes a new one)
|
||||
const b = JSON.parse(await readBody(req));
|
||||
const origin = (process.env.PUBLIC_URL || '').replace(/\/$/, '') || `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers['x-forwarded-host'] || req.headers.host}`;
|
||||
return json(res, 200, { url: `${origin}/cal/feed/${calToken(b.id, !!b.reset)}.ics` });
|
||||
}
|
||||
if (u.pathname === '/api/cal/people') // who a calendar can be shared with
|
||||
return json(res, 200, users.all().filter(x => !x.disabled && x.id !== myId()).map(x => ({ id: x.id, name: x.name })));
|
||||
if (u.pathname === '/api/cal/color' && req.method === 'POST') { // your own color for iCloud, shared or link calendars
|
||||
const { id, color } = JSON.parse(await readBody(req));
|
||||
if (!HEX.test(color)) throw new Error('Pick a color');
|
||||
const link = load(), l = link.find(x => x.id === id);
|
||||
if (l) { l.color = color; save(link); } else writeJ(COLOR_FILE, { ...colorOverrides(), [id]: color });
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
if (u.pathname === '/api/cal/add' && req.method === 'POST') {
|
||||
const { name, url } = JSON.parse(await readBody(req));
|
||||
if (!url) throw new Error('Paste the calendar link');
|
||||
@@ -298,10 +411,11 @@ async function handle(req, res) {
|
||||
}
|
||||
if (u.pathname === '/api/cal/event' && req.method === 'GET') {
|
||||
const q = u.searchParams, tz = q.get('tz') || 'UTC';
|
||||
if (q.get('cal') === 'local') {
|
||||
const e = loadLocal().find(x => x.uid === q.get('uid'));
|
||||
const r = !caldav.isCal(q.get('cal')) && resolveCal(q.get('cal'));
|
||||
if (r) {
|
||||
const e = eventsIn(r.dir).find(x => x.uid === q.get('uid') && evCal(x) === r.cid);
|
||||
if (!e) throw new Error('Event not found');
|
||||
return json(res, 200, { cal: 'local', uid: e.uid, fields: e.fields });
|
||||
return json(res, 200, { cal: q.get('cal'), uid: e.uid, fields: e.fields });
|
||||
}
|
||||
if (caldav.isCal(q.get('cal'))) {
|
||||
const r = await caldav.getResource(q.get('cal'), q.get('href'));
|
||||
@@ -313,12 +427,14 @@ async function handle(req, res) {
|
||||
}
|
||||
if (u.pathname === '/api/cal/event' && req.method === 'POST') {
|
||||
const b = JSON.parse(await readBody(req));
|
||||
if (b.cal === 'local') {
|
||||
const r = !caldav.isCal(b.cal) && resolveCal(b.cal);
|
||||
if (r) {
|
||||
if (!canWrite(r)) throw new Error('This calendar is shared with you to view only');
|
||||
ics.validate(b.fields);
|
||||
const all = loadLocal(), uid = b.uid || ics.newUid(), i = all.findIndex(x => x.uid === uid);
|
||||
const rec = { uid, fields: b.fields, updated: Date.now() };
|
||||
const all = eventsIn(r.dir), uid = b.uid || ics.newUid(), i = all.findIndex(x => x.uid === uid && evCal(x) === r.cid);
|
||||
const rec = { uid, cal: r.cid, fields: b.fields, updated: Date.now() };
|
||||
if (i >= 0) all[i] = { ...all[i], ...rec }; else all.push({ ...rec, created: Date.now() });
|
||||
writeJ(EV_FILE, all);
|
||||
saveEvents(r.dir, all);
|
||||
return json(res, 200, { ok: true, uid });
|
||||
}
|
||||
if (caldav.isCal(b.cal)) {
|
||||
@@ -336,7 +452,12 @@ async function handle(req, res) {
|
||||
}
|
||||
if (u.pathname === '/api/cal/event/delete' && req.method === 'POST') {
|
||||
const b = JSON.parse(await readBody(req));
|
||||
if (b.cal === 'local') { writeJ(EV_FILE, loadLocal().filter(x => x.uid !== b.uid)); return json(res, 200, { ok: true }); }
|
||||
const r = !caldav.isCal(b.cal) && resolveCal(b.cal);
|
||||
if (r) {
|
||||
if (!canWrite(r)) throw new Error('This calendar is shared with you to view only');
|
||||
saveEvents(r.dir, eventsIn(r.dir).filter(x => !(x.uid === b.uid && evCal(x) === r.cid)));
|
||||
return json(res, 200, { ok: true });
|
||||
}
|
||||
if (caldav.isCal(b.cal)) { await caldav.deleteEvent(b.cal, b.href, b.etag); return json(res, 200, { ok: true }); }
|
||||
throw new Error('Unknown calendar');
|
||||
}
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"test": "for t in smoke tasks push users plex player lyrics signal mail mail-imap files; do node tools/$t-test.js || exit 1; done",
|
||||
"test": "for t in smoke tasks push users cal plex player lyrics signal mail mail-imap files; do node tools/$t-test.js || exit 1; done",
|
||||
"audit": "npm audit --omit=dev --audit-level=high"
|
||||
},
|
||||
"keywords": [],
|
||||
|
||||
+67
-9
@@ -128,7 +128,7 @@ function openEvent(e) {
|
||||
const when = e.allDay ? 'All day' : new Date(e.start).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) + ' – ' + fmtTime(e.end);
|
||||
const el = openSheet(e.title, e.cal, `<div class="list" style="background:var(--panel2);border:0"><div class="row"><span class="grow"><b>When</b><small style="white-space:normal">${esc(when)}</small></span></div>
|
||||
${e.location ? `<div class="row"><span class="grow"><b>Where</b><small style="white-space:normal">${esc(e.location)}</small></span></div>` : ''}</div>
|
||||
<p class="sub">This calendar is read-only here (it is a subscribed link). Change it in the app it comes from.</p><div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
|
||||
<p class="sub">${e.shared ? esc(e.owner) + ' shared this calendar with you to view only.' : 'This calendar is read-only here (it is a subscribed link). Change it in the app it comes from.'}</p><div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
|
||||
el.querySelector('#sx').onclick = closeSheet;
|
||||
}
|
||||
|
||||
@@ -142,11 +142,12 @@ async function editEvent(e) {
|
||||
|
||||
async function newEvent(day) {
|
||||
try {
|
||||
const cals = (await jfetch('/api/cal/list')).filter(c => c.local || c.icloud);
|
||||
const cals = (await jfetch('/api/cal/list')).filter(c => (c.local && c.mode !== 'view') || c.icloud);
|
||||
if (!cals.length) return toast('No calendar here can take new events');
|
||||
const d = new Date(day + 'T00:00:00'), now = new Date();
|
||||
if (ymd(d) === ymd(now)) d.setHours(now.getHours() + 1, 0, 0, 0); else d.setHours(9, 0, 0, 0);
|
||||
const end = new Date(d.getTime() + 36e5);
|
||||
eventForm({ mode: 'new', cals, calId: store.get('cal_default', 'local'), fields: { title: '', location: '', notes: '', allDay: false, start: localStr(d), end: localStr(end), tz: deviceTz(), repeat: 'none' } });
|
||||
eventForm({ mode: 'new', cals, calId: (cals.find(c => c.id === store.get('cal_default', 'local')) || cals[0]).id, fields: { title: '', location: '', notes: '', allDay: false, start: localStr(d), end: localStr(end), tz: deviceTz(), repeat: 'none' } });
|
||||
} catch (err) { toast(err.message); }
|
||||
}
|
||||
|
||||
@@ -155,7 +156,7 @@ function eventForm(o) {
|
||||
const opts = [['none', 'Does not repeat'], ['daily', 'Every day'], ['weekdays', 'Every weekday'], ['weekly', 'Every week'], ['monthly', 'Every month'], ['yearly', 'Every year']];
|
||||
if (f.repeat === 'keep') opts.push(['keep', 'Custom repeat (unchanged)']);
|
||||
const calPick = editing ? `<div class="row" style="background:var(--panel2);border-radius:12px"><span class="grow"><small>Calendar</small><b>${esc(o.calName)}</b></span></div>`
|
||||
: `<label for="fc">Calendar</label><select id="fc">${o.cals.map(c => `<option value="${esc(c.id)}" ${c.id === o.calId ? 'selected' : ''}>${esc(c.name)}${c.icloud ? ' (iCloud)' : ''}</option>`).join('')}</select>`;
|
||||
: `<label for="fc">Calendar</label><select id="fc">${o.cals.map(c => `<option value="${esc(c.id)}" ${c.id === o.calId ? 'selected' : ''}>${esc(c.name)}${c.icloud ? ' (iCloud)' : c.shared ? ' (' + esc(c.owner) + ')' : ''}</option>`).join('')}</select>`;
|
||||
const el = openSheet(editing ? 'Edit event' : 'New event', '', `<form id="ef"><input type="text" id="ft" placeholder="Title" value="${esc(f.title)}" autocomplete="off" required style="margin-top:6px">
|
||||
<label class="chk"><input type="checkbox" id="fa" ${f.allDay ? 'checked' : ''}> All day</label>
|
||||
<div id="fdt"></div>
|
||||
@@ -219,16 +220,70 @@ function addCalendar() {
|
||||
};
|
||||
}
|
||||
|
||||
const CAL_COLORS = ['#4f7cff', '#2f9e6e', '#e0742b', '#d9453a', '#8a5cf0', '#e5a00d', '#e8559a', '#14a3b8', '#7a8a3a', '#6b7280'];
|
||||
const swatches = cur => `<div class="swatches">${CAL_COLORS.map(c => `<button type="button" class="sw ${c.toLowerCase() === String(cur).toLowerCase() ? 'on' : ''}" data-c="${c}" aria-label="${c}" style="background:${c}"></button>`).join('')}<label class="sw custom" title="Any color"><input type="color" value="${/^#[0-9a-f]{6}$/i.test(cur) ? cur : '#4f7cff'}"></label></div>`;
|
||||
// Wires swatches() inside root; calls onPick(hex) whenever the color changes.
|
||||
function wireSwatches(root, onPick) {
|
||||
root.querySelectorAll('.sw[data-c]').forEach(b => b.onclick = () => { root.querySelectorAll('.sw').forEach(x => x.classList.toggle('on', x === b)); onPick(b.dataset.c); });
|
||||
const cu = root.querySelector('.sw.custom input'); if (cu) cu.oninput = () => { root.querySelectorAll('.sw').forEach(x => x.classList.remove('on')); onPick(cu.value); };
|
||||
}
|
||||
|
||||
function colorSheet(c) {
|
||||
const el = openSheet(c.name, 'Calendar color', `${swatches(c.color)}<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Done</button></div>`);
|
||||
el.querySelector('#sx').onclick = () => { closeSheet(); viewCalendar(); };
|
||||
wireSwatches(el, async hex => { try { await jfetch('/api/cal/color', { method: 'POST', body: JSON.stringify({ id: c.id, color: hex }) }); } catch (e) { toast(e.message); } });
|
||||
}
|
||||
|
||||
// Create (c = null) or edit one of your NE Hub calendars: name, color, who it is shared with (view or edit), subscribe link, delete.
|
||||
async function calendarSheet(c) {
|
||||
const people = c ? await jfetch('/api/cal/people').catch(() => []) : [];
|
||||
const st = { name: c ? c.name : '', color: c ? c.color : CAL_COLORS[Math.floor(Math.random() * CAL_COLORS.length)], shares: Object.fromEntries(((c && c.shares) || []).map(x => [x.user, x.mode])) };
|
||||
const el = openSheet(c ? 'Edit calendar' : 'New calendar', '', `<form id="cf"><label for="cn">Name</label><input type="text" id="cn" value="${esc(st.name)}" placeholder="Family" autocomplete="off" required>
|
||||
<label>Color</label>${swatches(st.color)}
|
||||
${c ? `<h2 style="margin-top:14px">Sharing</h2><p class="sub" style="margin-top:0">Private unless you share it. For each person you choose whether they can only look, or add and change events too.</p>
|
||||
${people.length ? `<div class="list" style="background:var(--panel2);border:0">${people.map(p => `<div class="row"><span class="grow"><b>${esc(p.name)}</b></span><select data-sh="${esc(p.id)}" style="width:auto;min-height:36px">${[['', 'Not shared'], ['view', 'Can view'], ['edit', 'Can edit']].map(([v, l]) => `<option value="${v}" ${(st.shares[p.id] || '') === v ? 'selected' : ''}>${l}</option>`).join('')}</select></div>`).join('')}</div>` : '<p class="sub">Nobody else has an account on this hub yet.</p>'}
|
||||
<h2 style="margin-top:14px">Subscribe link</h2><p class="sub" style="margin-top:0">Show only this calendar in Outlook, Google or Apple Calendar (read-only there). Anyone with the link can see its events.</p>
|
||||
<div class="bar"><button class="btn ghost" type="button" id="cl" style="flex:1;justify-content:center">${icon('copy')}Copy link</button><button class="btn ghost" type="button" id="cr" style="flex:1;justify-content:center">Make a new link</button></div>` : ''}
|
||||
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" style="flex:1;justify-content:center">${c ? 'Save' : 'Create'}</button></div>
|
||||
${c ? '<div class="bar" style="margin-top:0"><button class="btn danger" type="button" id="cd" style="flex:1;justify-content:center">Delete calendar</button></div>' : ''}
|
||||
<div class="err" id="ce"></div></form>`);
|
||||
el.querySelector('.sheet').classList.add('wide');
|
||||
wireSwatches(el, hex => { st.color = hex; });
|
||||
el.querySelectorAll('[data-sh]').forEach(sel => sel.onchange = () => { if (sel.value) st.shares[sel.dataset.sh] = sel.value; else delete st.shares[sel.dataset.sh]; });
|
||||
el.querySelector('#sx').onclick = () => { closeSheet(); manageCalendars(); };
|
||||
el.querySelector('#cf').onsubmit = async ev => {
|
||||
ev.preventDefault(); const btn = el.querySelector('form .btn:not(.ghost):not(.danger)'); btn.disabled = true; el.querySelector('#ce').textContent = '';
|
||||
const name = el.querySelector('#cn').value.trim();
|
||||
try {
|
||||
if (c) await jfetch('/api/cal/ncal/update', { method: 'POST', body: JSON.stringify({ id: c.id, name, color: st.color, shares: Object.entries(st.shares).map(([user, mode]) => ({ user, mode })) }) });
|
||||
else await jfetch('/api/cal/ncal', { method: 'POST', body: JSON.stringify({ name, color: st.color }) });
|
||||
closeSheet(); toast(c ? 'Calendar saved' : 'Calendar created'); viewCalendar();
|
||||
} catch (err) { btn.disabled = false; el.querySelector('#ce').textContent = err.message; }
|
||||
};
|
||||
if (!c) return;
|
||||
const link = async reset => (await jfetch('/api/cal/ncal/feed', { method: 'POST', body: JSON.stringify({ id: c.id, reset }) })).url;
|
||||
el.querySelector('#cl').onclick = async () => { try { const u = await link(false); await navigator.clipboard.writeText(u); toast('Link copied'); } catch (e) { toast(e.message || 'Could not copy'); } };
|
||||
el.querySelector('#cr').onclick = () => confirmSheet('Make a new link?', 'The old link stops working, so anything subscribed to it has to be updated.', 'Make new link', async () => { try { await navigator.clipboard.writeText(await link(true)); toast('New link copied'); } catch (e) { toast(e.message); } });
|
||||
el.querySelector('#cd').onclick = () => confirmSheet('Delete ' + c.name + '?', 'Every event in it is deleted too' + (Object.keys(st.shares).length ? ', and the people it is shared with lose it' : '') + '. This cannot be undone.', 'Delete', async () => {
|
||||
try { await jfetch('/api/cal/ncal/delete', { method: 'POST', body: JSON.stringify({ id: c.id }) }); toast('Calendar deleted'); viewCalendar(); } catch (e) { toast(e.message); }
|
||||
});
|
||||
}
|
||||
|
||||
async function manageCalendars() {
|
||||
const [cals, ic, feed] = await Promise.all([jfetch('/api/cal/list'), jfetch('/api/cal/icloud'), jfetch('/api/cal/feed')]);
|
||||
const links = cals.filter(c => !c.local && !c.icloud);
|
||||
const el = openSheet('Calendars', '', `<div class="list" style="background:var(--panel2);border:0">
|
||||
<div class="row"><span class="dot" style="background:#4f7cff"></span><span class="grow"><b>NE Hub</b><small>Events you add here</small></span></div>
|
||||
${links.map(c => `<div class="row"><span class="dot" style="background:${esc(c.color)}"></span><span class="grow"><b>${esc(c.name)}</b><small>Link (read-only)</small></span><button class="btn danger" data-rm="${esc(c.id)}" style="min-height:36px">Remove</button></div>`).join('')}
|
||||
const links = cals.filter(c => !c.local && !c.icloud), mine = cals.filter(c => c.local), icol = Object.fromEntries(cals.filter(c => c.icloud).map(c => [c.id, c.color]));
|
||||
const dot = c => `<button type="button" class="cdot" data-col="${esc(c.id)}" aria-label="Color of ${esc(c.name)}" style="background:${esc(c.color)}"></button>`;
|
||||
const el = openSheet('Calendars', '', `<h2>NE Hub calendars</h2><div class="list" style="background:var(--panel2);border:0">
|
||||
${mine.map(c => `<div class="row">${dot(c)}<span class="grow"><b>${esc(c.name)}</b><small>${c.shared ? 'Shared by ' + esc(c.owner) + ' · ' + (c.mode === 'edit' ? 'you can edit' : 'view only') : c.shares && c.shares.length ? 'Shared with ' + c.shares.length + (c.shares.length === 1 ? ' person' : ' people') : 'Private'}</small></span>${c.shared ? '' : `<button class="btn ghost" data-ed="${esc(c.id)}" style="min-height:36px">Edit</button>`}</div>`).join('')}
|
||||
</div>
|
||||
<div class="bar"><button class="btn ghost" id="nc" style="flex:1;justify-content:center">New calendar</button></div>
|
||||
<h2 style="margin-top:14px">Calendar links</h2>
|
||||
<div class="list" style="background:var(--panel2);border:0">
|
||||
${links.length ? links.map(c => `<div class="row">${dot(c)}<span class="grow"><b>${esc(c.name)}</b><small>Link (read-only)</small></span><button class="btn danger" data-rm="${esc(c.id)}" style="min-height:36px">Remove</button></div>`).join('') : '<div class="row"><small>None yet</small></div>'}
|
||||
</div>
|
||||
<div class="bar"><button class="btn ghost" id="al" style="flex:1;justify-content:center">Add a calendar link</button></div>
|
||||
<h2 style="margin-top:14px">iCloud calendars</h2>
|
||||
${ic.connected ? `<div class="list" style="background:var(--panel2);border:0">${ic.calendars.map(c => `<label class="row" style="cursor:pointer"><span class="dot" style="background:${esc(c.color)}"></span><span class="grow"><b>${esc(c.name)}</b></span><input type="checkbox" data-ic="${esc(c.id)}" ${c.enabled ? 'checked' : ''}></label>`).join('')}</div>
|
||||
${ic.connected ? `<div class="list" style="background:var(--panel2);border:0">${ic.calendars.map(c => `<div class="row">${dot({ id: c.id, name: c.name, color: icol[c.id] || c.color })}<span class="grow"><b>${esc(c.name)}</b></span><input type="checkbox" data-ic="${esc(c.id)}" ${c.enabled ? 'checked' : ''}></div>`).join('')}</div>
|
||||
<p class="sub">Signed in as ${esc(ic.appleId)}. Events you add or change here are saved to iCloud.</p><div class="bar"><button class="btn danger" id="icx" style="flex:1;justify-content:center">Disconnect iCloud</button></div>`
|
||||
: `<p class="sub">Read and add events in your iCloud calendars. Use an <b>app-specific password</b> (appleid.apple.com → Sign-In and Security → App-Specific Passwords), not your normal Apple password.</p>
|
||||
<form id="icf"><input type="text" id="iu" placeholder="Apple ID (email)" autocomplete="off" autocapitalize="off" required><input type="password" id="ip" placeholder="App-specific password" autocomplete="off" style="margin-top:8px" required>
|
||||
@@ -240,6 +295,9 @@ async function manageCalendars() {
|
||||
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
|
||||
el.querySelector('.sheet').classList.add('wide');
|
||||
el.querySelector('#sx').onclick = closeSheet; el.querySelector('#al').onclick = addCalendar;
|
||||
el.querySelector('#nc').onclick = () => calendarSheet(null);
|
||||
el.querySelectorAll('[data-ed]').forEach(b => b.onclick = () => calendarSheet(mine.find(c => c.id === b.dataset.ed)));
|
||||
el.querySelectorAll('[data-col]').forEach(b => b.onclick = () => colorSheet(cals.find(c => c.id === b.dataset.col) || { id: b.dataset.col, name: 'Calendar', color: b.style.background }));
|
||||
el.querySelectorAll('[data-rm]').forEach(b => b.onclick = async () => { await jfetch('/api/cal/remove', { method: 'POST', body: JSON.stringify({ id: b.dataset.rm }) }); closeSheet(); viewCalendar(); });
|
||||
el.querySelectorAll('[data-ic]').forEach(c => c.onchange = async () => { try { await jfetch('/api/cal/icloud/toggle', { method: 'POST', body: JSON.stringify({ id: c.dataset.ic, enabled: c.checked }) }); } catch (e) { toast(e.message); } });
|
||||
const icx = el.querySelector('#icx'); if (icx) icx.onclick = async () => { await jfetch('/api/cal/icloud/disconnect', { method: 'POST', body: '{}' }); toast('iCloud disconnected'); closeSheet(); viewCalendar(); };
|
||||
|
||||
@@ -82,10 +82,10 @@ async function plexHomeBadge(st) {
|
||||
function plexSwitchSheet(users) {
|
||||
const el = openSheet('Who is listening?', 'People in your Plex Home. Playlists, ratings and what you have played follow the person you pick.',
|
||||
`<div class="list">${users.map((u, i) => `<button class="row" data-u="${i}"><span class="ic" style="background:var(--panel2);overflow:hidden;color:var(--text)">${u.thumb ? `<img src="${esc(u.thumb)}" alt="" referrerpolicy="no-referrer" style="width:100%;height:100%;object-fit:cover" onerror="this.remove()">` : esc([...u.title][0].toUpperCase())}</span>
|
||||
<span class="grow"><b>${esc(u.title)}${u.current ? ' ✓' : ''}</b><small>${[u.admin ? 'Admin' : '', u.restricted ? 'Restricted' : '', u.protected && !u.admin ? 'PIN' : ''].filter(Boolean).join(' · ') || ' '}</small></span>${u.current ? '' : icon('chev', 'chev')}</button>`).join('')}</div>
|
||||
<span class="grow"><b>${esc(u.title)}${u.current ? ' ✓' : ''}</b><small>${[u.admin ? 'Admin' : '', u.restricted ? 'Restricted' : '', u.protected ? 'PIN' : ''].filter(Boolean).join(' · ') || ' '}</small></span>${u.current ? '' : icon('chev', 'chev')}</button>`).join('')}</div>
|
||||
<div class="bar"><button class="btn ghost" id="sx" style="flex:1;justify-content:center">Close</button></div>`);
|
||||
el.querySelector('#sx').onclick = closeSheet;
|
||||
el.querySelectorAll('[data-u]').forEach(b => b.onclick = () => { const u = users[+b.dataset.u]; if (u.current) return closeSheet(); if (u.protected && !u.admin) plexPinSheet(u); else plexSwitchTo(u, ''); });
|
||||
el.querySelectorAll('[data-u]').forEach(b => b.onclick = () => { const u = users[+b.dataset.u]; if (u.current) return closeSheet(); if (u.protected) plexPinSheet(u); else plexSwitchTo(u, ''); });
|
||||
}
|
||||
function plexPinSheet(u) {
|
||||
const el = openSheet('PIN for ' + u.title, '', `<form id="ppf"><label for="ppi">PIN</label><input type="password" id="ppi" inputmode="numeric" autocomplete="off" maxlength="8" required>
|
||||
|
||||
@@ -693,3 +693,18 @@ body.nav-mini .ngrp span { display: none; }
|
||||
.vtop .vtc { flex: 0 1 auto; }
|
||||
.mini .vskip { position: relative; display: grid; place-items: center; } .mini .vskip em { position: absolute; font-style: normal; font-size: 8px; font-weight: 700; top: 7px; }
|
||||
@media (max-width: 700px) { .vtop .vright input[type=range], .vtop #vmute, .vtop #vspd { display: none; } .vtop .vtitle { display: none; } }
|
||||
|
||||
/* calendar colors */
|
||||
.cdot{width:22px;height:22px;min-height:0;padding:0;border-radius:50%;border:2px solid var(--panel2);box-shadow:0 0 0 1px var(--line);flex:none;cursor:pointer}
|
||||
.swatches{display:flex;flex-wrap:wrap;gap:10px;margin:8px 0 4px}
|
||||
.sw{width:30px;height:30px;min-height:0;padding:0;border-radius:50%;border:2px solid transparent;cursor:pointer;position:relative;box-shadow:0 0 0 1px var(--line)}
|
||||
.sw.on{border-color:var(--text)}
|
||||
.sw.custom{background:conic-gradient(#d9453a,#e5a00d,#2f9e6e,#4f7cff,#8a5cf0,#d9453a);overflow:hidden}
|
||||
.sw.custom input{position:absolute;inset:0;opacity:0;width:100%;height:100%;cursor:pointer}
|
||||
|
||||
/* Plex details editor: image choices */
|
||||
.vimgs{display:grid;grid-template-columns:repeat(auto-fill,minmax(90px,1fr));gap:8px;margin:6px 0}
|
||||
.vimgs.arts{grid-template-columns:repeat(auto-fill,minmax(150px,1fr))}
|
||||
.vpick{padding:0;min-height:0;border:2px solid transparent;border-radius:8px;overflow:hidden;background:var(--panel2);cursor:pointer;line-height:0}
|
||||
.vpick.on{border-color:var(--accent)}
|
||||
.vpick img{width:100%;height:auto;display:block}
|
||||
+65
-5
@@ -109,6 +109,9 @@ async function videoItem(id) {
|
||||
<p class="vsum">${esc(m.summary || '')}</p>
|
||||
${m.Genre && m.Genre.length ? `<p class="sub"><b>Genres</b> ${names(m.Genre)}</p>` : ''}${m.Director && m.Director.length ? `<p class="sub"><b>Director</b> ${names(m.Director, 'tag', 3)}</p>` : ''}${m.Role && m.Role.length ? `<p class="sub"><b>Cast</b> ${names(m.Role, 'tag', 8)}</p>` : ''}
|
||||
<div id="vkids"></div></div>`;
|
||||
jfetch('/api/plex/status').then(st => { // Plex only lets its owner edit details, so the button is offered only then
|
||||
if (st.canEdit && $('#vact') && !$('#vedit')) { $('#vact').insertAdjacentHTML('beforeend', '<button class="btn ghost" id="vedit">Edit</button>'); $('#vedit').onclick = () => videoEdit(m); }
|
||||
}).catch(() => {});
|
||||
if ($('#vplay')) $('#vplay').onclick = () => { location.hash = `#/video/watch/${id}`; };
|
||||
if ($('#vstart')) $('#vstart').onclick = () => { store.set('video_from_start', id); location.hash = `#/video/watch/${id}`; };
|
||||
if ($('#vnext')) $('#vnext').onclick = () => { location.hash = `#/video/watch/${next.ratingKey}`; };
|
||||
@@ -204,7 +207,10 @@ function videoMount() {
|
||||
/** Media events, bound once to the video element (which outlives the player page while the mini player is showing). */
|
||||
function videoBindMedia(el) {
|
||||
const msg = t => { const e = $('#vmsg'); if (e) e.textContent = t || ''; };
|
||||
el.addEventListener('timeupdate', () => { if (vp && vp.el === el && !el.seeking) vp.pos = vp.base + el.currentTime; vdUi(); });
|
||||
el.addEventListener('timeupdate', () => { if (vp && vp.el === el && vp.mode === 'hls' && vp.learn && el.currentTime > 0 && !el.seeking) { // Plex's converted stream may count from 0 (what we asked for starts there) or carry on from the real position: tell which, once
|
||||
const first = el.buffered.length ? el.buffered.start(0) : el.currentTime;
|
||||
vp.base = vp.learn > 5 ? (first > vp.learn - 10 ? 0 : Math.floor(vp.learn)) : 0; vp.learn = 0; }
|
||||
if (vp && vp.el === el && !el.seeking) vp.pos = vp.base + el.currentTime; vdUi(); });
|
||||
el.addEventListener('play', () => { vdUi(); if (vp && vp.wake) vp.wake(); });
|
||||
el.addEventListener('pause', () => { vdUi(); if (vp && vp.wake) vp.wake(); if (!el.ended) vdReport('paused'); });
|
||||
el.addEventListener('volumechange', () => { const mu = $('#vmute'); if (mu) mu.innerHTML = icon(el.muted || el.volume === 0 ? 'mute' : 'vol'); });
|
||||
@@ -281,8 +287,10 @@ function videoSeek(abs) {
|
||||
if (!vp) return; const el = vp.el, t = (vp.m.duration || 0) / 1000;
|
||||
abs = Math.max(0, t ? Math.min(abs, t - 1) : abs);
|
||||
if (vp.mode === 'direct') { el.currentTime = abs; return; }
|
||||
const rel = abs - vp.base;
|
||||
if (rel >= 0 && isFinite(el.duration) && rel < el.duration - 2) el.currentTime = rel; else videoStart(abs);
|
||||
const rel = abs - vp.base, sk = el.seekable;
|
||||
// Only inside what the converted stream really has (its range may begin late when it started part-way); anything else restarts the conversion there.
|
||||
const inside = sk && sk.length ? rel >= sk.start(0) - 0.5 && rel <= sk.end(sk.length - 1) - 2 : rel >= 0 && isFinite(el.duration) && rel < el.duration - 2;
|
||||
if (inside) el.currentTime = rel; else videoStart(abs);
|
||||
}
|
||||
function videoSkip(d) {
|
||||
if (!vp) return; const to = Math.max(0, vp.base + vp.el.currentTime + d), t = (vp.m.duration || 0) / 1000 || vp.el.duration || 0;
|
||||
@@ -320,10 +328,10 @@ async function videoStart(sec) {
|
||||
vp.session = crypto.randomUUID ? crypto.randomUUID() : String(Date.now() + Math.random());
|
||||
const q = QUALITIES.find(x => x[0] === vp.q) || QUALITIES[0], direct = !vp.forceHls && (vp.q === 'original' || (vp.q === 'auto' && canDirect(media) && !vp.audio && !vp.sub));
|
||||
if (direct) {
|
||||
vp.mode = 'direct'; vp.base = 0; el.src = '/plex' + part.key; if (sec > 1) el.addEventListener('loadedmetadata', () => { el.currentTime = sec; }, { once: true });
|
||||
vp.mode = 'direct'; vp.base = 0; vp.learn = 0; el.src = '/plex' + part.key; if (sec > 1) el.addEventListener('loadedmetadata', () => { el.currentTime = sec; }, { once: true });
|
||||
const t = $('#vmsg'); if (t) t.textContent = '';
|
||||
} else {
|
||||
vp.mode = 'hls'; vp.base = sec;
|
||||
vp.mode = 'hls'; vp.base = sec; vp.learn = sec || 0.001;
|
||||
const o = q[2] || QUALITIES[0][2], p = new URLSearchParams({ path: '/library/metadata/' + vp.id, mediaIndex: 0, partIndex: 0, protocol: 'hls', fastSeek: 1, directPlay: 0, directStream: 1, subtitleSize: 100, audioBoost: 100, offset: Math.floor(sec), session: vp.session, maxVideoBitrate: o.kbps, 'X-Plex-Platform': 'Chrome' });
|
||||
if (o.res) p.set('videoResolution', o.res); if (vp.audio) p.set('audioStreamID', vp.audio); if (vp.sub) { p.set('subtitleStreamID', vp.sub); p.set('subtitles', 'burn'); }
|
||||
const url = '/plex/video/:/transcode/universal/start.m3u8?' + p; const t = $('#vmsg'); if (t) t.textContent = 'Preparing the stream…';
|
||||
@@ -389,3 +397,55 @@ addEventListener('keydown', e => {
|
||||
else if (k === 'm') el.muted = !el.muted; else if (k === 'n' || k === 'N') videoGo(1); else if (k === 'p' || k === 'P') videoGo(-1);
|
||||
else if (/^[0-9]$/.test(k) && vp.m.duration) { videoSeek(vp.m.duration / 1000 * (+k / 10)); }
|
||||
}, true);
|
||||
|
||||
|
||||
// ---- Edit a title's details the way Plex's Edit dialog does: only what changed is saved, and each saved field is locked so a Plex refresh keeps it.
|
||||
const VD_TYPE = { movie: 1, show: 2, season: 3, episode: 4 };
|
||||
async function videoEdit(m) {
|
||||
const kind = m.type, rich = kind === 'movie' || kind === 'show', genres = (m.Genre || []).map(g => g.tag);
|
||||
const F = [['title', 'Title'], ['titleSort', 'Sort title'], ['originalTitle', 'Original title'], ['originallyAvailableAt', kind === 'show' ? 'First aired' : 'Release date', 'date'], ['contentRating', 'Content rating'], ...(rich ? [['studio', 'Studio'], ['tagline', 'Tagline']] : []), ['summary', 'Summary', 'area']]
|
||||
.filter(([k]) => kind !== 'season' || ['title', 'titleSort', 'summary'].includes(k));
|
||||
const old = k => String(m[k] || ''), dateOf = v => String(v || '').slice(0, 10);
|
||||
const el = openSheet('Edit ' + (m.title || ''), '', `<div class="sgrp" role="tablist" style="margin-bottom:8px"><a href="#" data-t="d" aria-selected="true" class="on">Details</a><a href="#" data-t="p" aria-selected="false">Poster</a><a href="#" data-t="a" aria-selected="false">Background</a></div>
|
||||
<form id="vf"><div id="vtab-d">${F.map(([k, l, t]) => `<label for="ve-${k}">${l}</label>${t === 'area' ? `<textarea id="ve-${k}" rows="5">${esc(old(k))}</textarea>` : `<input type="${t || 'text'}" id="ve-${k}" value="${esc(t === 'date' ? dateOf(m[k]) : old(k))}" autocomplete="off">`}`).join('')}
|
||||
${rich ? `<label for="ve-genre">Genres <small>(separate with commas)</small></label><input type="text" id="ve-genre" value="${esc(genres.join(', '))}" autocomplete="off">` : ''}
|
||||
<p class="sub" style="margin:10px 0 0">Changed fields are locked in Plex, so scans and refreshes will not put the old values back.</p></div>
|
||||
<div id="vtab-p" hidden></div><div id="vtab-a" hidden></div>
|
||||
<div class="bar"><button class="btn ghost" type="button" id="sx" style="flex:1;justify-content:center">Cancel</button><button class="btn" id="vs" style="flex:1;justify-content:center">Save</button></div><div class="err" id="ve"></div></form>`);
|
||||
el.querySelector('.sheet').classList.add('wide');
|
||||
el.querySelector('#sx').onclick = closeSheet;
|
||||
const loaded = {};
|
||||
const images = async (tab, what) => { // posters / backgrounds: pick one, or add from an image link
|
||||
const box = el.querySelector('#vtab-' + tab); box.innerHTML = '<p class="empty">Loading…</p>';
|
||||
try {
|
||||
const mc = await px.get(`/library/metadata/${m.ratingKey}/${what}`), list = mc.Metadata || [];
|
||||
box.innerHTML = `<div class="vimgs ${what}">${list.map((x, i) => `<button type="button" class="vpick ${x.selected ? 'on' : ''}" data-i="${i}" aria-label="Use this image"><img alt="" loading="lazy" src="${/^https?:/.test(x.thumb || '') ? esc(x.thumb) : vdArt(x.thumb, 200, 300)}"></button>`).join('') || '<p class="empty">No images yet.</p>'}</div>
|
||||
<label>Add from an image link</label><div class="bar" style="margin-top:4px"><input type="url" class="vurl" placeholder="https://…" autocomplete="off" style="flex:1"><button type="button" class="btn ghost vadd">Add</button></div><div class="err vie"></div>`;
|
||||
const put = async (url, add) => {
|
||||
const r = await fetch(`/plex/library/metadata/${m.ratingKey}/${add ? what : what === 'posters' ? 'poster' : 'art'}?url=${encodeURIComponent(url)}`, { method: add ? 'POST' : 'PUT' });
|
||||
if (!r.ok) throw new Error(r.status === 403 || r.status === 404 ? 'Only the Plex owner can change images' : 'Plex would not take that (' + r.status + ')');
|
||||
};
|
||||
box.querySelectorAll('.vpick').forEach(b => b.onclick = async () => { try { await put(list[+b.dataset.i].ratingKey || list[+b.dataset.i].key); box.querySelectorAll('.vpick').forEach(x => x.classList.toggle('on', x === b)); toast('Image changed'); changed = true; } catch (e) { box.querySelector('.vie').textContent = e.message; } });
|
||||
box.querySelector('.vadd').onclick = async () => { const u = box.querySelector('.vurl').value.trim(); if (!/^https:\/\//.test(u)) { box.querySelector('.vie').textContent = 'Use a link that starts with https://'; return; } try { await put(u, true); toast('Image added'); changed = true; loaded[tab] = false; images(tab, what); } catch (e) { box.querySelector('.vie').textContent = e.message; } };
|
||||
} catch (e) { box.innerHTML = `<p class="err">${esc(e.message)}</p>`; }
|
||||
};
|
||||
let changed = false;
|
||||
el.querySelectorAll('.sgrp a[data-t]').forEach(a => a.onclick = ev => {
|
||||
ev.preventDefault(); const t = a.dataset.t;
|
||||
el.querySelectorAll('.sgrp a[data-t]').forEach(x => { x.classList.toggle('on', x === a); x.setAttribute('aria-selected', x === a); });
|
||||
['d', 'p', 'a'].forEach(k => { el.querySelector('#vtab-' + k).hidden = k !== t; }); el.querySelector('#vs').hidden = t !== 'd';
|
||||
if (t !== 'd' && !loaded[t]) { loaded[t] = true; images(t, t === 'p' ? 'posters' : 'arts'); }
|
||||
});
|
||||
el.querySelector('#vf').onsubmit = async ev => {
|
||||
ev.preventDefault(); const btn = el.querySelector('#vs'); btn.disabled = true; el.querySelector('#ve').textContent = '';
|
||||
const q = new URLSearchParams({ type: VD_TYPE[kind] || 1, id: m.ratingKey, includeExternalMedia: 1 }); let n = 0;
|
||||
for (const [k, , t] of F) { const v = el.querySelector('#ve-' + k).value.trim(); if (v !== (t === 'date' ? dateOf(m[k]) : old(k))) { q.set(k + '.value', v); q.set(k + '.locked', 1); n++; } }
|
||||
if (rich) { const g = [...new Set(el.querySelector('#ve-genre').value.split(',').map(x => x.trim()).filter(Boolean))]; if (g.join('|') !== genres.join('|')) { g.forEach((x, i) => q.set(`genre[${i}].tag.tag`, x)); q.set('genre.locked', 1); n++; } }
|
||||
if (!n) { closeSheet(); if (changed) route(); return; }
|
||||
try {
|
||||
const r = await fetch(`/plex/library/sections/${m.librarySectionID}/all?${q}`, { method: 'PUT' });
|
||||
if (!r.ok) throw new Error(r.status === 403 || r.status === 404 ? 'Only the Plex owner can change details' : 'Plex would not save that (' + r.status + ')');
|
||||
closeSheet(); toast('Saved in Plex'); route();
|
||||
} catch (e) { btn.disabled = false; el.querySelector('#ve').textContent = e.message; }
|
||||
};
|
||||
}
|
||||
@@ -241,6 +241,7 @@ async function plexTvAny(path, token, opts = {}) {
|
||||
}
|
||||
const truthy = v => v === true || v === 1 || v === '1' || v === 'true';
|
||||
/** The people in this Plex Home: [{ id, title, thumb, admin, protected, restricted, guest }]. */
|
||||
const plexPinFails = new Map(); // hub person + Plex person -> wrong guesses
|
||||
async function plexHomeUsers(token) {
|
||||
const { json, text } = await plexTvAny('/api/home/users', token);
|
||||
const raw = json ? (json.users || json.MediaContainer?.User || json.User || []) : [...text.matchAll(/<User\b[^>]*>/g)].map(m => attrs(m[0]));
|
||||
@@ -319,7 +320,7 @@ async function plexApi(req, res) {
|
||||
const u = new URL(req.url, 'http://x'), st = plexLoad(), token = st.token;
|
||||
try {
|
||||
if (u.pathname === '/api/plex/status') {
|
||||
return json(res, 200, { signedIn: !!token, server: st.srv ? st.name || 'Plex' : null, user: st.user ? { id: st.user.id, title: st.user.title, thumb: st.user.thumb, admin: st.user.admin } : null });
|
||||
return json(res, 200, { signedIn: !!token, server: st.srv ? st.name || 'Plex' : null, user: st.user ? { id: st.user.id, title: st.user.title, thumb: st.user.thumb, admin: st.user.admin } : null, canEdit: !!token && await plexCanEdit(st) });
|
||||
}
|
||||
if (u.pathname === '/api/plex/pin' && req.method === 'POST') {
|
||||
const pin = await plexTv('/api/v2/pins?strong=true', null, { method: 'POST' });
|
||||
@@ -363,19 +364,32 @@ async function plexApi(req, res) {
|
||||
const owner = st.mainToken || token;
|
||||
const target = (await plexHomeUsers(owner)).find(x => x.id === String(id));
|
||||
if (!target) return json(res, 404, { error: 'That person is not in your Plex Home' });
|
||||
if (target.admin && !st.mainToken) { plexSave({ ...st, user: { id: target.id, title: target.title, thumb: target.thumb, admin: true } }); return json(res, 200, { ok: true, user: { id: target.id, title: target.title, thumb: target.thumb, admin: true } }); } // already the account that signed in
|
||||
const meNow = st.user ? String(st.user.id) : (target.admin && !st.mainToken ? target.id : '');
|
||||
const same = meNow === target.id; // already using Plex as this person: nothing to prove
|
||||
// Anyone with a PIN in Plex (the owner included) needs it every time you switch to them; plex.tv checks it, and wrong guesses are slowed down here.
|
||||
const gate = (users.current() ? users.current().id : '') + ':' + target.id, fails = plexPinFails.get(gate) || { n: 0, until: 0 };
|
||||
if (target.protected && !same) {
|
||||
if (fails.until > Date.now()) return json(res, 429, { error: `Too many wrong PINs. Try again in ${Math.ceil((fails.until - Date.now()) / 60000)} min.` });
|
||||
if (!pin) return json(res, 403, { error: 'That PIN is not right' });
|
||||
}
|
||||
let userToken;
|
||||
if (target.admin && st.mainToken) userToken = st.mainToken; // back to the account that signed in: no PIN needed
|
||||
if (same) userToken = target.admin && st.mainToken ? st.mainToken : st.token;
|
||||
else if (target.admin && st.mainToken && !target.protected) userToken = st.mainToken; // back to the account that signed in, which has no PIN
|
||||
else {
|
||||
try {
|
||||
const q = pin ? '?' + new URLSearchParams({ pin: String(pin).slice(0, 8) }) : '';
|
||||
const { json: j, text } = await plexTvAny(`/api/home/users/${encodeURIComponent(target.id)}/switch${q}`, owner, { method: 'POST' });
|
||||
userToken = (j && (j.authToken || j.authenticationToken || (j.user && (j.user.authToken || j.user.authenticationToken)))) || (text.match(/authenticationToken="([^"]+)"/) || [])[1];
|
||||
} catch (e) {
|
||||
if (e.status === 401 || e.status === 403) return json(res, 403, { error: target.protected || pin ? 'That PIN is not right' : 'Plex would not switch to that person' });
|
||||
if (e.status === 401 || e.status === 403) {
|
||||
if (target.protected) { fails.n++; if (fails.n >= 5) { fails.until = Date.now() + 10 * 60e3; fails.n = 0; } plexPinFails.set(gate, fails); }
|
||||
return json(res, 403, { error: target.protected || pin ? 'That PIN is not right' : 'Plex would not switch to that person' });
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
if (!userToken && target.admin && st.mainToken) userToken = st.mainToken; // plex.tv accepted the PIN for the owner and gave no new token: the one that signed in is theirs
|
||||
if (!userToken) return json(res, 502, { error: 'Plex did not give a sign-in for that person' });
|
||||
plexPinFails.delete(gate);
|
||||
}
|
||||
const rs = await plexTv(RESOURCES, userToken), r = rs.find(x => x.clientIdentifier === st.sid);
|
||||
if (!r) return json(res, 403, { error: target.title + ' cannot use this Plex server' });
|
||||
@@ -393,8 +407,40 @@ async function plexApi(req, res) {
|
||||
} catch (e) { json(res, 502, { error: e.message }); }
|
||||
}
|
||||
|
||||
// Only playlists may be changed through the proxy (create, add, remove, rename, delete); everything else on the Plex server stays read-only.
|
||||
const PLEX_WRITABLE = /^\/plex\/playlists(\/\d+)?(\/items(\/\d+)?)?\?|^\/plex\/playlists(\/\d+)?(\/items(\/\d+)?)?$/;
|
||||
// Only playlists and a title's own details (the fields Plex's Edit dialog has, and its poster/background choice) may be changed through the proxy;
|
||||
// everything else on the Plex server stays read-only. Plex itself refuses edits from anyone who is not allowed, and the hub checks too (plexCanEdit).
|
||||
const PLEX_PLAYLISTS = /^\/plex\/playlists(\/\d+)?(\/items(\/\d+)?)?\?|^\/plex\/playlists(\/\d+)?(\/items(\/\d+)?)?$/;
|
||||
const EDIT_FIELDS = 'title|titleSort|originalTitle|originallyAvailableAt|contentRating|studio|tagline|summary';
|
||||
const EDIT_TAGS = 'genre|label|collection';
|
||||
const EDIT_KEY = new RegExp(`^(type|id|includeExternalMedia|(${EDIT_FIELDS})\\.(value|locked)|(${EDIT_TAGS})\\.locked|(${EDIT_TAGS})\\[\\d{1,3}\\]\\.tag\\.tag)$`);
|
||||
/** Is this request one of the allowed edits of a title's details? */
|
||||
function plexEditRequest(method, url) {
|
||||
const u = new URL(url, 'http://x'), p = u.pathname;
|
||||
if (u.href.length > 12000) return false;
|
||||
let m;
|
||||
if (method === 'PUT' && (m = /^\/plex\/library\/sections\/\d+\/all$/.exec(p))) { // save fields: ?type=1&id=<key>&title.value=..&title.locked=1
|
||||
const q = u.searchParams;
|
||||
return /^[1-4]$/.test(q.get('type') || '') && /^\d+$/.test(q.get('id') || '') && [...q.keys()].every(k => EDIT_KEY.test(k));
|
||||
}
|
||||
if ((m = /^\/plex\/library\/metadata\/\d+\/(poster|art|posters|arts)$/.exec(p))) { // choose or add a poster / background
|
||||
const img = u.searchParams.get('url') || '';
|
||||
return [...u.searchParams.keys()].every(k => k === 'url') && /^(https:\/\/[^\s]{4,2000}|\/library\/metadata\/\d+\/(file|thumb|art)[^\s]*|(upload|metadata|media):\/\/[\w./%-]{1,300})$/.test(img)
|
||||
&& (m[1].length > 3 ? ['POST', 'PUT'] : ['PUT']).includes(method) && (method === 'PUT' ? /^(poster|art)$/.test(m[1]) : /^(posters|arts)$/.test(m[1]));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const PLEX_WRITABLE_URL = (method, url) => PLEX_PLAYLISTS.test(url) || plexEditRequest(method, url);
|
||||
const editOk = new Map(); // token + server -> { t, ok }
|
||||
/** Only the server's owner (or an admin of the Home) may edit details: the person's own plex.tv view of the server says so. */
|
||||
async function plexCanEdit(st) {
|
||||
if (!st.token || !st.sid) return false;
|
||||
const k = st.token + '|' + st.sid, c = editOk.get(k);
|
||||
if (c && Date.now() - c.t < 5 * 60e3) return c.ok;
|
||||
let ok = false;
|
||||
try { const rs = await plexTv(RESOURCES, st.token); ok = !!rs.find(x => x.clientIdentifier === st.sid && truthy(x.owned)); } catch { return false; }
|
||||
editOk.set(k, { t: Date.now(), ok });
|
||||
return ok;
|
||||
}
|
||||
async function plexProxy(req, res) {
|
||||
const st = plexLoad();
|
||||
if (!st.srv || !st.stk) { res.writeHead(401); return res.end(); }
|
||||
@@ -565,7 +611,7 @@ async function handleRequest(req, res) {
|
||||
if (pathname.startsWith('/api/signal/')) return signal.enabled() ? signal.handle(req, res) : forbidden(res, 'Signal is not set up on this server');
|
||||
if (pathname.startsWith('/api/mail/')) return mail.handle(req, res);
|
||||
if (req.url.startsWith('/api/plex/')) return plexApi(req, res);
|
||||
if (req.url.startsWith('/plex/') && (req.method === 'GET' || (['POST', 'PUT', 'DELETE'].includes(req.method) && PLEX_WRITABLE.test(req.url)))) return plexProxy(req, res);
|
||||
if (req.url.startsWith('/plex/') && (req.method === 'GET' || (['POST', 'PUT', 'DELETE'].includes(req.method) && PLEX_WRITABLE_URL(req.method, req.url) && (PLEX_PLAYLISTS.test(req.url) || await plexCanEdit(plexLoad()))))) return plexProxy(req, res);
|
||||
if (req.url.startsWith('/sf/')) return proxySeafile(req, res);
|
||||
return serveStatic(req, res);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env node
|
||||
// NE Hub calendars: several per person, colors, private by default, shared view-only or edit by the owner's choice, subscribe links.
|
||||
// Calls the calendar module directly as different people (no server needed).
|
||||
// Run: node tools/cal-test.js
|
||||
const fs = require('fs'), os = require('os'), path = require('path');
|
||||
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-cal-'));
|
||||
const users = require('../users'), cal = require('../cal');
|
||||
let failed = 0;
|
||||
const ok = (n, c, x) => { console.log((c ? 'PASS ' : 'FAIL ') + n + (c ? '' : ' -> ' + JSON.stringify(x))); if (!c) failed++; };
|
||||
|
||||
const ann = users.create({ name: 'Ann', role: 'admin' }), bob = users.create({ name: 'Bob', tabs: ['calendar'] }), cy = users.create({ name: 'Cy', tabs: ['calendar'] });
|
||||
async function call(u, p, body, method) {
|
||||
return users.runAs(u, () => new Promise(done => {
|
||||
const { EventEmitter } = require('events');
|
||||
const req = new EventEmitter(); req.url = p; req.method = method || (body ? 'POST' : 'GET'); req.headers = { host: 'hub.test' };
|
||||
const res = { code: 0, writeHead(c) { this.code = c; }, end(t) { let j = {}; try { j = JSON.parse(t); } catch {} done({ status: this.code, json: j, text: t }); } };
|
||||
cal.handle(req, res);
|
||||
process.nextTick(() => { if (body) req.emit('data', Buffer.from(JSON.stringify(body))); req.emit('end'); });
|
||||
}));
|
||||
}
|
||||
const F = (title, d = '2026-11-03') => ({ title, location: '', notes: '', allDay: true, start: d, end: d, tz: 'UTC', repeat: 'none' });
|
||||
const win = '/api/cal/events?from=' + Date.parse('2026-11-01T00:00:00Z') + '&to=' + Date.parse('2026-11-07T00:00:00Z') + '&fd=2026-11-01&td=2026-11-07';
|
||||
const titles = async u => (await call(u, win)).json.events.map(e => e.title).sort().join('|');
|
||||
|
||||
(async () => {
|
||||
let r = await call(ann, '/api/cal/list');
|
||||
ok('everyone starts with the one NE Hub calendar', r.json.length === 1 && r.json[0].id === 'local' && r.json[0].name === 'NE Hub' && r.json[0].color === '#4f7cff', r.json);
|
||||
|
||||
// legacy events (no calendar field) belong to the first calendar
|
||||
fs.writeFileSync(path.join(users.dirOf(ann.id), 'events.json'), JSON.stringify([{ uid: 'old1', fields: F('Old event'), created: 1, updated: 1 }]));
|
||||
ok('older events stay in the first calendar', (await call(ann, win)).json.events.find(e => e.title === 'Old event').calId === 'local');
|
||||
|
||||
// create, recolor, rename
|
||||
r = await call(ann, '/api/cal/ncal', { name: ' Family ', color: '#d9453a' }); const fam = r.json.id;
|
||||
ok('a person can make another calendar', r.status === 200 && fam && (await call(ann, '/api/cal/list')).json.length === 2);
|
||||
r = await call(ann, '/api/cal/ncal', { name: ' ' }); ok('a calendar needs a name', r.status === 400, r);
|
||||
r = await call(ann, '/api/cal/ncal/update', { id: fam, color: 'red' }); ok('colors must be real hex colors', r.status === 400, r);
|
||||
await call(ann, '/api/cal/ncal/update', { id: fam, name: 'Family stuff', color: '#8a5cf0' });
|
||||
const l = (await call(ann, '/api/cal/list')).json.find(c => c.id === fam); ok('rename and recolor stick', l.name === 'Family stuff' && l.color === '#8a5cf0', l);
|
||||
|
||||
// events go into the chosen calendar and carry its color
|
||||
await call(ann, '/api/cal/event', { cal: fam, fields: F('Dinner') });
|
||||
await call(ann, '/api/cal/event', { cal: 'local', fields: F('Work thing') });
|
||||
const ev = (await call(ann, win)).json.events;
|
||||
const dn = ev.find(e => e.title === 'Dinner'), wk = ev.find(e => e.title === 'Work thing');
|
||||
ok('events use the color of their calendar', dn.color === '#8a5cf0' && dn.calId === fam && wk.color === '#4f7cff' && wk.calId === 'local', [dn, wk]);
|
||||
ok('an event can be read back from its calendar', (await call(ann, `/api/cal/event?cal=${fam}&uid=${dn.uid}&tz=UTC`)).json.fields.title === 'Dinner');
|
||||
ok('the same uid is not found in a different calendar', (await call(ann, `/api/cal/event?cal=local&uid=${dn.uid}&tz=UTC`)).status === 400);
|
||||
|
||||
// private by default
|
||||
ok('other people see nothing of it by default', (await titles(bob)) === '' && (await call(bob, '/api/cal/list')).json.length === 1);
|
||||
r = await call(bob, '/api/cal/event', { cal: `${ann.id}~${fam}`, fields: F('Sneaky') }); ok('and cannot write to it', r.status === 400, r);
|
||||
r = await call(bob, `/api/cal/event?cal=${ann.id}~${fam}&uid=${dn.uid}&tz=UTC`); ok('or read it', r.status === 400, r);
|
||||
|
||||
// sharing, with the owner's choice of view or edit per person
|
||||
r = await call(ann, '/api/cal/people'); ok('the owner can see who it can be shared with (not themself)', r.json.map(p => p.name).sort().join() === 'Bob,Cy', r.json);
|
||||
r = await call(ann, '/api/cal/ncal/update', { id: fam, shares: [{ user: bob.id, mode: 'view' }, { user: cy.id, mode: 'edit' }, { user: ann.id, mode: 'edit' }, { user: 'nobody', mode: 'edit' }] });
|
||||
ok('sharing saves, ignoring yourself and unknown people', r.status === 200 && (await call(ann, '/api/cal/list')).json.find(c => c.id === fam).shares.length === 2);
|
||||
ok('a view-only person sees the events', (await titles(bob)) === 'Dinner');
|
||||
const bl = (await call(bob, '/api/cal/list')).json.find(c => c.shared);
|
||||
ok('and the calendar in their list, marked shared with the owner and their access', bl && bl.mode === 'view' && bl.owner === 'Ann' && bl.name === 'Family stuff', bl);
|
||||
const bev = (await call(bob, win)).json.events[0]; ok('shared events say they cannot be edited', bev.editable === false && bev.shared === true, bev);
|
||||
r = await call(bob, '/api/cal/event', { cal: `${ann.id}~${fam}`, fields: F('Bob adds') }); ok('view-only people cannot add', r.status === 400 && /view only/.test(r.json.error), r);
|
||||
r = await call(bob, '/api/cal/event/delete', { cal: `${ann.id}~${fam}`, uid: dn.uid }); ok('or delete', r.status === 400, r);
|
||||
ok('and still see exactly what they saw', (await titles(bob)) === 'Dinner');
|
||||
r = await call(cy, '/api/cal/event', { cal: `${ann.id}~${fam}`, fields: F('Cy adds') }); ok('an edit person can add', r.status === 200, r);
|
||||
r = await call(cy, '/api/cal/event', { cal: `${ann.id}~${fam}`, uid: dn.uid, fields: F('Dinner at 7') }); ok('and change events', r.status === 200);
|
||||
ok('the owner sees those changes', (await titles(ann)).includes('Cy adds') && (await titles(ann)).includes('Dinner at 7'));
|
||||
ok('Cy saw it in the owner\'s calendar, not in their own', (await call(cy, '/api/cal/list')).json.filter(c => !c.shared).length === 1);
|
||||
r = await call(cy, '/api/cal/ncal/update', { id: fam, shares: [] }); ok("a person can't change the sharing of someone else's calendar", r.status === 400, r);
|
||||
r = await call(cy, '/api/cal/event/delete', { cal: `${ann.id}~${fam}`, uid: dn.uid }); ok('an edit person can delete events', r.status === 200 && !(await titles(ann)).includes('Dinner at 7'));
|
||||
|
||||
// everybody's own color for things shared with them / not theirs
|
||||
await call(bob, '/api/cal/color', { id: `${ann.id}~${fam}`, color: '#2f9e6e' });
|
||||
ok('a shared calendar can be given your own color', (await call(bob, win)).json.events[0].color === '#2f9e6e' && (await call(ann, win)).json.events.find(e => e.title === 'Cy adds').color === '#8a5cf0');
|
||||
|
||||
// stopping sharing
|
||||
await call(ann, '/api/cal/ncal/update', { id: fam, shares: [{ user: cy.id, mode: 'edit' }] });
|
||||
ok('removing someone hides it from them right away', (await titles(bob)) === '' && (await call(bob, '/api/cal/list')).json.length === 1);
|
||||
|
||||
// subscribe links
|
||||
const feedOf = async (u, id, reset) => { const x = await call(u, '/api/cal/ncal/feed', { id, reset }); return x.json.url; };
|
||||
const get = async url => { const res = { text: '', code: 0, writeHead(c) { this.code = c; }, end(t) { this.text = t; } }; cal.feed({ url: new URL(url).pathname }, res); return res; };
|
||||
const u1 = await feedOf(ann, fam), u2 = await feedOf(ann, fam), u3 = await feedOf(ann, 'local');
|
||||
ok('each calendar has its own stable subscribe link', u1 === u2 && u1 !== u3 && /^http:\/\/hub\.test\/cal\/feed\/[0-9a-f]{48}\.ics$/.test(u1), [u1, u3]);
|
||||
const f1 = await get(u1), f3 = await get(u3);
|
||||
ok('it shows only that calendar', f1.code === 200 && f1.text.includes('Cy adds') && !f1.text.includes('Work thing') && f3.text.includes('Work thing') && !f3.text.includes('Cy adds'));
|
||||
const all = await get((await call(ann, '/api/cal/feed')).json.url);
|
||||
ok('the person-wide link still shows all of their calendars', all.text.includes('Cy adds') && all.text.includes('Work thing') && all.text.includes('Old event'));
|
||||
const u4 = await feedOf(ann, fam, true); const old = await get(u1);
|
||||
ok('making a new link kills the old one', u4 !== u1 && old.code === 404 && (await get(u4)).code === 200);
|
||||
|
||||
// delete
|
||||
r = await call(ann, '/api/cal/ncal/delete', { id: fam }); ok('deleting a calendar works', r.status === 200 && (await call(ann, '/api/cal/list')).json.length === 1);
|
||||
ok('and removes its events and its share', !(await titles(ann)).includes('Cy adds') && (await titles(cy)) === '');
|
||||
r = await call(ann, '/api/cal/ncal/delete', { id: 'local' }); ok('the last calendar cannot be deleted', r.status === 400, r);
|
||||
ok('older events survived all that', (await titles(ann)).includes('Old event'));
|
||||
|
||||
// link-calendar colors
|
||||
fs.writeFileSync(path.join(users.dirOf(bob.id), 'calendars.json'), JSON.stringify([{ id: 'lnk', name: 'Holidays', url: 'https://example.com/x.ics', color: '#e5a00d' }]));
|
||||
await call(bob, '/api/cal/color', { id: 'lnk', color: '#112233' });
|
||||
ok('a calendar link can be recolored', (await call(bob, '/api/cal/list')).json.find(c => c.id === 'lnk').color === '#112233');
|
||||
r = await call(bob, '/api/cal/color', { id: 'lnk', color: 'blue' }); ok('with a real color only', r.status === 400);
|
||||
|
||||
console.log(failed ? `\n${failed} FAILED` : '\nall passed'); process.exit(failed ? 1 : 0);
|
||||
})();
|
||||
+31
-5
@@ -16,26 +16,27 @@ const jsonOut = (rs, code, o) => { rs.writeHead(code, { 'content-type': 'applica
|
||||
|
||||
(async () => {
|
||||
// ---- fake plex.tv
|
||||
const tv = { switchCalls: [], homeJson: false, accountToken: 'ACCOUNT', serverToken: 'SRV1', plexPort: 0, pinPolls: 0, resourceCalls: 0 };
|
||||
const tv = { adminPin: false, switchCalls: [], homeJson: false, accountToken: 'ACCOUNT', serverToken: 'SRV1', plexPort: 0, pinPolls: 0, resourceCalls: 0 };
|
||||
const plexTv = await listen((rq, rs) => {
|
||||
const u = new URL(rq.url, 'http://x'); let m;
|
||||
if (rq.method === 'POST' && u.pathname === '/api/v2/pins') return jsonOut(rs, 201, { id: 42, code: 'ABCD' });
|
||||
if (u.pathname === '/api/v2/pins/42') return jsonOut(rs, 200, { authToken: ++tv.pinPolls >= 2 ? tv.accountToken : null });
|
||||
if (u.pathname === '/api/home/users' && rq.method === 'GET') {
|
||||
if (!['ACCOUNT'].includes(rq.headers['x-plex-token'])) { rs.writeHead(401); return rs.end(); }
|
||||
if (tv.homeJson) return jsonOut(rs, 200, { users: [{ id: 1, title: 'Nate', admin: true, protected: false, thumb: 'https://plex.example/a.png' }, { id: 2, title: 'Kid', admin: false, protected: true, restricted: true }, { id: 3, title: 'Guest', admin: false, protected: false }] });
|
||||
rs.writeHead(200, { 'content-type': 'application/xml' }); return rs.end('<?xml version="1.0"?><MediaContainer size="3"><User id="1" title="Nate" admin="1" protected="0" thumb="https://plex.example/a.png"/><User id="2" title="Kid" admin="0" protected="1" restricted="1"/><User id="3" title="Guest" admin="0" protected="0"/></MediaContainer>');
|
||||
if (tv.homeJson) return jsonOut(rs, 200, { users: [{ id: 1, title: 'Nate', admin: true, protected: tv.adminPin, thumb: 'https://plex.example/a.png' }, { id: 2, title: 'Kid', admin: false, protected: true, restricted: true }, { id: 3, title: 'Guest', admin: false, protected: false }] });
|
||||
rs.writeHead(200, { 'content-type': 'application/xml' }); return rs.end('<?xml version="1.0"?><MediaContainer size="3"><User id="1" title="Nate" admin="1" protected="' + (tv.adminPin ? 1 : 0) + '" thumb="https://plex.example/a.png"/><User id="2" title="Kid" admin="0" protected="1" restricted="1"/><User id="3" title="Guest" admin="0" protected="0"/></MediaContainer>');
|
||||
}
|
||||
if ((m = /^\/api\/home\/users\/(\d+)\/switch$/.exec(u.pathname)) && rq.method === 'POST') {
|
||||
tv.switchCalls.push(m[1] + ':' + (u.searchParams.get('pin') || '') + ':' + rq.headers['x-plex-token']);
|
||||
if (m[1] === '1' && u.searchParams.get('pin') !== '9999') { rs.writeHead(403); return rs.end('<errors><error>bad pin</error></errors>'); }
|
||||
if (m[1] === '2' && u.searchParams.get('pin') !== '1234') { rs.writeHead(403); return rs.end('<errors><error>bad pin</error></errors>'); }
|
||||
rs.writeHead(200, { 'content-type': 'application/xml' }); return rs.end(`<user id="${m[1]}" authenticationToken="${m[1] === '2' ? 'KIDTOKEN' : 'GUESTTOKEN'}"/>`);
|
||||
rs.writeHead(200, { 'content-type': 'application/xml' }); return rs.end(`<user id="${m[1]}" authenticationToken="${m[1] === '2' ? 'KIDTOKEN' : m[1] === '1' ? 'ACCOUNT' : 'GUESTTOKEN'}"/>`);
|
||||
}
|
||||
if (u.pathname === '/api/v2/resources') {
|
||||
tv.resourceCalls++;
|
||||
const who = rq.headers['x-plex-token'], other = { KIDTOKEN: 'KIDSRV', GUESTTOKEN: 'GUESTSRV' }[who];
|
||||
if (who !== tv.accountToken && !other) return jsonOut(rs, 401, { error: 'bad token' });
|
||||
return jsonOut(rs, 200, [{ name: 'E-va', clientIdentifier: 'sid1', provides: 'server', owned: true, accessToken: other || tv.serverToken,
|
||||
return jsonOut(rs, 200, [{ name: 'E-va', clientIdentifier: 'sid1', provides: 'server', owned: who === tv.accountToken, accessToken: other || tv.serverToken,
|
||||
connections: [{ uri: `http://127.0.0.1:${tv.plexPort}`, local: false, relay: false, address: '127.0.0.1', port: tv.plexPort }] }]);
|
||||
}
|
||||
rs.writeHead(404); rs.end();
|
||||
@@ -94,6 +95,18 @@ const jsonOut = (rs, code, o) => { rs.writeHead(code, { 'content-type': 'applica
|
||||
const bad = [['DELETE', '/plex/library/metadata/5'], ['PUT', '/plex/library/sections/1/refresh'], ['POST', '/plex/playlists/7/../library/metadata/5'], ['DELETE', '/plex/library/sections/1']];
|
||||
const refused = []; for (const [m, p] of bad) { const x = await call(p, undefined, m); refused.push(x.status); }
|
||||
ok('but nothing else on the Plex server can be changed through the hub', refused.every(x => x >= 400) && !plexReqs.length, [refused, plexReqs]);
|
||||
// ---- editing a title's details (what Plex's Edit dialog has) and choosing its poster
|
||||
r = await call('/api/plex/status'); ok('the owner is told they can edit details', r.json.canEdit === true, r.json);
|
||||
const E = '/plex/library/sections/1/all?type=1&id=5&title.value=' + encodeURIComponent('New & Title') + '&title.locked=1&summary.value=Hi&summary.locked=1&genre%5B0%5D.tag.tag=Drama&genre%5B1%5D.tag.tag=Comedy&genre.locked=1&originallyAvailableAt.value=2020-01-02&originallyAvailableAt.locked=1';
|
||||
plexReqs.length = 0; r = await call(E, undefined, 'PUT');
|
||||
ok('the owner can save a title\'s details, locked so a refresh keeps them', r.status === 200 && plexReqs.length === 1 && plexReqs[0].startsWith('PUT /library/sections/1/all?type=1&id=5&title.value=New') && plexReqs[0].includes('title.locked=1'), [r.status, plexReqs]);
|
||||
plexReqs.length = 0; r = await call('/plex/library/metadata/5/poster?url=' + encodeURIComponent('/library/metadata/5/thumb/123'), undefined, 'PUT'); ok('and pick one of its posters', r.status === 200 && plexReqs.length === 1, [r.status, plexReqs]);
|
||||
plexReqs.length = 0; r = await call('/plex/library/metadata/5/posters?url=' + encodeURIComponent('https://img.example/p.jpg'), undefined, 'POST'); ok('or add one from an image link', r.status === 200 && plexReqs.length === 1, [r.status, plexReqs]);
|
||||
plexReqs.length = 0;
|
||||
const badEdit = [['PUT', '/plex/library/sections/1/all?type=1&id=5&mediaType=9&title.value=x'], ['PUT', '/plex/library/sections/1/all?type=1&id=5&deleteAll=1'], ['PUT', '/plex/library/sections/1/all?type=1&title.value=x'], ['PUT', '/plex/library/sections/1/all?type=9&id=5'],
|
||||
['DELETE', '/plex/library/sections/1/all?type=1&id=5&title.value=x'], ['PUT', '/plex/library/metadata/5/poster?url=file:///etc/passwd'], ['PUT', '/plex/library/metadata/5/poster?url=http://10.0.0.1/x.jpg'], ['PUT', '/plex/library/metadata/5/poster?url=https://a.example/x.jpg&extra=1'], ['POST', '/plex/library/metadata/5/poster?url=https://a.example/x.jpg'], ['PUT', '/plex/library/metadata/5/posters?url=https://a.example/x.jpg']];
|
||||
const bst = []; for (const [m, p] of badEdit) bst.push((await call(p, undefined, m)).status);
|
||||
ok('anything else in an edit request is refused before it reaches Plex', bst.every(x => x >= 400) && !plexReqs.length, [bst, plexReqs]);
|
||||
|
||||
// ================= Plex Home: using Plex as someone else in the Home =================
|
||||
r = await call('/api/plex/users'); ok('the Plex Home is listed (read from plex.tv\'s XML), with who is using NE Hub now', r.status === 200 && r.json.users.length === 3 && r.json.users.find(x => x.id === '1').current && r.json.users.find(x => x.id === '2').protected && r.json.users.find(x => x.id === '2').restricted && r.json.users.find(x => x.id === '1').admin, r.json);
|
||||
@@ -105,6 +118,7 @@ const jsonOut = (rs, code, o) => { rs.writeHead(code, { 'content-type': 'applica
|
||||
seen.length = 0; r = await call('/api/plex/switch', { id: '2', pin: '1234' }); ok('with the right PIN the hub switches to that person', r.status === 200 && r.json.user.title === 'Kid', r);
|
||||
r = await call('/api/plex/status'); ok('status says who is using Plex now', r.json.user && r.json.user.title === 'Kid' && r.json.signedIn && r.json.server === 'E-va', r.json);
|
||||
r = await call('/plex/library/sections'); ok('Plex requests now use that person\'s own server token', r.status === 200 && seen[seen.length - 1] === 'KIDSRV', [r.status, seen]);
|
||||
plexReqs.length = 0; r = await call('/api/plex/status'); const ke = await call(E, undefined, 'PUT'); ok('a Home member who is not the owner cannot edit (no Edit button, and the hub refuses)', r.json.canEdit === false && ke.status >= 400 && !plexReqs.length, [r.json, ke.status, plexReqs]);
|
||||
s = savedPlex(); ok('the owner\'s sign-in is kept (to switch back, and to list the Home) and the new one is saved', s && s.mainToken === 'ACCOUNT' && s.token === 'KIDTOKEN' && s.stk === 'KIDSRV' && s.user.title === 'Kid', s);
|
||||
r = await call('/api/plex/users'); ok('the list still works as the owner and marks the new current person', r.json.users.find(x => x.id === '2').current && !r.json.users.find(x => x.id === '1').current, r.json);
|
||||
r = await call('/api/plex/switch', { id: '3' }); ok('switching straight to another person works (no PIN needed for them)', r.status === 200 && r.json.user.title === 'Guest', r);
|
||||
@@ -112,6 +126,18 @@ const jsonOut = (rs, code, o) => { rs.writeHead(code, { 'content-type': 'applica
|
||||
tv.switchCalls.length = 0; r = await call('/api/plex/switch', { id: '1' }); r = await call('/api/plex/status'); r2 = await call('/plex/library/sections');
|
||||
ok('switching back to the owner needs no PIN and no call to plex.tv', r.json.user.title === 'Nate' && tv.switchCalls.length === 0 && r2.status === 200 && seen[seen.length - 1] === 'SRV1', [r.json, tv.switchCalls, seen.slice(-2)]);
|
||||
s = savedPlex(); ok('and uses the owner\'s own tokens again', s.token === 'ACCOUNT' && s.stk === 'SRV1', s);
|
||||
// the owner has a PIN in Plex: the hub asks for it too, plex.tv checks it, and guessing is slowed down
|
||||
tv.adminPin = true; await call('/api/plex/switch', { id: '3' });
|
||||
r = await call('/api/plex/users'); ok('an owner with a PIN is marked protected in the list', r.json.users.find(x => x.id === '1').protected === true, r.json);
|
||||
tv.switchCalls.length = 0; r = await call('/api/plex/switch', { id: '1' }); ok('going back to the owner asks for the PIN', r.status === 403 && /PIN/.test(r.json.error) && !tv.switchCalls.length, [r, tv.switchCalls]);
|
||||
r = await call('/api/plex/switch', { id: '1', pin: '1111' }); ok('a wrong owner PIN is refused by plex.tv and nothing changes', r.status === 403 && (await call('/api/plex/status')).json.user.title === 'Guest', r);
|
||||
r = await call('/api/plex/switch', { id: '1', pin: '9999' }); ok('the right owner PIN switches back, and the wrong-guess count starts over', r.status === 200 && r.json.user.title === 'Nate', r);
|
||||
await call('/api/plex/switch', { id: '3' }); tv.switchCalls.length = 0;
|
||||
r = await call('/api/plex/switch', { id: '1', pin: '1111' });
|
||||
for (let i = 0; i < 4; i++) r = await call("/api/plex/switch", { id: "1", pin: "2222" });
|
||||
r = await call('/api/plex/switch', { id: '1', pin: '9999' }); ok('after five wrong guesses even the right PIN waits', r.status === 429 && /Try again/.test(r.json.error), r);
|
||||
ok('and plex.tv is not asked while waiting', tv.switchCalls.length === 5, tv.switchCalls);
|
||||
tv.adminPin = false;
|
||||
hub.child.kill(); await wait(400); hub = startHub(); await ready();
|
||||
await call('/api/plex/switch', { id: '2', pin: '1234' }); hub.child.kill(); await wait(400); hub = startHub(); await ready();
|
||||
r = await call('/api/plex/status'); ok('AFTER A RESTART: still using Plex as the chosen person', r.json.user && r.json.user.title === 'Kid', r.json);
|
||||
|
||||
Reference in new issue
Block a user