diff --git a/frontend/css/style.css b/frontend/css/style.css index 45f7bde..74c20ec 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -160,6 +160,13 @@ body.theme-light .top-bar { color: var(--text-secondary); } +/* A merge reads its members' own words, so the box has nothing to say for it. + Dimmed rather than hidden: the placeholder is where it says why. */ +.search-container input.is-inert { + opacity: 0.6; + cursor: default; +} + .search-clear-btn { position: absolute; top: 50%; @@ -3337,6 +3344,22 @@ body.theme-light .saved-badge { box-shadow: 0 0 0 2px rgba(246, 241, 231, 0.95); .saved-row.is-unreachable .saved-count { border-style: dashed; } +/* Picking rows to merge. The count becomes a tick box: empty until the row is + chosen, so a glance down the list says which ones are going in. */ +.saved-count.saved-pick { + border-color: var(--border-hover); + background: transparent; + color: var(--accent); + font-size: 15px; +} + +.saved-row.is-picked .saved-count.saved-pick { + background: rgba(201, 165, 103, 0.16); + border-color: var(--accent); +} + +.saved-row.is-picked { background: var(--bg-tertiary); } + .saved-row-text { min-width: 0; display: flex; diff --git a/frontend/index.html b/frontend/index.html index 91011c8..64c5184 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -259,6 +259,7 @@

+ @@ -270,6 +271,7 @@ then press the bookmark in the search bar to keep the whole thing.
+
diff --git a/frontend/js/savedSearches.js b/frontend/js/savedSearches.js index 490be2e..431f346 100644 --- a/frontend/js/savedSearches.js +++ b/frontend/js/savedSearches.js @@ -31,6 +31,8 @@ App.savedSearches = App.savedSearches || {}; const PROBE_PER_PAGE = 12; // A group asks each of its channels, so each one asks for less. const GROUP_PROBE_PER_PAGE = 4; + // So does a merge, of each of its searches. + const MERGED_PROBE_PER_PAGE = 6; // Above this the badge says "9+" rather than a number. const NEW_CAP = 9; const DAY_MS = 24 * 60 * 60 * 1000; @@ -48,9 +50,44 @@ App.savedSearches = App.savedSearches || {}; // ---- store ---------------------------------------------------------- + // One search's worth of "where to look": a source, a channel on it, that + // channel's filters and the words. A plain saved search is one of these + // held flat on the entry; a merged one is a list of them. + const cleanMember = function(member, i) { + if (!member || typeof member !== 'object') return null; + if (!member.server || !member.channelId) return null; + return { + memberId: String(member.memberId || `m${i}`), + server: String(member.server), + channelId: String(member.channelId), + channelName: typeof member.channelName === 'string' ? member.channelName : '', + query: typeof member.query === 'string' ? member.query : '', + options: member.options && typeof member.options === 'object' ? member.options : {}, + optionLabels: Array.isArray(member.optionLabels) ? member.optionLabels.map(String) : [] + }; + }; + const clean = function(entry) { if (!entry || typeof entry !== 'object') return null; - if (!entry.id || !entry.server || !entry.channelId) return null; + if (!entry.id) return null; + // A merge keeps its members and no flat source of its own: there is no + // single server or channel that would be the truth for it. + if (Array.isArray(entry.members) && entry.members.length) { + const members = entry.members.map(cleanMember).filter(Boolean); + if (!members.length) return null; + return { + id: String(entry.id), + name: typeof entry.name === 'string' && entry.name.trim() ? entry.name : 'Merged search', + members: members, + createdAt: Number(entry.createdAt) || 0, + seenIds: Array.isArray(entry.seenIds) ? entry.seenIds.map(String).slice(0, SEEN_KEEP) : [], + lastViewedAt: Number(entry.lastViewedAt) || 0, + lastCheckedAt: Number(entry.lastCheckedAt) || 0, + newCount: Math.max(0, Number(entry.newCount) || 0), + unreachable: !!entry.unreachable + }; + } + if (!entry.server || !entry.channelId) return null; return { id: String(entry.id), name: typeof entry.name === 'string' && entry.name.trim() ? entry.name : 'Saved search', @@ -90,6 +127,27 @@ App.savedSearches = App.savedSearches || {}; return App.savedSearches.getAll().find((entry) => entry.id === id) || null; }; + App.savedSearches.isMerged = function(entry) { + return !!(entry && Array.isArray(entry.members) && entry.members.length); + }; + + // Every entry read as a list of places to look, so the code that asks a + // source something does not have to care which kind it was handed. A plain + // search is a merge of one. + App.savedSearches.membersOf = function(entry) { + if (!entry) return []; + if (App.savedSearches.isMerged(entry)) return entry.members; + return [{ + memberId: 'm0', + server: entry.server, + channelId: entry.channelId, + channelName: entry.channelName, + query: entry.query || '', + options: entry.options || {}, + optionLabels: entry.optionLabels || [] + }]; + }; + // Every write goes through here: the list is read again first, so a check // that lands while the reader is renaming or deleting can't write back a // copy of the list from before they did it. @@ -126,6 +184,25 @@ App.savedSearches = App.savedSearches || {}; App.savedSearches.current = function() { const session = App.storage.getSession(); if (!session || !session.channel) return null; + // A merge is described by the searches it is made of, not by the + // session's server and channel -- there is no one of either. Its + // members are what the bookmark has to recognise it by, whichever of + // them happen to be toggled on just now. + if (session.channel.isMerged) { + return { + name: session.channel.name, + savedSearchId: session.channel.savedSearchId, + members: (session.channel.members || []).map((member) => ({ + memberId: member.memberId, + server: member.server, + channelId: member.channelId, + channelName: member.channelName, + query: member.query || '', + options: member.options || {}, + optionLabels: member.optionLabels || [] + })) + }; + } const box = document.getElementById('search-input'); return { server: session.server, @@ -137,19 +214,29 @@ App.savedSearches = App.savedSearches || {}; }; }; - // Two searches are the same search when they would send the same request. - App.savedSearches.signature = function(desc) { - if (!desc) return ''; - const options = desc.options || {}; + // One member's worth of identity: the request it would send. + const memberSignature = function(member) { + const options = member.options || {}; const parts = Object.keys(options).sort().map((key) => { const value = options[key]; const flat = Array.isArray(value) ? value.slice().sort().join(',') : String(value); return `${key}=${flat}`; }); - return [desc.server, desc.channelId, (desc.query || '').toLowerCase().trim()] + return [member.server, member.channelId, (member.query || '').toLowerCase().trim()] .concat(parts).join('||'); }; + // Two searches are the same search when they would send the same request. + // A merge is the same merge when it is made of the same searches -- in any + // order, since which one was ticked first is not part of what it is. + App.savedSearches.signature = function(desc) { + if (!desc) return ''; + if (Array.isArray(desc.members) && desc.members.length) { + return `merged::${desc.members.map(memberSignature).sort().join('&&')}`; + } + return memberSignature(desc); + }; + // The saved search the app is currently showing, if any -- what makes the // bookmark in the search bar light up. App.savedSearches.findMatching = function() { @@ -162,10 +249,19 @@ App.savedSearches = App.savedSearches || {}; // What to call a search nobody has named: the words searched for, or the // channel when there were none. + // What to call a merge nobody has named: the searches in it, which is the + // only thing it is. Two names fit on a row; past that it counts. + const mergedAutoName = function(members) { + const names = members.map((member) => member.channelName || member.channelId); + if (names.length <= 2) return names.join(' + '); + return `${names[0]} + ${names.length - 1} more`; + }; + const autoName = function(desc) { + if (Array.isArray(desc.members) && desc.members.length) return mergedAutoName(desc.members); if (desc.query) return desc.query; const channel = desc.channelName || desc.channelId; - const narrowed = desc.optionLabels.slice(0, 2).join(' · '); + const narrowed = (desc.optionLabels || []).slice(0, 2).join(' · '); return narrowed ? `${channel} · ${narrowed}` : channel; }; @@ -213,6 +309,61 @@ App.savedSearches = App.savedSearches || {}; return entry; }; + // Merges several saved searches into one listing whose results come from + // all of them at once. The sources need nothing in common: that is the + // point, and it is why this cannot be a channel group (a group lives on + // one server and shares one query). + // + // The searches that went in are replaced by the merge. Merging a merge + // flattens it rather than nesting, so a merge is always one level deep and + // "the searches in it" means the same thing however it was assembled. + App.savedSearches.merge = function(ids, name) { + const wanted = Array.isArray(ids) ? ids : []; + const entries = App.savedSearches.getAll(); + const chosen = wanted + .map((id) => entries.find((entry) => entry.id === id)) + .filter(Boolean); + if (chosen.length < 2) return null; + + // Deduped by what each member would ask for, so merging two searches + // that happen to be the same search does not ask twice. + const members = []; + const seenSigs = new Set(); + chosen.forEach((entry) => { + App.savedSearches.membersOf(entry).forEach((member) => { + const sig = memberSignature(member); + if (seenSigs.has(sig)) return; + seenSigs.add(sig); + members.push(Object.assign({}, member, { memberId: `m${members.length}` })); + }); + }); + if (members.length < 2) return null; + + const entry = clean({ + id: `ss_${now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, + name: (name || '').trim() || mergedAutoName(members), + members: members, + createdAt: now(), + // No mark yet, so the first check sets one instead of counting a + // whole first page as news (see checkOne). + seenIds: [], + lastViewedAt: 0, + lastCheckedAt: 0, + newCount: 0 + }); + if (!entry) return null; + + const chosenIds = new Set(chosen.map((item) => item.id)); + const kept = entries.filter((item) => !chosenIds.has(item.id)); + // Where the first of them was, rather than at the top: the reader put + // the list in the order they wanted. + const at = entries.findIndex((item) => chosenIds.has(item.id)); + kept.splice(at < 0 ? 0 : at, 0, entry); + setAll(kept); + changed(); + return entry; + }; + // Opened, so whatever it shows now counts as seen and the badge goes out. App.savedSearches.markViewed = function(id) { const seen = visibleIds(); @@ -256,20 +407,34 @@ App.savedSearches = App.savedSearches || {}; // The ids on the first page of a saved search, as the server orders them. // Not the grid's request: the grid's paging state belongs to what the // reader is looking at, and a check must not disturb it. - const probeIds = async function(entry, signal) { + // One from each list in turn, so "the newest" means here what it means in + // the grid: the sources taken alternately rather than one exhausted first. + const interleave = function(lists) { + const out = []; + const longest = lists.reduce((max, ids) => Math.max(max, ids.length), 0); + for (let i = 0; i < longest; i++) { + lists.forEach((ids) => { if (ids[i]) out.push(ids[i]); }); + } + return out; + }; + + // The first page of one member -- one source, one channel on it, its own + // filters and its own words. A member may itself name a channel group, in + // which case its own channels are interleaved first. + const probeMember = async function(member, perPage, signal) { const serverEntry = App.storage.getServerEntries() - .find((candidate) => candidate.url === entry.server); + .find((candidate) => candidate.url === member.server); const serverData = serverEntry ? serverEntry.data : null; - const channel = serverData ? App.session.resolveChannelById(serverData, entry.channelId) : null; + const channel = serverData ? App.session.resolveChannelById(serverData, member.channelId) : null; if (!channel) throw new Error('channel is no longer listed'); - const ask = async function(channelId, perPage, extra) { + const ask = async function(channelId, size, extra) { const body = Object.assign({ channel: channelId, - query: entry.query || '', + query: member.query || '', page: 1, - perPage: perPage, - server: entry.server + perPage: size, + server: member.server }, extra || {}); const response = await fetch('/api/videos', { method: 'POST', @@ -284,26 +449,36 @@ App.savedSearches = App.savedSearches || {}; }; if (channel.isGroup) { - const selected = entry.options ? entry.options[App.session.GROUP_CHANNELS_OPTION_ID] : null; + const selected = member.options ? member.options[App.session.GROUP_CHANNELS_OPTION_ID] : null; const enabled = (Array.isArray(selected) && selected.length ? selected : channel.channelIds) .filter((id) => channel.channelIds.includes(id)); if (!enabled.length) return []; - const perChannel = await Promise.all(enabled.map((id) => ask(id, GROUP_PROBE_PER_PAGE))); - // Interleaved, so "the newest" means the same thing it does in the - // grid: one from each channel in turn. - const merged = []; - const longest = perChannel.reduce((max, ids) => Math.max(max, ids.length), 0); - for (let i = 0; i < longest; i++) { - perChannel.forEach((ids) => { if (ids[i]) merged.push(ids[i]); }); - } - return merged; + return interleave(await Promise.all( + enabled.map((id) => ask(id, GROUP_PROBE_PER_PAGE)))); } const extra = {}; - Object.entries(entry.options || {}).forEach(([key, value]) => { + Object.entries(member.options || {}).forEach(([key, value]) => { extra[key] = Array.isArray(value) ? value.join(', ') : value; }); - return ask(entry.channelId, PROBE_PER_PAGE, extra); + return ask(member.channelId, perPage, extra); + }; + + const probeIds = async function(entry, signal) { + const members = App.savedSearches.membersOf(entry); + if (members.length === 1) return probeMember(members[0], PROBE_PER_PAGE, signal); + // A merge asks every member and interleaves them, so the badge counts + // what the grid would actually have put in front of the reader. One + // member whose source has gone is not the end of the merge: the rest + // still answer, and only a merge where nothing answers is a failure. + const settled = await Promise.allSettled( + members.map((member) => probeMember(member, MERGED_PROBE_PER_PAGE, signal))); + const ok = settled.filter((r) => r.status === 'fulfilled').map((r) => r.value); + if (!ok.length) { + const first = settled.find((r) => r.status === 'rejected'); + throw (first && first.reason) || new Error('no member could be reached'); + } + return interleave(ok); }; // How many of the videos the source lists now are ones the reader has not @@ -441,6 +616,53 @@ App.savedSearches = App.savedSearches || {}; // ---- opening one ----------------------------------------------------- + // Opens a merge: one listing fed by all of its searches at once. + // + // The search box is left empty and stays out of it. Every member carries + // its own words -- that is what makes merging two different searches worth + // doing -- so there is nothing for one box to say for all of them. + // + // Not saved as a channel preference either: a preference is stored per + // server against a channel that server lists, and a merge is neither. + // Reload restores it from the session instead (see ensureSessionFromCache). + const applyMerged = async function(entry) { + const channel = App.session.buildMergedChannel(entry); + if (!channel) return false; + + const configured = new Set(App.storage.getServerEntries() + .filter((candidate) => candidate.data) + .map((candidate) => candidate.url)); + const reachable = channel.members.filter((member) => configured.has(member.server)); + if (!reachable.length) { + App.ui.showError(`${entry.name}: none of its sources are set up on this device any more.`); + patch(entry.id, { unreachable: true }); + changed(); + return false; + } + if (reachable.length < channel.members.length) { + const missing = channel.members + .filter((member) => !configured.has(member.server)) + .map((member) => member.server); + App.ui.showError(`${entry.name}: showing it without ${missing.join(', ')} — not set up on this device.`); + } + + App.storage.setSession({ + server: reachable[0].server, + channel: channel, + options: App.session.buildDefaultOptions(channel) + }); + + App.videos.setSearchBox(''); + if (App.favoritesView && App.favoritesView.isActive()) { + App.favoritesView.close({ silent: true }); + } + App.ui.renderMenu(); + App.videos.resetGrid(); + await App.videos.loadVideos(); + App.savedSearches.markViewed(entry.id); + return true; + }; + // Puts the whole saved search back: source, channel, filters, query. The // grid is reloaded here rather than left to the caller, because "applied" // means the reader is looking at it -- which is also when it stops being @@ -449,6 +671,8 @@ App.savedSearches = App.savedSearches || {}; const entry = App.savedSearches.get(id); if (!entry) return false; + if (App.savedSearches.isMerged(entry)) return applyMerged(entry); + const serverEntry = App.storage.getServerEntries() .find((candidate) => candidate.url === entry.server); if (!serverEntry || !serverEntry.data) { diff --git a/frontend/js/state.js b/frontend/js/state.js index 7865f64..69a3646 100644 --- a/frontend/js/state.js +++ b/frontend/js/state.js @@ -15,7 +15,8 @@ App.state = { feedMuted: true, feedActiveIndex: -1, feedActiveVideoId: null, - groupCursors: null + groupCursors: null, + mergedCursors: null }; // Local storage keys used across modules. diff --git a/frontend/js/storage.js b/frontend/js/storage.js index 2de57ae..ba4cab7 100644 --- a/frontend/js/storage.js +++ b/frontend/js/storage.js @@ -164,6 +164,7 @@ App.session = App.session || {}; // channels as a toggleable multi-select, so the user can browse "All " // while disabling individual channels. App.session.GROUP_CHANNELS_OPTION_ID = '__groupChannels'; + App.session.MERGED_MEMBERS_OPTION_ID = '__mergedMembers'; // Options/session helpers that power channel selection and filters. App.session.serializeOptions = function(options) { @@ -233,6 +234,45 @@ App.session = App.session || {}; }; }; + // Builds a pseudo-channel representing a merged saved search: several + // saved searches, each with its own source, channel, filters and words, + // browsed as one listing. A channel group does something similar but + // cannot express this -- every channel in a group belongs to the same + // server and shares one query, where a merge is held together by nothing + // except the reader having put it together (see fetchMergedBatch). + // + // The members are carried on the channel itself rather than looked up from + // a server's status, because no single server knows about them. That is + // also what lets a merged session survive a reload. + App.session.buildMergedChannel = function(entry) { + if (!entry || !Array.isArray(entry.members) || entry.members.length === 0) return null; + const members = entry.members.map((member, i) => Object.assign({}, member, { + // Stable within the merge, so a member toggle keeps meaning the + // same member even if two of them name the same channel. + memberId: member.memberId || `m${i}` + })); + const memberOptions = members.map((member) => ({ + id: member.memberId, + title: member.channelName || member.channelId + })); + return { + id: `merged:${entry.id}`, + name: entry.name || 'Merged search', + isMerged: true, + savedSearchId: entry.id, + members: members, + // Same shape as a group's "Channels" filter, so the existing + // filter UI shows it without knowing what a merge is. + options: memberOptions.length > 0 ? [{ + id: App.session.MERGED_MEMBERS_OPTION_ID, + title: 'Searches', + multiSelect: true, + selectAllDefault: true, + options: memberOptions + }] : [] + }; + }; + // Resolves a stored channel id (which may reference a single channel or a // "group:" pseudo-channel) against a server's status payload. App.session.resolveChannelById = function(serverData, channelId) { @@ -350,6 +390,28 @@ App.session = App.session || {}; const serverKeys = (config.servers || []).map((serverObj) => Object.keys(serverObj)[0]); const existingSession = App.storage.getSession(); + // A merged search answers to no single server, so the check below -- + // "does this server still list this channel" -- cannot speak for it. + // It is valid while the saved search it came from is still there and + // at least one member's source is still set up. Rebuilt from that + // saved search rather than trusted as stored, so a rename or an edited + // member is picked up instead of being frozen into the session. + if (existingSession && existingSession.channel && existingSession.channel.isMerged) { + const stored = App.savedSearches && typeof App.savedSearches.get === 'function' + ? App.savedSearches.get(existingSession.channel.savedSearchId) + : null; + const channel = stored ? App.session.buildMergedChannel(stored) : existingSession.channel; + const members = (channel && channel.members) || []; + if (members.some((member) => serverKeys.includes(member.server))) { + App.storage.setSession({ + server: members[0].server, + channel: channel, + options: App.session.hydrateOptions(channel, App.session.serializeOptions(existingSession.options)) + }); + return true; + } + } + // Leave a still-valid session untouched so we don't disturb the user's // current server/channel selection on refresh. if (existingSession && existingSession.channel && serverKeys.includes(existingSession.server)) { diff --git a/frontend/js/ui.js b/frontend/js/ui.js index fec27ac..9f0e757 100644 --- a/frontend/js/ui.js +++ b/frontend/js/ui.js @@ -511,7 +511,25 @@ App.ui = App.ui || {}; // Where the search points, in one line: the channel, what the filters // narrow it to, and the words themselves. + const hostOf = function(url) { + try { return new URL(url).host; } catch (err) { return String(url || ''); } + }; + const metaLine = function(entry) { + // A merge has no channel or words of its own -- what it is, is the + // searches in it, so those are what the row says. Each one names + // where it looks and which source it looks on, because two members + // differing only by source is the whole reason to merge. + if (App.savedSearches.isMerged(entry)) { + const shown = entry.members.slice(0, 3).map((member) => { + const where = member.channelName || member.channelId; + const words = member.query ? ` “${member.query}”` : ''; + return `${where}${words} · ${hostOf(member.server)}`; + }); + const rest = entry.members.length - shown.length; + const tail = rest > 0 ? ` + ${rest} more` : ''; + return `${entry.members.length} searches · ${shown.join(' + ')}${tail}`; + } const parts = [entry.channelName || entry.channelId]; if (entry.optionLabels && entry.optionLabels.length) { parts.push(entry.optionLabels.slice(0, 3).join(' · ')); @@ -614,10 +632,18 @@ App.ui = App.ui || {}; return strip; }; - // Which of the three shapes a row is in. A row only has to be built - // again when it changes shape; otherwise the one on screen is updated - // where it stands (see render). + // Picking rows to merge. A merge is made here rather than from a row's + // own menu because it is about several rows at once -- and because + // "which ones" is the whole question. + let selecting = false; + let picked = new Set(); + + // Which shape a row is in. A row only has to be built again when it + // changes shape; otherwise the one on screen is updated where it + // stands (see render). Being picked is a shape too: the row swaps its + // count for a tick and its tools for nothing. const modeOf = function(entry) { + if (selecting) return picked.has(entry.id) ? 'pick-on' : 'pick-off'; return (pending && pending.id === entry.id) ? pending.mode : 'plain'; }; @@ -662,9 +688,30 @@ App.ui = App.ui || {}; text.appendChild(stamp); main.appendChild(count); main.appendChild(text); - main.addEventListener('click', () => choose(entry.id)); + main.addEventListener('click', () => { + if (selecting) { + if (picked.has(entry.id)) picked.delete(entry.id); + else picked.add(entry.id); + render(); + return; + } + choose(entry.id); + }); row.appendChild(main); + // While picking, a tap means "this one too" rather than "open + // this", so the row says so: a tick where the count was, and no + // rename or remove to catch the thumb on the way past. + if (selecting) { + row.classList.add('is-selecting'); + row.classList.toggle('is-picked', picked.has(entry.id)); + count.classList.remove('is-quiet'); + count.classList.add('saved-pick'); + count.textContent = picked.has(entry.id) ? '✓' : ''; + count.removeAttribute('title'); + return row; + } + if (pending && pending.id === entry.id && pending.mode === 'rename') { const field = renameField(entry, row); name.replaceWith(field); @@ -737,6 +784,8 @@ App.ui = App.ui || {}; const add = el('saved-add-btn'); const check = el('saved-check-btn'); const read = el('saved-read-btn'); + const select = el('saved-select-btn'); + const mergeBtn = el('saved-merge-btn'); if (!list) return; const entries = App.savedSearches.getAll(); @@ -781,13 +830,33 @@ App.ui = App.ui || {}; read.disabled = marking || !App.savedSearches.totalNew(); read.textContent = marking ? 'Marking…' : 'Mark all read'; } + if (select) { + // There is nothing to merge with fewer than two of them. + select.hidden = entries.length < 2; + select.textContent = selecting ? 'Cancel' : 'Merge…'; + } + if (mergeBtn) { + mergeBtn.hidden = !selecting; + mergeBtn.disabled = picked.size < 2; + mergeBtn.textContent = picked.size < 2 + ? 'Pick two or more to merge' + : `Merge ${picked.size} selected`; + } if (add) { + // While picking, the merge is the only action that means + // anything down here. + add.hidden = selecting; const match = App.savedSearches.findMatching(); add.disabled = !!match || !App.savedSearches.current(); add.textContent = match ? `Already saved as “${match.name}”` : 'Save the current search'; } }; + const stopSelecting = function() { + selecting = false; + picked = new Set(); + }; + const choose = function(id) { close(); App.savedSearches.apply(id); @@ -797,6 +866,7 @@ App.ui = App.ui || {}; const box = panel(); if (!box) return; pending = null; + stopSelecting(); render(); box.classList.add('open'); box.setAttribute('aria-hidden', 'false'); @@ -820,6 +890,7 @@ App.ui = App.ui || {}; box.setAttribute('aria-hidden', 'true'); } pending = null; + stopSelecting(); const trigger = el('saved-searches-btn'); if (trigger) { trigger.setAttribute('aria-expanded', 'false'); @@ -905,6 +976,32 @@ App.ui = App.ui || {}; }); } + const select = el('saved-select-btn'); + if (select) { + select.addEventListener('click', () => { + if (selecting) stopSelecting(); + else { selecting = true; picked = new Set(); pending = null; } + render(); + }); + } + + const mergeBtn = el('saved-merge-btn'); + if (mergeBtn) { + mergeBtn.addEventListener('click', () => { + // In the order they are listed, not the order they were + // ticked: the list is the reader's own arrangement. + const order = App.savedSearches.getAll() + .map((entry) => entry.id) + .filter((id) => picked.has(id)); + const made = App.savedSearches.merge(order); + stopSelecting(); + // Straight into renaming it: a merge's own name is the + // only thing about it nobody has said yet. + if (made) pending = { id: made.id, mode: 'rename' }; + render(); + }); + } + const check = el('saved-check-btn'); if (check) { check.addEventListener('click', () => { @@ -1285,8 +1382,36 @@ App.ui = App.ui || {}; })(); // Settings + menu rendering. + // A merge carries its members' own words, and one box cannot stand for + // several (see fetchMergedBatch, which does not read it). So rather than + // leave a live-looking box that changes nothing, it is turned off and says + // why. Switching to any single channel turns it back on. + let searchPlaceholder = null; + App.ui.syncSearchForSession = function(session) { + const searchInput = document.getElementById('search-input'); + if (!searchInput) return; + // Kept from the markup the first time through, so putting it back does + // not depend on remembering what it said. + if (searchPlaceholder === null) searchPlaceholder = searchInput.placeholder; + const merged = !!(session && session.channel && session.channel.isMerged); + searchInput.disabled = merged; + searchInput.classList.toggle('is-inert', merged); + if (merged) { + searchInput.value = ''; + searchInput.placeholder = 'Each search in this merge keeps its own words'; + } else { + searchInput.placeholder = searchPlaceholder; + } + const clearBtn = document.getElementById('search-clear-btn'); + if (clearBtn && merged) { + clearBtn.classList.remove('is-visible'); + clearBtn.disabled = true; + } + }; + App.ui.renderMenu = function() { const session = App.storage.getSession(); + App.ui.syncSearchForSession(session); const serverEntries = App.storage.getServerEntries(); const sourceSelect = document.getElementById('source-select'); const filtersContainer = document.getElementById('filters-container'); diff --git a/frontend/js/videos.js b/frontend/js/videos.js index 7bff2ae..0f899a6 100644 --- a/frontend/js/videos.js +++ b/frontend/js/videos.js @@ -755,6 +755,94 @@ App.videos = App.videos || {}; }; }; + // Fetches one page from every search in a merge and zips them together, + // the way a group does its channels -- except that a merge's members share + // nothing. Each one carries its own server, channel, filters and words, so + // each gets its own request built from itself rather than from the session. + // + // The search box is deliberately not consulted: a merge is several + // searches, each with its own words, and one box cannot speak for all of + // them (see applyMerged). + const fetchMergedBatch = async function(session, signal) { + const merged = session.channel; + const all = Array.isArray(merged.members) ? merged.members : []; + + // The per-merge "Searches" multi-select, so a member can be switched + // off while browsing. Falls back to all of them when nothing is + // selected. + const selected = session.options ? session.options[App.session.MERGED_MEMBERS_OPTION_ID] : null; + const enabledIds = Array.isArray(selected) && selected.length > 0 + ? selected.map((opt) => opt.id) + : all.map((member) => member.memberId); + const members = all.filter((member) => enabledIds.includes(member.memberId)); + const signature = members.map((member) => member.memberId).join(','); + + if (!state.mergedCursors || state.mergedCursors.mergeId !== merged.id || + state.mergedCursors.signature !== signature) { + state.mergedCursors = { + mergeId: merged.id, + signature: signature, + members: members.map((member) => ({ member: member, page: 1, hasNextPage: true })) + }; + } + + const active = state.mergedCursors.members.filter((cursor) => cursor.hasNextPage); + if (active.length === 0) { + return { items: [], hasNextPage: false }; + } + + const results = await Promise.all(active.map(async (cursor) => { + const member = cursor.member; + const page = claimPage(cursor); + const body = { + channel: member.channelId, + query: member.query || '', + page: page, + perPage: GROUP_CHANNEL_PAGE_SIZE, + server: member.server + }; + Object.entries(member.options || {}).forEach(([key, value]) => { + body[key] = Array.isArray(value) ? value.join(', ') : value; + }); + try { + const response = await fetch('/api/videos', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: signal + }); + const data = await response.json(); + const items = data && Array.isArray(data.items) ? data.items : []; + cursor.hasNextPage = items.length > 0 && + (data && data.pageInfo ? data.pageInfo.hasNextPage !== false : true); + return items; + } catch (err) { + returnPage(cursor, page); + // One source failing is not the merge failing: the others + // still have pages, and a merge that drops to one member is + // still worth reading. An aborted load is the exception -- + // that is the grid being reset under us, and it has to + // propagate or the reset races the batch it cancelled. + if (err && err.name === 'AbortError') throw err; + cursor.hasNextPage = false; + return []; + } + })); + + const interleaved = []; + const maxLen = results.reduce((max, items) => Math.max(max, items.length), 0); + for (let i = 0; i < maxLen; i++) { + results.forEach((items) => { + if (items[i]) interleaved.push(items[i]); + }); + } + + return { + items: interleaved, + hasNextPage: state.mergedCursors.members.some((cursor) => cursor.hasNextPage) + }; + }; + const fetchChannelBatch = async function(session, signal) { const searchInput = document.getElementById('search-input'); const query = searchInput ? searchInput.value : ""; @@ -796,6 +884,7 @@ App.videos = App.videos || {}; }; const fetchNextBatch = function(session, signal) { + if (session.channel.isMerged) return fetchMergedBatch(session, signal); return session.channel.isGroup ? fetchGroupBatch(session, signal) : fetchChannelBatch(session, signal); @@ -1398,7 +1487,43 @@ App.videos = App.videos || {}; } }; + // Steps out of a merge onto one real channel: its first member whose + // source is still set up. Returns false when none of them is, in which + // case the merge is left alone -- there is nothing better to show. + App.videos.leaveMerge = function() { + const session = App.storage.getSession(); + if (!session || !session.channel || !session.channel.isMerged) return false; + const entries = App.storage.getServerEntries(); + for (const member of (session.channel.members || [])) { + const serverEntry = entries.find((candidate) => candidate.url === member.server); + const data = serverEntry ? serverEntry.data : null; + const channel = data ? App.session.resolveChannelById(data, member.channelId) : null; + if (!channel) continue; + const next = { + server: member.server, + channel: channel, + options: App.session.hydrateOptions(channel, member.options || {}) + }; + App.storage.setSession(next); + App.session.savePreference(next); + if (App.ui && typeof App.ui.renderMenu === 'function') App.ui.renderMenu(); + return true; + } + return false; + }; + App.videos.handleSearch = function(value) { + // Searching while a merge is open leaves it. A merge is held together + // by its members' own words (see fetchMergedBatch, which does not read + // the box), so there is nowhere in it to put new ones -- and a tag or + // an uploader tapped from a card would otherwise reload the same + // videos and look like nothing happened. The source and channel it + // lands on are the merge's first member, which is also the one the + // session was already pointed at. + const open = App.storage.getSession(); + if (open && open.channel && open.channel.isMerged && typeof value === 'string' && value.trim()) { + App.videos.leaveMerge(); + } if (typeof value === 'string') { App.videos.setSearchBox(value); } @@ -1428,6 +1553,7 @@ App.videos = App.videos || {}; state.renderedVideoIds.clear(); state.loadedVideos = []; state.groupCursors = null; + state.mergedCursors = null; App.virtualGrid.reset(); if (App.feed && typeof App.feed.reset === 'function') { App.feed.reset(); diff --git a/tests/smoke_merge_searches.py b/tests/smoke_merge_searches.py new file mode 100644 index 0000000..d4637ab --- /dev/null +++ b/tests/smoke_merge_searches.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +"""Merging saved searches from different providers into one listing. + +Run against a locally running backend: + + backend/main.py & + .venv/bin/python tests/smoke_merge_searches.py + +Two fixture providers, one saved search on each, merged into one. What the +merge has to do that a channel group cannot: a group's channels all live on +one server and share one query, where these two share nothing at all. So the +load-bearing check is that the grid comes back holding videos from *both* +providers, taken alternately rather than one provider's page and then the +other's. + +Both providers are fixtures, so "interleaved" is something the test can read +off the ids rather than infer: provider A answers a-0, a-1 ... and B answers +b-0, b-1 ... +""" +import json +import sys +from playwright.sync_api import sync_playwright + +BASE = "http://127.0.0.1:5000/" +SERVER_A = "https://a.fixture.test" +SERVER_B = "https://b.fixture.test" +CHANNEL_A = "alpha" +CHANNEL_B = "beta" +# fetchMergedBatch asks each member for this many (GROUP_CHANNEL_PAGE_SIZE). +PER_MEMBER = 4 + + +def status(name, channel_id, channel_name): + return { + "id": name, + "name": name, + "online": True, + "channels": [{ + "id": channel_id, + "name": channel_name, + "description": "A channel that only exists for this test.", + "options": [], + }], + } + + +STATUS_A = status("alpha-co", CHANNEL_A, "Alpha Tube") +STATUS_B = status("beta-co", CHANNEL_B, "Beta Tube") + +# Two saved searches, one per provider, seeded straight into the store -- the +# saving of a search is smoke_saved_searches' business, not this test's. +SEED = """([a, b, ca, cb]) => { + localStorage.setItem('config', JSON.stringify({ + servers: [{ [a]: {} }, { [b]: {} }] + })); + localStorage.removeItem('session'); + localStorage.setItem('favorites', JSON.stringify([])); + localStorage.setItem('savedSearches', JSON.stringify([ + { id: 'ss_a', name: 'Alpha pick', server: a, channelId: ca, + channelName: 'Alpha Tube', query: 'reef', options: {}, optionLabels: [], + createdAt: 1, seenIds: [], lastViewedAt: 1, lastCheckedAt: 1, newCount: 0 }, + { id: 'ss_b', name: 'Beta pick', server: b, channelId: cb, + channelName: 'Beta Tube', query: 'ridge', options: {}, optionLabels: [], + createdAt: 2, seenIds: [], lastViewedAt: 2, lastCheckedAt: 2, newCount: 0 } + ])); +}""" + +GRID_IDS = """() => (App.state.loadedVideos || []).map(v => v.id)""" + +SESSION = """() => { + const s = App.storage.getSession(); + return { + merged: !!(s && s.channel && s.channel.isMerged), + channelId: s && s.channel ? s.channel.id : null, + members: s && s.channel && s.channel.members + ? s.channel.members.map(m => m.server + '|' + m.channelId + '|' + m.query) + : null, + name: s && s.channel ? s.channel.name : null + }; +}""" + +STORE = """() => App.savedSearches.getAll().map(e => ({ + id: e.id, name: e.name, + members: e.members ? e.members.map(m => m.server) : null +}))""" + + +class Checks: + def __init__(self): + self.failed = 0 + + def ok(self, label, condition, detail=""): + mark = "PASS" if condition else "FAIL" + if not condition: + self.failed += 1 + print(f" [{mark}] {label}" + (f" -- {detail}" if detail and not condition else "")) + + +def install_routes(page, seen): + page.route(f"{SERVER_A}/api/status", lambda route: route.fulfill( + status=200, content_type="application/json", body=json.dumps(STATUS_A))) + page.route(f"{SERVER_B}/api/status", lambda route: route.fulfill( + status=200, content_type="application/json", body=json.dumps(STATUS_B))) + + def videos(route): + try: + body = json.loads(route.request.post_data or "{}") + except ValueError: + body = {} + server = body.get("server") or "" + page_no = int(body.get("page") or 1) + per = int(body.get("perPage") or 12) + tag = "a" if server == SERVER_A else ("b" if server == SERVER_B else "?") + seen.append({"server": server, "channel": body.get("channel"), + "query": body.get("query"), "page": page_no, "perPage": per}) + start = (page_no - 1) * per + items = [{ + "id": f"{tag}-{start + i}", + "title": f"{tag} clip {start + i}", + "url": f"{server}/v{start + i}", + "thumb": "", "channel": body.get("channel"), + "duration": 30, "isLive": False, "tags": [], + } for i in range(per)] + route.fulfill(status=200, content_type="application/json", + body=json.dumps({"items": items, "pageInfo": {"hasNextPage": True}})) + + page.route("**/api/videos", videos) + + +def main(): + c = Checks() + with sync_playwright() as p: + browser = p.chromium.launch(args=[ + "--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu", + "--renderer-process-limit=1", "--js-flags=--max-old-space-size=512", + ]) + page = browser.new_page(viewport={"width": 1280, "height": 900}) + crashes = [] + page.on("pageerror", lambda e: crashes.append(str(e))) + seen = [] + install_routes(page, seen) + + page.goto(BASE, wait_until="domcontentloaded") + page.evaluate(SEED, [SERVER_A, SERVER_B, CHANNEL_A, CHANNEL_B]) + page.goto(BASE, wait_until="load") + page.wait_for_selector(".video-card", timeout=30000) + page.wait_for_timeout(800) + + print("\nmerging the two") + page.click("#saved-searches-btn") + page.wait_for_selector("#saved-panel.open", timeout=10000) + rows = page.eval_on_selector_all(".saved-row", "els => els.length") + c.ok("both saved searches are listed", rows == 2, str(rows)) + + page.click("#saved-select-btn") + page.wait_for_timeout(200) + page.click('.saved-row[data-id="ss_a"] .saved-row-main') + page.click('.saved-row[data-id="ss_b"] .saved-row-main') + page.wait_for_timeout(200) + label = page.eval_on_selector("#saved-merge-btn", "el => el.textContent") + c.ok("the merge action counts what is picked", + "2" in label, label) + + page.click("#saved-merge-btn") + page.wait_for_timeout(400) + # The new merge opens straight into its rename field; Enter keeps the + # name it was given. + page.keyboard.press("Enter") + page.wait_for_timeout(300) + + store = page.evaluate(STORE) + c.ok("the two originals were replaced by one merge", len(store) == 1, str(store)) + c.ok("and it holds a member from each provider", + len(store) == 1 and sorted(store[0]["members"] or []) == sorted([SERVER_A, SERVER_B]), + str(store)) + + print("\nopening it") + seen.clear() + page.click(f'.saved-row[data-id="{store[0]["id"]}"] .saved-row-main') + page.wait_for_timeout(2500) + + session = page.evaluate(SESSION) + c.ok("the session is a merge", session["merged"], str(session)) + c.ok("carrying both members, each with its own words", + sorted(session["members"] or []) == sorted([ + f"{SERVER_A}|{CHANNEL_A}|reef", f"{SERVER_B}|{CHANNEL_B}|ridge"]), + str(session["members"])) + + asked = {(r["server"], r["query"]) for r in seen} + c.ok("both providers were asked, each for its own search", + asked == {(SERVER_A, "reef"), (SERVER_B, "ridge")}, str(asked)) + + ids = page.evaluate(GRID_IDS) + kinds = [i.split("-")[0] for i in ids] + c.ok("the grid holds videos from both providers", + "a" in kinds and "b" in kinds, str(ids[:10])) + # The point of the exercise: alternated, not one provider then the other. + head = kinds[:2 * PER_MEMBER] + c.ok("taken alternately rather than one provider at a time", + head == ["a", "b"] * PER_MEMBER, str(head)) + + print("\nthe search box stays out of it") + box = page.evaluate("""() => { + const el = document.getElementById('search-input'); + return { disabled: el.disabled, value: el.value, placeholder: el.placeholder }; + }""") + c.ok("the box is off while a merge is open", box["disabled"], str(box)) + c.ok("and says why", "own words" in box["placeholder"], str(box["placeholder"])) + + print("\nit survives a reload") + page.reload(wait_until="load") + page.wait_for_selector(".video-card", timeout=30000) + page.wait_for_timeout(2000) + again = page.evaluate(SESSION) + c.ok("still a merge after reloading", again["merged"], str(again)) + c.ok("with the same members", + sorted(again["members"] or []) == sorted(session["members"] or []), + str(again["members"])) + reloaded = [i.split("-")[0] for i in page.evaluate(GRID_IDS)] + c.ok("and still fed by both providers", + "a" in reloaded and "b" in reloaded, str(reloaded[:10])) + + print("\nturning one member off") + page.evaluate("""(host) => { + const s = App.storage.getSession(); + const keep = s.channel.members.filter(m => m.server.indexOf(host) !== -1); + s.options = { __mergedMembers: keep.map(m => ({ id: m.memberId, title: m.channelName })) }; + App.storage.setSession(s); + }""", "a.fixture") + page.evaluate("() => { App.videos.resetGrid(); return App.videos.loadVideos(); }") + page.wait_for_timeout(2000) + only = [i.split("-")[0] for i in page.evaluate(GRID_IDS)] + c.ok("only the member left on is asked", + only and set(only) == {"a"}, str(only[:10])) + + print("\nsearching from inside a merge steps out of it") + # A tag or an uploader tapped on a card comes through handleSearch. + # There is nowhere in a merge to put new words, so rather than reload + # the same videos and look broken, it lands on one real channel. + page.evaluate("() => App.videos.handleSearch('anemone')") + page.wait_for_timeout(2000) + after = page.evaluate(SESSION) + c.ok("the session is no longer a merge", not after["merged"], str(after)) + c.ok("it landed on a member's own channel", + after["channelId"] in (CHANNEL_A, CHANNEL_B), str(after["channelId"])) + box2 = page.evaluate("""() => { + const el = document.getElementById('search-input'); + return { disabled: el.disabled, value: el.value }; + }""") + c.ok("and the box is usable again", not box2["disabled"], str(box2)) + c.ok("holding what was searched for", box2["value"] == "anemone", str(box2)) + searched = [r for r in seen if r["query"] == "anemone"] + c.ok("which is what the provider was asked for", bool(searched), str(seen[-3:])) + + c.ok("nothing threw along the way", not crashes, str(crashes[:2])) + browser.close() + + print(f"\n{'FAILED' if c.failed else 'OK'}: {c.failed} check(s) failed") + return 1 if c.failed else 0 + + +if __name__ == "__main__": + sys.exit(main())