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

@@ -164,6 +164,7 @@ App.session = App.session || {};
// channels as a toggleable multi-select, so the user can browse "All <group>"
// while disabling individual channels.
App.session.GROUP_CHANNELS_OPTION_ID = '__groupChannels';
App.session.MERGED_MEMBERS_OPTION_ID = '__mergedMembers';
// Options/session helpers that power channel selection and filters.
App.session.serializeOptions = function(options) {
@@ -233,6 +234,45 @@ App.session = App.session || {};
};
};
// Builds a pseudo-channel representing a merged saved search: several
// saved searches, each with its own source, channel, filters and words,
// browsed as one listing. A channel group does something similar but
// cannot express this -- every channel in a group belongs to the same
// server and shares one query, where a merge is held together by nothing
// except the reader having put it together (see fetchMergedBatch).
//
// The members are carried on the channel itself rather than looked up from
// a server's status, because no single server knows about them. That is
// also what lets a merged session survive a reload.
App.session.buildMergedChannel = function(entry) {
if (!entry || !Array.isArray(entry.members) || entry.members.length === 0) return null;
const members = entry.members.map((member, i) => Object.assign({}, member, {
// Stable within the merge, so a member toggle keeps meaning the
// same member even if two of them name the same channel.
memberId: member.memberId || `m${i}`
}));
const memberOptions = members.map((member) => ({
id: member.memberId,
title: member.channelName || member.channelId
}));
return {
id: `merged:${entry.id}`,
name: entry.name || 'Merged search',
isMerged: true,
savedSearchId: entry.id,
members: members,
// Same shape as a group's "Channels" filter, so the existing
// filter UI shows it without knowing what a merge is.
options: memberOptions.length > 0 ? [{
id: App.session.MERGED_MEMBERS_OPTION_ID,
title: 'Searches',
multiSelect: true,
selectAllDefault: true,
options: memberOptions
}] : []
};
};
// Resolves a stored channel id (which may reference a single channel or a
// "group:<id>" pseudo-channel) against a server's status payload.
App.session.resolveChannelById = function(serverData, channelId) {
@@ -350,6 +390,28 @@ App.session = App.session || {};
const serverKeys = (config.servers || []).map((serverObj) => Object.keys(serverObj)[0]);
const existingSession = App.storage.getSession();
// A merged search answers to no single server, so the check below --
// "does this server still list this channel" -- cannot speak for it.
// It is valid while the saved search it came from is still there and
// at least one member's source is still set up. Rebuilt from that
// saved search rather than trusted as stored, so a rename or an edited
// member is picked up instead of being frozen into the session.
if (existingSession && existingSession.channel && existingSession.channel.isMerged) {
const stored = App.savedSearches && typeof App.savedSearches.get === 'function'
? App.savedSearches.get(existingSession.channel.savedSearchId)
: null;
const channel = stored ? App.session.buildMergedChannel(stored) : existingSession.channel;
const members = (channel && channel.members) || [];
if (members.some((member) => serverKeys.includes(member.server))) {
App.storage.setSession({
server: members[0].server,
channel: channel,
options: App.session.hydrateOptions(channel, App.session.serializeOptions(existingSession.options))
});
return true;
}
}
// Leave a still-valid session untouched so we don't disturb the user's
// current server/channel selection on refresh.
if (existingSession && existingSession.channel && serverKeys.includes(existingSession.server)) {