diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index e22b797..8219d61 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -32,6 +32,7 @@ jobs: - 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/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/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 diff --git a/moviefam.js b/moviefam.js new file mode 100644 index 0000000..d78c8e3 --- /dev/null +++ b/moviefam.js @@ -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//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 }; diff --git a/public/style.css b/public/style.css index 3fc6cb6..9731d0b 100644 --- a/public/style.css +++ b/public/style.css @@ -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; } .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; } +.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; } diff --git a/public/sw.js b/public/sw.js index 7cee342..8f16b5e 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // 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 SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; diff --git a/public/video.js b/public/video.js index 705f566..f2a67e0 100644 --- a/public/video.js +++ b/public/video.js @@ -73,6 +73,21 @@ async function vdRemove(m, quiet) { 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 {} } +// ---- 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 ? `

Movie night

${items.map((x, i) => ``).join('')}
` : ''; + 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 ? `

Favorites

${r.items.slice(0, 30).map(x => vdCard(vfCard(x), false)).join('')}
` : ''; }).catch(() => {}); +} async function videoHome(st, libKey) { const v = $('#view'); 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); v.innerHTML = `

Movies & TV

${secs.length > 1 ? `
${secs.map(s => `${esc(s.title)}`).join('')}
` : ''}
-

${esc(cur.title)}

+

${esc(cur.title)}

Loading…

`; 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. @@ -97,12 +112,20 @@ async function videoHome(st, libKey) { }).catch(() => {}); const cd = vcGet('deck'); if (cd) drawDeck(cd); loadDeck(); 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 => `${m[1]}`).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.', '

Looking…

'); + 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 ? `
${items.map(x => vdCard(x, false)).join('')}
` : '

Nothing matches. Try another mix.

'; 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']]) { const c = vcGet(key); if (c) { const el = $(id); if (el) el.innerHTML = `

${title}

${c.map(m => vdCard(m, false)).join('')}
`; } 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 ? `

${title}

${items.map(m => vdCard(m, false)).join('')}
` : ''; }).catch(() => {}); } + vdFamilyRows(); wlItems().then(items => { const el = $('#vwlr'); if (el) el.innerHTML = items.length ? `

Watch later

${items.slice(0, 20).map(m => vdCard(m, false)).join('')}
` : ''; }).catch(() => {}); px.get(`/library/sections/${cur.key}/collections`).then(mc => { const el = $('#vcolr'), items = mc.Metadata || []; if (el) el.innerHTML = items.length ? `

Collections

${items.slice(0, 30).map(m => vdCard(m, false)).join('')}
` : ''; }).catch(() => {}); // 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 }]); }; }); + 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', ``); + $('#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 const on = list.some(x => x.ratingKey === String(id)), box = $('#vact'); if (!box || $('#vwl')) return; box.insertAdjacentHTML('beforeend', ``); diff --git a/server.js b/server.js index e8ad9fe..6cf1fd7 100644 --- a/server.js +++ b/server.js @@ -48,6 +48,7 @@ const contacts = require('./contacts'); // each person's address book (mail su 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 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 notes = require('./notes'); // shared and private notes and checklists 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\/read\//, ['read']], [/^\/api\/radio\//, ['radio']], + [/^\/api\/moviefam(\/|$)/, ['video']], [/^\/api\/chores(\/|$)/, ['chores']], [/^\/api\/notes(\/|$)/, ['notes']], [/^\/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.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/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/notes' || pathname.startsWith('/api/notes/')) return notes.handle(req, res); if (pathname.startsWith('/api/chat/')) return chat.handle(req, res); diff --git a/tools/moviefam-test.js b/tools/moviefam-test.js new file mode 100644 index 0000000..a1e9482 --- /dev/null +++ b/tools/moviefam-test.js @@ -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(); +});