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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user