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

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