Movies & TV: favorites, a family movie night list to vote on, and mixes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fe651a8c37
commit
2766424bb2
7 files changed
+107
-2
No files matched your search
@@ -32,6 +32,7 @@ jobs:
|
|||||||
- run: node tools/files-test.js # the Seafile pass-through: timeouts, retries, closed downloads, uploads
|
- run: node tools/files-test.js # the Seafile pass-through: timeouts, retries, closed downloads, uploads
|
||||||
- run: node tools/backup-test.js # backup restore (unsafe archives refused, old data kept) and personal export
|
- run: node tools/backup-test.js # backup restore (unsafe archives refused, old data kept) and personal export
|
||||||
- run: node tools/listening-test.js # listening stats: real listening counted, seeks not, streak and finished books
|
- run: node tools/listening-test.js # listening stats: real listening counted, seeks not, streak and finished books
|
||||||
|
- run: node tools/moviefam-test.js # Movies & TV: favorites and the family movie night vote
|
||||||
- run: node tools/signal-test.js # Signal: linking, messages, sending, live updates
|
- run: node tools/signal-test.js # Signal: linking, messages, sending, live updates
|
||||||
- run: node tools/podcast-test.js # Podgrab podcasts: only allowed calls pass, password stays on the server
|
- run: node tools/podcast-test.js # Podgrab podcasts: only allowed calls pass, password stays on the server
|
||||||
- run: node tools/abapi-test.js # audiobooks: signed in as the person, audio ranges, progress, blocked calls
|
- run: node tools/abapi-test.js # audiobooks: signed in as the person, audio ranges, progress, blocked calls
|
||||||
|
|||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
// Movies & TV for the family: Favorites (each person's own hearted titles) and Movie night (one shared list everyone adds to and votes on, so the family picks together).
|
||||||
|
// Titles are kept as a small card (Plex id, title, year, poster path), so the lists draw without asking Plex. Favorites: users/<id>/video-favs.json; Movie night: DATA_DIR/movienight.json.
|
||||||
|
const fs = require('fs'), path = require('path');
|
||||||
|
const atomicWrite = require('./atomic');
|
||||||
|
const users = require('./users');
|
||||||
|
const { readBody } = require('./body');
|
||||||
|
|
||||||
|
const NIGHT = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'movienight.json');
|
||||||
|
const FAVS = () => path.join(users.dir(), 'video-favs.json');
|
||||||
|
const rd = (f, d) => { try { const v = JSON.parse(fs.readFileSync(f, 'utf8')); return Array.isArray(v) ? v : d; } catch { return d; } };
|
||||||
|
const wr = (f, v) => atomicWrite(f, JSON.stringify(v), { mode: 0o600 });
|
||||||
|
const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); };
|
||||||
|
const fail = (code, message) => Object.assign(new Error(message), { code });
|
||||||
|
const str = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u001f]/g, '').trim().slice(0, n);
|
||||||
|
/** A title card from what the browser sent: only the fields a card needs, and the poster path must be a Plex path of ours. */
|
||||||
|
function card(b) {
|
||||||
|
const id = str(b.id, 20); if (!/^\d{1,12}$/.test(id)) throw fail(400, 'Pick a movie or show');
|
||||||
|
const title = str(b.title, 160); if (!title) throw fail(400, 'That title has no name');
|
||||||
|
return { id, title, year: Math.round(+b.year) || 0, type: b.type === 'show' ? 'show' : 'movie', thumb: /^\/library\/metadata\/\d+\/thumb(\/\d+)?$/.test(str(b.thumb, 120)) ? str(b.thumb, 120) : '' };
|
||||||
|
}
|
||||||
|
const nameOf = id => (users.get(id) || {}).name || 'Someone';
|
||||||
|
const nightView = (l, me) => l.map(x => ({ ...x, byName: nameOf(x.by), votes: x.votes.length, voted: x.votes.includes(me.id), voters: x.votes.map(nameOf) })).sort((a, b) => b.votes - a.votes || a.added - b.added);
|
||||||
|
|
||||||
|
async function handle(req, res) {
|
||||||
|
const me = users.current(), p = new URL(req.url, 'http://x').pathname.slice('/api/moviefam'.length) || '/';
|
||||||
|
try {
|
||||||
|
if (!me) throw fail(401, 'Sign in');
|
||||||
|
if (p === '/night' && req.method === 'GET') return json(res, 200, { items: nightView(rd(NIGHT(), []), me) });
|
||||||
|
if (p === '/favs' && req.method === 'GET') return json(res, 200, { items: rd(FAVS(), []) });
|
||||||
|
if (req.method !== 'POST') throw fail(404, 'Not found');
|
||||||
|
const b = JSON.parse(await readBody(req, 1e4) || '{}');
|
||||||
|
if (p === '/fav') { const c = card(b), l = rd(FAVS(), []).filter(x => x.id !== c.id); if (b.on) l.unshift({ ...c, added: Date.now() }); wr(FAVS(), l.slice(0, 500)); return json(res, 200, { on: !!b.on }); }
|
||||||
|
const l = rd(NIGHT(), []);
|
||||||
|
if (p === '/night/add') { const c = card(b); if (l.some(x => x.id === c.id)) throw fail(400, c.title + ' is already on the list'); if (l.length >= 60) throw fail(400, 'The list is full; remove something first'); l.push({ ...c, by: me.id, added: Date.now(), votes: [me.id] }); wr(NIGHT(), l); return json(res, 200, { items: nightView(l, me) }); }
|
||||||
|
if (p === '/night/clear') { if (me.role !== 'admin') throw fail(403, 'Only an admin can clear the list'); wr(NIGHT(), []); return json(res, 200, { items: [] }); }
|
||||||
|
const x = l.find(y => y.id === str(b.id, 20)); if (!x) throw fail(404, 'That title is not on the list');
|
||||||
|
if (p === '/night/vote') { x.votes = x.votes.includes(me.id) ? x.votes.filter(v => v !== me.id) : [...x.votes, me.id]; wr(NIGHT(), l); return json(res, 200, { items: nightView(l, me) }); }
|
||||||
|
if (p === '/night/remove') { if (x.by !== me.id && me.role !== 'admin') throw fail(403, 'Only the person who added it can remove it'); wr(NIGHT(), l.filter(y => y !== x)); return json(res, 200, { items: nightView(l.filter(y => y !== x), me) }); }
|
||||||
|
throw fail(404, 'Not found');
|
||||||
|
} catch (e) { json(res, e.code && e.code < 600 ? e.code : 400, { error: e.message }); }
|
||||||
|
}
|
||||||
|
module.exports = { handle };
|
||||||
@@ -1102,3 +1102,4 @@ body:has(#sheet) #simplehome, body:has(#sheet) #kidreq, body:has(#lyr) #simpleho
|
|||||||
.lsbars { display: flex; align-items: flex-end; gap: 4px; height: 44px; margin: 14px 0 4px; } .lsbars i { flex: 1; background: var(--accent); border-radius: 3px; opacity: .85; }
|
.lsbars { display: flex; align-items: flex-end; gap: 4px; height: 44px; margin: 14px 0 4px; } .lsbars i { flex: 1; background: var(--accent); border-radius: 3px; opacity: .85; }
|
||||||
.lsgoal { height: 6px; background: var(--panel2); border-radius: 3px; overflow: hidden; margin-top: 8px; } .lsgoal i { display: block; height: 100%; background: var(--accent); }
|
.lsgoal { height: 6px; background: var(--panel2); border-radius: 3px; overflow: hidden; margin-top: 8px; } .lsgoal i { display: block; height: 100%; background: var(--accent); }
|
||||||
.invlist { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 6px 0; } .invdot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin: 0 6px 0 2px; }
|
.invlist { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: 6px 0; } .invdot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin: 0 6px 0 2px; }
|
||||||
|
.vnrow { gap: 10px; } .vnart { width: 44px; flex: none; aspect-ratio: 2/3; border-radius: 6px; overflow: hidden; background: var(--panel2); display: grid; place-items: center; } .vnart img { width: 100%; height: 100%; object-fit: cover; } .vnvote { min-height: 36px; padding: 0 12px; flex: none; }
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
// App-shell cache only; Seafile/API traffic always goes to the network.
|
// App-shell cache only; Seafile/API traffic always goes to the network.
|
||||||
const CACHE = 'hub-v106';
|
const CACHE = 'hub-v108';
|
||||||
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder
|
||||||
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png'];
|
||||||
|
|
||||||
|
|||||||
+33
-1
@@ -73,6 +73,21 @@ async function vdRemove(m, quiet) {
|
|||||||
vd.deck = (vd.deck || []).filter(x => x.ratingKey !== m.ratingKey);
|
vd.deck = (vd.deck || []).filter(x => x.ratingKey !== m.ratingKey);
|
||||||
try { const k = 'vc:' + (ME ? ME.id : '') + ':deck', c = JSON.parse(localStorage.getItem(k) || '[]').filter(x => x.ratingKey !== m.ratingKey); localStorage.setItem(k, JSON.stringify(c)); } catch {}
|
try { const k = 'vc:' + (ME ? ME.id : '') + ':deck', c = JSON.parse(localStorage.getItem(k) || '[]').filter(x => x.ratingKey !== m.ratingKey); localStorage.setItem(k, JSON.stringify(c)); } catch {}
|
||||||
}
|
}
|
||||||
|
// ---- Favorites (yours) and Movie night (the family's list to vote on)
|
||||||
|
const vfApi = (p, o) => jfetch('/api/moviefam' + p, o), vfOpt = o => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) });
|
||||||
|
const vfCard = x => ({ ratingKey: x.id, type: x.type, title: x.title, year: x.year || undefined, thumb: x.thumb || undefined });
|
||||||
|
function vdFamilyRows() {
|
||||||
|
const drawNight = items => {
|
||||||
|
const el = $('#vnight'); if (!el) return;
|
||||||
|
el.innerHTML = items.length ? `<h2>Movie night</h2><div class="list">${items.map((x, i) => `<div class="row vnrow"><a class="vnart" href="#/video/item/${esc(x.id)}">${x.thumb ? `<img loading="lazy" alt="" src="${vdArt(x.thumb, 90, 135)}" onerror="this.remove()">` : icon('film')}</a>
|
||||||
|
<a class="grow" href="#/video/item/${esc(x.id)}" style="white-space:normal"><b>${esc(x.title)}</b><small>${esc([x.year || '', 'added by ' + x.byName].filter(Boolean).join(' · '))}${x.voters.length ? ' · ' + esc(x.voters.join(', ')) : ''}</small></a>
|
||||||
|
<button class="btn${x.voted ? '' : ' ghost'} vnvote" data-v="${esc(x.id)}" aria-pressed="${x.voted}" aria-label="${x.voted ? 'Take back your vote for ' : 'Vote for '}${esc(x.title)}">${i === 0 && x.votes > 1 ? '★ ' : ''}${x.votes} ${x.votes === 1 ? 'vote' : 'votes'}</button><button class="ibtn" data-r="${esc(x.id)}" aria-label="Remove ${esc(x.title)}" title="Remove">${icon('x')}</button></div>`).join('')}</div>` : '';
|
||||||
|
el.querySelectorAll('[data-v]').forEach(b => b.onclick = async () => { try { drawNight((await vfApi('/night/vote', vfOpt({ id: b.dataset.v }))).items); } catch (e) { toast(e.message); } });
|
||||||
|
el.querySelectorAll('[data-r]').forEach(b => b.onclick = async () => { try { drawNight((await vfApi('/night/remove', vfOpt({ id: b.dataset.r }))).items); } catch (e) { toast(e.message); } });
|
||||||
|
};
|
||||||
|
vfApi('/night').then(r => drawNight(r.items)).catch(() => {});
|
||||||
|
vfApi('/favs').then(r => { const el = $('#vfavr'); if (el) el.innerHTML = r.items.length ? `<h2>Favorites</h2><div class="vrow">${r.items.slice(0, 30).map(x => vdCard(vfCard(x), false)).join('')}</div>` : ''; }).catch(() => {});
|
||||||
|
}
|
||||||
async function videoHome(st, libKey) {
|
async function videoHome(st, libKey) {
|
||||||
const v = $('#view');
|
const v = $('#view');
|
||||||
const secs = ((await px.get('/library/sections')).Directory || []).filter(d => d.type === 'movie' || d.type === 'show');
|
const secs = ((await px.get('/library/sections')).Directory || []).filter(d => d.type === 'movie' || d.type === 'show');
|
||||||
@@ -81,7 +96,7 @@ async function videoHome(st, libKey) {
|
|||||||
store.set('video_lib', cur.key);
|
store.set('video_lib', cur.key);
|
||||||
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Movies & TV</h1><span class="sub" id="phu"></span>${secs.length > 1 ? `<div class="sgrp" role="tablist" aria-label="Library">${secs.map(s => `<a href="#/video/lib/${s.key}" role="tab" aria-selected="${s.key === cur.key}" class="${s.key === cur.key ? 'on' : ''}">${esc(s.title)}</a>`).join('')}</div>` : ''}</div>
|
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Movies & TV</h1><span class="sub" id="phu"></span>${secs.length > 1 ? `<div class="sgrp" role="tablist" aria-label="Library">${secs.map(s => `<a href="#/video/lib/${s.key}" role="tab" aria-selected="${s.key === cur.key}" class="${s.key === cur.key ? 'on' : ''}">${esc(s.title)}</a>`).join('')}</div>` : ''}</div>
|
||||||
<input type="search" id="vq" placeholder="Search titles, actors, directors" autocomplete="off" aria-label="Search"><div id="vsr"></div>
|
<input type="search" id="vq" placeholder="Search titles, actors, directors" autocomplete="off" aria-label="Search"><div id="vsr"></div>
|
||||||
<div id="vhero"></div><div id="vdeck"></div><div id="vwlr"></div><div id="vcolr"></div><div id="vrecent"></div><div id="vunw"></div><h2 id="vlt">${esc(cur.title)}</h2>
|
<div id="vhero"></div><div id="vdeck"></div><div id="vmix" class="seg"></div><div id="vnight"></div><div id="vwlr"></div><div id="vfavr"></div><div id="vcolr"></div><div id="vrecent"></div><div id="vunw"></div><h2 id="vlt">${esc(cur.title)}</h2>
|
||||||
<div class="bar vfilt"><select id="vsort" aria-label="Sort"><option value="titleSort:asc">Title</option><option value="addedAt:desc">Date added</option><option value="year:desc">Year, newest</option><option value="year:asc">Year, oldest</option><option value="rating:desc">Rating</option><option value="lastViewedAt:desc">Recently watched</option></select><select id="vgen" aria-label="Genre"><option value="">All genres</option></select><select id="vdec" aria-label="Decade"><option value="">Any decade</option></select><select id="vcr" aria-label="Rating"><option value="">Any rating</option></select><select id="vwatch" aria-label="Watched"><option value="">Watched or not</option><option value="1">Unwatched</option><option value="0">Watched</option></select></div><div id="vgrid" class="vgrid"><p class="empty">Loading…</p></div><div id="vmore"></div></div>`;
|
<div class="bar vfilt"><select id="vsort" aria-label="Sort"><option value="titleSort:asc">Title</option><option value="addedAt:desc">Date added</option><option value="year:desc">Year, newest</option><option value="year:asc">Year, oldest</option><option value="rating:desc">Rating</option><option value="lastViewedAt:desc">Recently watched</option></select><select id="vgen" aria-label="Genre"><option value="">All genres</option></select><select id="vdec" aria-label="Decade"><option value="">Any decade</option></select><select id="vcr" aria-label="Rating"><option value="">Any rating</option></select><select id="vwatch" aria-label="Watched"><option value="">Watched or not</option><option value="1">Unwatched</option><option value="0">Watched</option></select></div><div id="vgrid" class="vgrid"><p class="empty">Loading…</p></div><div id="vmore"></div></div>`;
|
||||||
plexHomeBadge(st); // "Watching as …" with the switch, when the Plex Home has more than one person
|
plexHomeBadge(st); // "Watching as …" with the switch, when the Plex Home has more than one person
|
||||||
// Continue watching, Recently added, Unwatched and the library load side by side. The rows are kept on this device (see vcGet) so they draw at once.
|
// Continue watching, Recently added, Unwatched and the library load side by side. The rows are kept on this device (see vcGet) so they draw at once.
|
||||||
@@ -97,12 +112,20 @@ async function videoHome(st, libKey) {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
const cd = vcGet('deck'); if (cd) drawDeck(cd); loadDeck();
|
const cd = vcGet('deck'); if (cd) drawDeck(cd); loadDeck();
|
||||||
const type = cur.type === 'movie' ? 1 : 2, size = 120;
|
const type = cur.type === 'movie' ? 1 : 2, size = 120;
|
||||||
|
// Mixes: a handful of ways to pick something to watch, from what you have not seen yet
|
||||||
|
{ const MIX = [['shuffle', 'Surprise me', 'sort=random', x => true], ['family', 'Family night', 'sort=random&contentRating=' + vdEnc('G,PG,TV-Y,TV-G,TV-PG'), x => true], ...(type === 1 ? [['short', 'Under 95 minutes', 'sort=random', x => x.duration && x.duration <= 95 * 60000]] : []), ['classic', 'Classics', 'sort=random', x => x.year && x.year <= 1979]];
|
||||||
|
$('#vmix').innerHTML = MIX.map(m => `<a href="#" data-mix="${m[0]}">${m[1]}</a>`).join('');
|
||||||
|
$('#vmix').querySelectorAll('[data-mix]').forEach(a => a.onclick = async e => { e.preventDefault(); const m = MIX.find(x => x[0] === a.dataset.mix), el = openSheet(m[1], 'Unwatched, picked at random. Tap the same mix again for a new set.', '<p class="empty">Looking…</p>');
|
||||||
|
try { const r = await px.get(`/library/sections/${cur.key}/all?type=${type}&unwatched=1&${m[2]}&X-Plex-Container-Start=0&X-Plex-Container-Size=${m[0] === 'shuffle' || m[0] === 'family' ? 24 : 200}`), items = (r.Metadata || []).filter(m[3]).slice(0, 24);
|
||||||
|
el.querySelector('.empty').outerHTML = items.length ? `<div class="vgrid">${items.map(x => vdCard(x, false)).join('')}</div>` : '<p class="empty">Nothing matches. Try another mix.</p>'; el.querySelectorAll('.vcard').forEach(c => c.addEventListener('click', () => closeSheet()));
|
||||||
|
} catch (er) { el.querySelector('.empty').textContent = er.message; } }); }
|
||||||
for (const [id, key, title, q] of [['#vrecent', 'recent:' + cur.key, 'Recently added', 'sort=addedAt:desc'], ['#vunw', 'unw:' + cur.key, 'Unwatched', 'unwatched=1&sort=addedAt:desc']]) {
|
for (const [id, key, title, q] of [['#vrecent', 'recent:' + cur.key, 'Recently added', 'sort=addedAt:desc'], ['#vunw', 'unw:' + cur.key, 'Unwatched', 'unwatched=1&sort=addedAt:desc']]) {
|
||||||
const c = vcGet(key); if (c) { const el = $(id); if (el) el.innerHTML = `<h2>${title}</h2><div class="vrow">${c.map(m => vdCard(m, false)).join('')}</div>`; }
|
const c = vcGet(key); if (c) { const el = $(id); if (el) el.innerHTML = `<h2>${title}</h2><div class="vrow">${c.map(m => vdCard(m, false)).join('')}</div>`; }
|
||||||
px.get(`/library/sections/${cur.key}/all?type=${type}&${q}&X-Plex-Container-Start=0&X-Plex-Container-Size=14`).then(mc => {
|
px.get(`/library/sections/${cur.key}/all?type=${type}&${q}&X-Plex-Container-Start=0&X-Plex-Container-Size=14`).then(mc => {
|
||||||
const items = mc.Metadata || []; vcPut(key, items); const el = $(id); if (el) el.innerHTML = items.length ? `<h2>${title}</h2><div class="vrow">${items.map(m => vdCard(m, false)).join('')}</div>` : '';
|
const items = mc.Metadata || []; vcPut(key, items); const el = $(id); if (el) el.innerHTML = items.length ? `<h2>${title}</h2><div class="vrow">${items.map(m => vdCard(m, false)).join('')}</div>` : '';
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
vdFamilyRows();
|
||||||
wlItems().then(items => { const el = $('#vwlr'); if (el) el.innerHTML = items.length ? `<h2>Watch later</h2><div class="vrow">${items.slice(0, 20).map(m => vdCard(m, false)).join('')}</div>` : ''; }).catch(() => {});
|
wlItems().then(items => { const el = $('#vwlr'); if (el) el.innerHTML = items.length ? `<h2>Watch later</h2><div class="vrow">${items.slice(0, 20).map(m => vdCard(m, false)).join('')}</div>` : ''; }).catch(() => {});
|
||||||
px.get(`/library/sections/${cur.key}/collections`).then(mc => { const el = $('#vcolr'), items = mc.Metadata || []; if (el) el.innerHTML = items.length ? `<h2>Collections</h2><div class="vrow">${items.slice(0, 30).map(m => vdCard(m, false)).join('')}</div>` : ''; }).catch(() => {});
|
px.get(`/library/sections/${cur.key}/collections`).then(mc => { const el = $('#vcolr'), items = mc.Metadata || []; if (el) el.innerHTML = items.length ? `<h2>Collections</h2><div class="vrow">${items.slice(0, 30).map(m => vdCard(m, false)).join('')}</div>` : ''; }).catch(() => {});
|
||||||
// sort and filter the library below
|
// sort and filter the library below
|
||||||
@@ -189,6 +212,15 @@ async function videoItem(id) {
|
|||||||
actionSheet('Trailers', m.title, [...list.slice(0, 8).map(x => ({ label: x.title || 'Trailer', icon: 'play', run: () => go(x) })), { label: 'Search YouTube instead', icon: 'search', run: yt }]);
|
actionSheet('Trailers', m.title, [...list.slice(0, 8).map(x => ({ label: x.title || 'Trailer', icon: 'play', run: () => go(x) })), { label: 'Search YouTube instead', icon: 'search', run: yt }]);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
if (m.type === 'movie' || m.type === 'show') { // Favorite, and Movie night (the family's shared list to vote on)
|
||||||
|
const mc = { id: String(id), title: m.title, year: m.year || 0, type: m.type, thumb: m.thumb || '' };
|
||||||
|
Promise.all([vfApi('/favs'), vfApi('/night')]).then(([f, n]) => {
|
||||||
|
const box = $('#vact'); if (!box || $('#vfav')) return; const fav = f.items.some(x => x.id === mc.id), night = n.items.some(x => x.id === mc.id);
|
||||||
|
box.insertAdjacentHTML('beforeend', `<button class="btn ghost" id="vfav">${fav ? '♥ Favorite' : '♡ Favorite'}</button><button class="btn ghost" id="vmn">${night ? '✓ On movie night' : '+ Movie night'}</button>`);
|
||||||
|
$('#vfav').onclick = async () => { try { await vfApi('/fav', vfOpt({ ...mc, on: !fav })); toast(fav ? 'Removed from Favorites' : 'Added to Favorites'); route(); } catch (e) { toast(e.message); } };
|
||||||
|
$('#vmn').onclick = async () => { try { if (night) { location.hash = '#/video'; return; } await vfApi('/night/add', vfOpt(mc)); toast('Added to movie night. Everyone can vote on it from Movies & TV.'); route(); } catch (e) { toast(e.message); } };
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
if (m.type === 'movie' || m.type === 'show') wlItems().then(list => { // Watch later
|
if (m.type === 'movie' || m.type === 'show') wlItems().then(list => { // Watch later
|
||||||
const on = list.some(x => x.ratingKey === String(id)), box = $('#vact'); if (!box || $('#vwl')) return;
|
const on = list.some(x => x.ratingKey === String(id)), box = $('#vact'); if (!box || $('#vwl')) return;
|
||||||
box.insertAdjacentHTML('beforeend', `<button class="btn ghost" id="vwl">${on ? '✓ On Watch later' : '+ Watch later'}</button>`);
|
box.insertAdjacentHTML('beforeend', `<button class="btn ghost" id="vwl">${on ? '✓ On Watch later' : '+ Watch later'}</button>`);
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const contacts = require('./contacts'); // each person's address book (mail su
|
|||||||
const activity = require('./activity'), alertParents = activity.alertParents;
|
const activity = require('./activity'), alertParents = activity.alertParents;
|
||||||
const kidlimits = require('./kidlimits'); // child accounts: a daily time limit and bedtime, and asking a parent for more time
|
const kidlimits = require('./kidlimits'); // child accounts: a daily time limit and bedtime, and asking a parent for more time
|
||||||
const screentime = require('./screentime'); // fewer web addresses for iPad Screen Time: a report-only policy, the list of what devices reached, and a picture pass-through
|
const screentime = require('./screentime'); // fewer web addresses for iPad Screen Time: a report-only policy, the list of what devices reached, and a picture pass-through
|
||||||
|
const moviefam = require('./moviefam'); // Movies & TV: favorites and the family's movie night list
|
||||||
const chores = require('./chores'); // chores for the family, with points
|
const chores = require('./chores'); // chores for the family, with points
|
||||||
const notes = require('./notes'); // shared and private notes and checklists
|
const notes = require('./notes'); // shared and private notes and checklists
|
||||||
const chat = require('./chat'); // Family Chat: a messenger for the people with an account here
|
const chat = require('./chat'); // Family Chat: a messenger for the people with an account here
|
||||||
@@ -584,6 +585,7 @@ const ACCESS = [
|
|||||||
[/^\/api\/bible\//, ['bible']],
|
[/^\/api\/bible\//, ['bible']],
|
||||||
[/^\/api\/read\//, ['read']],
|
[/^\/api\/read\//, ['read']],
|
||||||
[/^\/api\/radio\//, ['radio']],
|
[/^\/api\/radio\//, ['radio']],
|
||||||
|
[/^\/api\/moviefam(\/|$)/, ['video']],
|
||||||
[/^\/api\/chores(\/|$)/, ['chores']],
|
[/^\/api\/chores(\/|$)/, ['chores']],
|
||||||
[/^\/api\/notes(\/|$)/, ['notes']],
|
[/^\/api\/notes(\/|$)/, ['notes']],
|
||||||
[/^\/api\/chat(\/|$)/, ['chat']],
|
[/^\/api\/chat(\/|$)/, ['chat']],
|
||||||
@@ -707,6 +709,7 @@ async function handleRequest(req, res) {
|
|||||||
if (pathname === '/api/kid/requests' || pathname.startsWith('/api/kid/requests/')) return require('./requests').handle(req, res, user);
|
if (pathname === '/api/kid/requests' || pathname.startsWith('/api/kid/requests/')) return require('./requests').handle(req, res, user);
|
||||||
if (pathname.startsWith('/api/kid/')) return kidlimits.handle(req, res, user, { askParents: chat.askParents });
|
if (pathname.startsWith('/api/kid/')) return kidlimits.handle(req, res, user, { askParents: chat.askParents });
|
||||||
if (pathname === '/api/csp-report' || pathname === '/api/img' || pathname === '/api/screentime') return screentime.handle(req, res, user);
|
if (pathname === '/api/csp-report' || pathname === '/api/img' || pathname === '/api/screentime') return screentime.handle(req, res, user);
|
||||||
|
if (pathname === '/api/moviefam' || pathname.startsWith('/api/moviefam/')) return moviefam.handle(req, res);
|
||||||
if (pathname === '/api/chores' || pathname.startsWith('/api/chores/')) return chores.handle(req, res);
|
if (pathname === '/api/chores' || pathname.startsWith('/api/chores/')) return chores.handle(req, res);
|
||||||
if (pathname === '/api/notes' || pathname.startsWith('/api/notes/')) return notes.handle(req, res);
|
if (pathname === '/api/notes' || pathname.startsWith('/api/notes/')) return notes.handle(req, res);
|
||||||
if (pathname.startsWith('/api/chat/')) return chat.handle(req, res);
|
if (pathname.startsWith('/api/chat/')) return chat.handle(req, res);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Movies & TV for the family: favorites are per person; Movie night is one shared list with votes.
|
||||||
|
const assert = require('assert'), fs = require('fs'), os = require('os'), path = require('path'), http = require('http');
|
||||||
|
process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'nehub-mf-'));
|
||||||
|
const users = require('../users'), mf = require('../moviefam');
|
||||||
|
const ann = users.create({ name: 'Ann', role: 'admin', handle: 'YQ' }), bo = users.create({ name: 'Bo', role: 'user', tabs: ['video'], handle: 'ZP' });
|
||||||
|
const hub = http.createServer((req, res) => users.runAs(req.headers['x-user'] === 'bo' ? bo : ann, () => mf.handle(req, res))).listen(0, async () => {
|
||||||
|
const base = 'http://127.0.0.1:' + hub.address().port + '/api/moviefam', j = r => r.json(), post = (p, o, who) => fetch(base + p, { method: 'POST', body: JSON.stringify(o), headers: who ? { 'x-user': who } : {} }), get = (p, who) => fetch(base + p, { headers: who ? { 'x-user': who } : {} });
|
||||||
|
const up = { id: '101', title: 'Up', year: 2009, type: 'movie', thumb: '/library/metadata/101/thumb/17' };
|
||||||
|
try {
|
||||||
|
// favorites
|
||||||
|
await post('/fav', { ...up, on: true }); assert.deepEqual((await j(await get('/favs'))).items.map(x => x.title), ['Up']); assert.equal((await j(await get('/favs', 'bo'))).items.length, 0, 'favorites are personal');
|
||||||
|
await post('/fav', { ...up, on: false }); assert.equal((await j(await get('/favs'))).items.length, 0);
|
||||||
|
assert.equal((await post('/fav', { id: '../x', title: 'x', on: true })).status, 400, 'a bad id is refused');
|
||||||
|
// a poster path that is not ours is dropped
|
||||||
|
await post('/fav', { ...up, thumb: 'https://evil.example/x.jpg', on: true }); assert.equal((await j(await get('/favs'))).items[0].thumb, '');
|
||||||
|
// movie night
|
||||||
|
let r = await j(await post('/night/add', up)); assert.equal(r.items[0].votes, 1, 'the person who adds it votes for it'); assert.equal((await post('/night/add', up)).status, 400, 'once only');
|
||||||
|
await post('/night/add', { id: '102', title: 'Ratatouille', year: 2007 }, 'bo'); r = await j(await post('/night/vote', { id: '102' }));
|
||||||
|
assert.equal(r.items[0].title, 'Ratatouille', 'the one with more votes comes first'); assert.deepEqual(r.items[0].voters.sort(), ['Ann', 'Bo']);
|
||||||
|
r = await j(await post('/night/vote', { id: '102' })); assert.equal(r.items.find(x => x.id === '102').votes, 1, 'voting again takes the vote back');
|
||||||
|
assert.equal((await post('/night/remove', { id: '101' }, 'bo')).status, 403, 'only the adder or an admin removes'); r = await j(await post('/night/remove', { id: '102' })); assert.equal(r.items.length, 1, 'an admin can remove anything');
|
||||||
|
assert.equal((await post('/night/clear', {}, 'bo')).status, 403); assert.equal((await j(await post('/night/clear', {}))).items.length, 0);
|
||||||
|
console.log('moviefam test ok');
|
||||||
|
} catch (e) { console.error(e); process.exitCode = 1; }
|
||||||
|
hub.close();
|
||||||
|
});
|
||||||
Reference in new issue
Block a user