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())