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:
Simon
2026-11-01 07:59:12 +00:00
parent c60f5e01dc
commit 2fcc17877d
8 changed files with 857 additions and 31 deletions

View File

@@ -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) {