Let saved searches from different sources be read as one
A saved search is one source, one channel on it, that channel's filters and the words. Two of them on different sources had no way to be read together, and a channel group is not it: every channel in a group belongs to the same server and shares one query, where the interesting pair is "uncensored JAV over here" and "amateur 1080p over there". So a saved search can now hold several of those instead of one. Opening it feeds a single grid from all of them, taken alternately -- one from each in turn, the way a group zips its channels -- rather than one source's page and then the next. Each member keeps its own source, channel, filters and words, because having its own words is most of why it was worth saving separately. Every entry is read as a list of members, a plain search being a merge of one. That is what lets the daily check, mark-all-read and the bookmark go on working without learning what a merge is: they ask each member and zip the answers, so the badge counts what the grid would have shown. The search box is turned off while a merge is open and says why -- there is nothing for one box to say for several members. A tag or an uploader tapped on a card steps out of the merge onto one real channel instead, since otherwise it would reload the same videos and look broken. A merge answers to no single server, so it is not stored as a channel preference and is not looked up from a server's status: its members ride on the session, and a reload rebuilds it from the saved search so a rename is picked up rather than frozen in. One source failing drops that member and leaves the rest readable. Merging a merge flattens it, so "the searches in it" means the same however it was assembled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
This commit is contained in:
@@ -160,6 +160,13 @@ body.theme-light .top-bar {
|
|||||||
color: var(--text-secondary);
|
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 {
|
.search-clear-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
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; }
|
.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 {
|
.saved-row-text {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -259,6 +259,7 @@
|
|||||||
<p class="saved-head-note" id="saved-head-note"></p>
|
<p class="saved-head-note" id="saved-head-note"></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="saved-head-actions">
|
<div class="saved-head-actions">
|
||||||
|
<button id="saved-select-btn" class="btn-link" type="button">Merge…</button>
|
||||||
<button id="saved-read-btn" class="btn-link" type="button">Mark all read</button>
|
<button id="saved-read-btn" class="btn-link" type="button">Mark all read</button>
|
||||||
<button id="saved-check-btn" class="btn-link" type="button">Check now</button>
|
<button id="saved-check-btn" class="btn-link" type="button">Check now</button>
|
||||||
<button id="saved-close-btn" class="close-btn" type="button" aria-label="Close">✕</button>
|
<button id="saved-close-btn" class="close-btn" type="button" aria-label="Close">✕</button>
|
||||||
@@ -270,6 +271,7 @@
|
|||||||
then press the bookmark in the search bar to keep the whole thing.
|
then press the bookmark in the search bar to keep the whole thing.
|
||||||
</div>
|
</div>
|
||||||
<div class="saved-foot">
|
<div class="saved-foot">
|
||||||
|
<button id="saved-merge-btn" class="btn-secondary saved-add" type="button" hidden>Merge selected</button>
|
||||||
<button id="saved-add-btn" class="btn-secondary saved-add" type="button">Save the current search</button>
|
<button id="saved-add-btn" class="btn-secondary saved-add" type="button">Save the current search</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ App.savedSearches = App.savedSearches || {};
|
|||||||
const PROBE_PER_PAGE = 12;
|
const PROBE_PER_PAGE = 12;
|
||||||
// A group asks each of its channels, so each one asks for less.
|
// A group asks each of its channels, so each one asks for less.
|
||||||
const GROUP_PROBE_PER_PAGE = 4;
|
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.
|
// Above this the badge says "9+" rather than a number.
|
||||||
const NEW_CAP = 9;
|
const NEW_CAP = 9;
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
@@ -48,9 +50,44 @@ App.savedSearches = App.savedSearches || {};
|
|||||||
|
|
||||||
// ---- store ----------------------------------------------------------
|
// ---- 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) {
|
const clean = function(entry) {
|
||||||
if (!entry || typeof entry !== 'object') return null;
|
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 {
|
return {
|
||||||
id: String(entry.id),
|
id: String(entry.id),
|
||||||
name: typeof entry.name === 'string' && entry.name.trim() ? entry.name : 'Saved search',
|
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;
|
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
|
// 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
|
// that lands while the reader is renaming or deleting can't write back a
|
||||||
// copy of the list from before they did it.
|
// copy of the list from before they did it.
|
||||||
@@ -126,6 +184,25 @@ App.savedSearches = App.savedSearches || {};
|
|||||||
App.savedSearches.current = function() {
|
App.savedSearches.current = function() {
|
||||||
const session = App.storage.getSession();
|
const session = App.storage.getSession();
|
||||||
if (!session || !session.channel) return null;
|
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');
|
const box = document.getElementById('search-input');
|
||||||
return {
|
return {
|
||||||
server: session.server,
|
server: session.server,
|
||||||
@@ -137,19 +214,29 @@ App.savedSearches = App.savedSearches || {};
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Two searches are the same search when they would send the same request.
|
// One member's worth of identity: the request it would send.
|
||||||
App.savedSearches.signature = function(desc) {
|
const memberSignature = function(member) {
|
||||||
if (!desc) return '';
|
const options = member.options || {};
|
||||||
const options = desc.options || {};
|
|
||||||
const parts = Object.keys(options).sort().map((key) => {
|
const parts = Object.keys(options).sort().map((key) => {
|
||||||
const value = options[key];
|
const value = options[key];
|
||||||
const flat = Array.isArray(value) ? value.slice().sort().join(',') : String(value);
|
const flat = Array.isArray(value) ? value.slice().sort().join(',') : String(value);
|
||||||
return `${key}=${flat}`;
|
return `${key}=${flat}`;
|
||||||
});
|
});
|
||||||
return [desc.server, desc.channelId, (desc.query || '').toLowerCase().trim()]
|
return [member.server, member.channelId, (member.query || '').toLowerCase().trim()]
|
||||||
.concat(parts).join('||');
|
.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
|
// The saved search the app is currently showing, if any -- what makes the
|
||||||
// bookmark in the search bar light up.
|
// bookmark in the search bar light up.
|
||||||
App.savedSearches.findMatching = function() {
|
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
|
// What to call a search nobody has named: the words searched for, or the
|
||||||
// channel when there were none.
|
// 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) {
|
const autoName = function(desc) {
|
||||||
|
if (Array.isArray(desc.members) && desc.members.length) return mergedAutoName(desc.members);
|
||||||
if (desc.query) return desc.query;
|
if (desc.query) return desc.query;
|
||||||
const channel = desc.channelName || desc.channelId;
|
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;
|
return narrowed ? `${channel} · ${narrowed}` : channel;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -213,6 +309,61 @@ App.savedSearches = App.savedSearches || {};
|
|||||||
return entry;
|
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.
|
// Opened, so whatever it shows now counts as seen and the badge goes out.
|
||||||
App.savedSearches.markViewed = function(id) {
|
App.savedSearches.markViewed = function(id) {
|
||||||
const seen = visibleIds();
|
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.
|
// 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
|
// 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.
|
// 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()
|
const serverEntry = App.storage.getServerEntries()
|
||||||
.find((candidate) => candidate.url === entry.server);
|
.find((candidate) => candidate.url === member.server);
|
||||||
const serverData = serverEntry ? serverEntry.data : null;
|
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');
|
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({
|
const body = Object.assign({
|
||||||
channel: channelId,
|
channel: channelId,
|
||||||
query: entry.query || '',
|
query: member.query || '',
|
||||||
page: 1,
|
page: 1,
|
||||||
perPage: perPage,
|
perPage: size,
|
||||||
server: entry.server
|
server: member.server
|
||||||
}, extra || {});
|
}, extra || {});
|
||||||
const response = await fetch('/api/videos', {
|
const response = await fetch('/api/videos', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -284,26 +449,36 @@ App.savedSearches = App.savedSearches || {};
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (channel.isGroup) {
|
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)
|
const enabled = (Array.isArray(selected) && selected.length ? selected : channel.channelIds)
|
||||||
.filter((id) => channel.channelIds.includes(id));
|
.filter((id) => channel.channelIds.includes(id));
|
||||||
if (!enabled.length) return [];
|
if (!enabled.length) return [];
|
||||||
const perChannel = await Promise.all(enabled.map((id) => ask(id, GROUP_PROBE_PER_PAGE)));
|
return interleave(await Promise.all(
|
||||||
// Interleaved, so "the newest" means the same thing it does in the
|
enabled.map((id) => ask(id, GROUP_PROBE_PER_PAGE))));
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const extra = {};
|
const extra = {};
|
||||||
Object.entries(entry.options || {}).forEach(([key, value]) => {
|
Object.entries(member.options || {}).forEach(([key, value]) => {
|
||||||
extra[key] = Array.isArray(value) ? value.join(', ') : 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
|
// 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 -----------------------------------------------------
|
// ---- 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
|
// Puts the whole saved search back: source, channel, filters, query. The
|
||||||
// grid is reloaded here rather than left to the caller, because "applied"
|
// 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
|
// 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);
|
const entry = App.savedSearches.get(id);
|
||||||
if (!entry) return false;
|
if (!entry) return false;
|
||||||
|
|
||||||
|
if (App.savedSearches.isMerged(entry)) return applyMerged(entry);
|
||||||
|
|
||||||
const serverEntry = App.storage.getServerEntries()
|
const serverEntry = App.storage.getServerEntries()
|
||||||
.find((candidate) => candidate.url === entry.server);
|
.find((candidate) => candidate.url === entry.server);
|
||||||
if (!serverEntry || !serverEntry.data) {
|
if (!serverEntry || !serverEntry.data) {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ App.state = {
|
|||||||
feedMuted: true,
|
feedMuted: true,
|
||||||
feedActiveIndex: -1,
|
feedActiveIndex: -1,
|
||||||
feedActiveVideoId: null,
|
feedActiveVideoId: null,
|
||||||
groupCursors: null
|
groupCursors: null,
|
||||||
|
mergedCursors: null
|
||||||
};
|
};
|
||||||
|
|
||||||
// Local storage keys used across modules.
|
// Local storage keys used across modules.
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ App.session = App.session || {};
|
|||||||
// channels as a toggleable multi-select, so the user can browse "All <group>"
|
// channels as a toggleable multi-select, so the user can browse "All <group>"
|
||||||
// while disabling individual channels.
|
// while disabling individual channels.
|
||||||
App.session.GROUP_CHANNELS_OPTION_ID = '__groupChannels';
|
App.session.GROUP_CHANNELS_OPTION_ID = '__groupChannels';
|
||||||
|
App.session.MERGED_MEMBERS_OPTION_ID = '__mergedMembers';
|
||||||
|
|
||||||
// Options/session helpers that power channel selection and filters.
|
// Options/session helpers that power channel selection and filters.
|
||||||
App.session.serializeOptions = function(options) {
|
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
|
// Resolves a stored channel id (which may reference a single channel or a
|
||||||
// "group:<id>" pseudo-channel) against a server's status payload.
|
// "group:<id>" pseudo-channel) against a server's status payload.
|
||||||
App.session.resolveChannelById = function(serverData, channelId) {
|
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 serverKeys = (config.servers || []).map((serverObj) => Object.keys(serverObj)[0]);
|
||||||
const existingSession = App.storage.getSession();
|
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
|
// Leave a still-valid session untouched so we don't disturb the user's
|
||||||
// current server/channel selection on refresh.
|
// current server/channel selection on refresh.
|
||||||
if (existingSession && existingSession.channel && serverKeys.includes(existingSession.server)) {
|
if (existingSession && existingSession.channel && serverKeys.includes(existingSession.server)) {
|
||||||
|
|||||||
@@ -511,7 +511,25 @@ App.ui = App.ui || {};
|
|||||||
|
|
||||||
// Where the search points, in one line: the channel, what the filters
|
// Where the search points, in one line: the channel, what the filters
|
||||||
// narrow it to, and the words themselves.
|
// 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) {
|
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];
|
const parts = [entry.channelName || entry.channelId];
|
||||||
if (entry.optionLabels && entry.optionLabels.length) {
|
if (entry.optionLabels && entry.optionLabels.length) {
|
||||||
parts.push(entry.optionLabels.slice(0, 3).join(' · '));
|
parts.push(entry.optionLabels.slice(0, 3).join(' · '));
|
||||||
@@ -614,10 +632,18 @@ App.ui = App.ui || {};
|
|||||||
return strip;
|
return strip;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Which of the three shapes a row is in. A row only has to be built
|
// Picking rows to merge. A merge is made here rather than from a row's
|
||||||
// again when it changes shape; otherwise the one on screen is updated
|
// own menu because it is about several rows at once -- and because
|
||||||
// where it stands (see render).
|
// "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) {
|
const modeOf = function(entry) {
|
||||||
|
if (selecting) return picked.has(entry.id) ? 'pick-on' : 'pick-off';
|
||||||
return (pending && pending.id === entry.id) ? pending.mode : 'plain';
|
return (pending && pending.id === entry.id) ? pending.mode : 'plain';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -662,9 +688,30 @@ App.ui = App.ui || {};
|
|||||||
text.appendChild(stamp);
|
text.appendChild(stamp);
|
||||||
main.appendChild(count);
|
main.appendChild(count);
|
||||||
main.appendChild(text);
|
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);
|
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') {
|
if (pending && pending.id === entry.id && pending.mode === 'rename') {
|
||||||
const field = renameField(entry, row);
|
const field = renameField(entry, row);
|
||||||
name.replaceWith(field);
|
name.replaceWith(field);
|
||||||
@@ -737,6 +784,8 @@ App.ui = App.ui || {};
|
|||||||
const add = el('saved-add-btn');
|
const add = el('saved-add-btn');
|
||||||
const check = el('saved-check-btn');
|
const check = el('saved-check-btn');
|
||||||
const read = el('saved-read-btn');
|
const read = el('saved-read-btn');
|
||||||
|
const select = el('saved-select-btn');
|
||||||
|
const mergeBtn = el('saved-merge-btn');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
|
|
||||||
const entries = App.savedSearches.getAll();
|
const entries = App.savedSearches.getAll();
|
||||||
@@ -781,13 +830,33 @@ App.ui = App.ui || {};
|
|||||||
read.disabled = marking || !App.savedSearches.totalNew();
|
read.disabled = marking || !App.savedSearches.totalNew();
|
||||||
read.textContent = marking ? 'Marking…' : 'Mark all read';
|
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) {
|
if (add) {
|
||||||
|
// While picking, the merge is the only action that means
|
||||||
|
// anything down here.
|
||||||
|
add.hidden = selecting;
|
||||||
const match = App.savedSearches.findMatching();
|
const match = App.savedSearches.findMatching();
|
||||||
add.disabled = !!match || !App.savedSearches.current();
|
add.disabled = !!match || !App.savedSearches.current();
|
||||||
add.textContent = match ? `Already saved as “${match.name}”` : 'Save the current search';
|
add.textContent = match ? `Already saved as “${match.name}”` : 'Save the current search';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stopSelecting = function() {
|
||||||
|
selecting = false;
|
||||||
|
picked = new Set();
|
||||||
|
};
|
||||||
|
|
||||||
const choose = function(id) {
|
const choose = function(id) {
|
||||||
close();
|
close();
|
||||||
App.savedSearches.apply(id);
|
App.savedSearches.apply(id);
|
||||||
@@ -797,6 +866,7 @@ App.ui = App.ui || {};
|
|||||||
const box = panel();
|
const box = panel();
|
||||||
if (!box) return;
|
if (!box) return;
|
||||||
pending = null;
|
pending = null;
|
||||||
|
stopSelecting();
|
||||||
render();
|
render();
|
||||||
box.classList.add('open');
|
box.classList.add('open');
|
||||||
box.setAttribute('aria-hidden', 'false');
|
box.setAttribute('aria-hidden', 'false');
|
||||||
@@ -820,6 +890,7 @@ App.ui = App.ui || {};
|
|||||||
box.setAttribute('aria-hidden', 'true');
|
box.setAttribute('aria-hidden', 'true');
|
||||||
}
|
}
|
||||||
pending = null;
|
pending = null;
|
||||||
|
stopSelecting();
|
||||||
const trigger = el('saved-searches-btn');
|
const trigger = el('saved-searches-btn');
|
||||||
if (trigger) {
|
if (trigger) {
|
||||||
trigger.setAttribute('aria-expanded', 'false');
|
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');
|
const check = el('saved-check-btn');
|
||||||
if (check) {
|
if (check) {
|
||||||
check.addEventListener('click', () => {
|
check.addEventListener('click', () => {
|
||||||
@@ -1285,8 +1382,36 @@ App.ui = App.ui || {};
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
// Settings + menu rendering.
|
// 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() {
|
App.ui.renderMenu = function() {
|
||||||
const session = App.storage.getSession();
|
const session = App.storage.getSession();
|
||||||
|
App.ui.syncSearchForSession(session);
|
||||||
const serverEntries = App.storage.getServerEntries();
|
const serverEntries = App.storage.getServerEntries();
|
||||||
const sourceSelect = document.getElementById('source-select');
|
const sourceSelect = document.getElementById('source-select');
|
||||||
const filtersContainer = document.getElementById('filters-container');
|
const filtersContainer = document.getElementById('filters-container');
|
||||||
|
|||||||
@@ -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 fetchChannelBatch = async function(session, signal) {
|
||||||
const searchInput = document.getElementById('search-input');
|
const searchInput = document.getElementById('search-input');
|
||||||
const query = searchInput ? searchInput.value : "";
|
const query = searchInput ? searchInput.value : "";
|
||||||
@@ -796,6 +884,7 @@ App.videos = App.videos || {};
|
|||||||
};
|
};
|
||||||
|
|
||||||
const fetchNextBatch = function(session, signal) {
|
const fetchNextBatch = function(session, signal) {
|
||||||
|
if (session.channel.isMerged) return fetchMergedBatch(session, signal);
|
||||||
return session.channel.isGroup
|
return session.channel.isGroup
|
||||||
? fetchGroupBatch(session, signal)
|
? fetchGroupBatch(session, signal)
|
||||||
: fetchChannelBatch(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) {
|
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') {
|
if (typeof value === 'string') {
|
||||||
App.videos.setSearchBox(value);
|
App.videos.setSearchBox(value);
|
||||||
}
|
}
|
||||||
@@ -1428,6 +1553,7 @@ App.videos = App.videos || {};
|
|||||||
state.renderedVideoIds.clear();
|
state.renderedVideoIds.clear();
|
||||||
state.loadedVideos = [];
|
state.loadedVideos = [];
|
||||||
state.groupCursors = null;
|
state.groupCursors = null;
|
||||||
|
state.mergedCursors = null;
|
||||||
App.virtualGrid.reset();
|
App.virtualGrid.reset();
|
||||||
if (App.feed && typeof App.feed.reset === 'function') {
|
if (App.feed && typeof App.feed.reset === 'function') {
|
||||||
App.feed.reset();
|
App.feed.reset();
|
||||||
|
|||||||
263
tests/smoke_merge_searches.py
Normal file
263
tests/smoke_merge_searches.py
Normal file
@@ -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())
|
||||||
Reference in New Issue
Block a user