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
727 lines
31 KiB
JavaScript
727 lines
31 KiB
JavaScript
window.App = window.App || {};
|
|
App.savedSearches = App.savedSearches || {};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Saved searches
|
|
//
|
|
// A search here is more than the words in the box: it is the source, the
|
|
// channel, every filter that channel offers, and the query together. Set all
|
|
// that up once -- "uncensored JAV, newest first, 1080p and up" -- and this
|
|
// keeps it, so getting back to it is one tap instead of five.
|
|
//
|
|
// Each saved search also remembers what was on screen the last time it was
|
|
// opened (the ids of the newest videos it showed). Once a day the client asks
|
|
// each one's first page again and counts how many of those videos it has never
|
|
// shown the reader, which is the number on the badge. Nothing is counted
|
|
// server-side and nothing leaves the device but the same listing request the
|
|
// grid would have made anyway.
|
|
// ---------------------------------------------------------------------------
|
|
(function() {
|
|
const KEY = App.constants.SAVED_SEARCHES_KEY;
|
|
|
|
// How many of the newest ids are kept as the "already seen" mark. Several
|
|
// times the page asked for below: `perPage` is a request, not a promise --
|
|
// sources answer a page of 12 with 30 whenever that is how their own pages
|
|
// are cut -- so the mark has to be able to hold a page larger than the one
|
|
// that was asked for, and then some, for a listing that shifts between
|
|
// visits to still be measured against something.
|
|
const SEEN_KEEP = 60;
|
|
// One page is enough to answer "is there anything new, and roughly how
|
|
// much": the badge stops counting at 9+ anyway.
|
|
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;
|
|
// How often a tab left open gets round to asking again. The check itself
|
|
// is still once a day per search; this is just how often we look.
|
|
const HEARTBEAT_MS = 30 * 60 * 1000;
|
|
// Checks run together, but not all of them: a reader with twenty saved
|
|
// searches should not open twenty listing requests on startup.
|
|
const CHECK_CONCURRENCY = 3;
|
|
const PROBE_TIMEOUT_MS = 12000;
|
|
|
|
App.savedSearches.NEW_CAP = NEW_CAP;
|
|
|
|
const now = () => Date.now();
|
|
|
|
// ---- 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) 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',
|
|
server: String(entry.server),
|
|
channelId: String(entry.channelId),
|
|
channelName: typeof entry.channelName === 'string' ? entry.channelName : '',
|
|
query: typeof entry.query === 'string' ? entry.query : '',
|
|
options: entry.options && typeof entry.options === 'object' ? entry.options : {},
|
|
optionLabels: Array.isArray(entry.optionLabels) ? entry.optionLabels.map(String) : [],
|
|
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
|
|
};
|
|
};
|
|
|
|
App.savedSearches.getAll = function() {
|
|
try {
|
|
const parsed = JSON.parse(localStorage.getItem(KEY));
|
|
if (!Array.isArray(parsed)) return [];
|
|
return parsed.map(clean).filter(Boolean);
|
|
} catch (err) {
|
|
return [];
|
|
}
|
|
};
|
|
|
|
const setAll = function(entries) {
|
|
try {
|
|
localStorage.setItem(KEY, JSON.stringify(entries));
|
|
} catch (err) { /* a full store is not worth failing the click over */ }
|
|
return entries;
|
|
};
|
|
|
|
App.savedSearches.get = function(id) {
|
|
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.
|
|
const patch = function(id, changes) {
|
|
const entries = App.savedSearches.getAll();
|
|
const found = entries.find((entry) => entry.id === id);
|
|
if (!found) return null;
|
|
Object.assign(found, changes);
|
|
setAll(entries);
|
|
return found;
|
|
};
|
|
|
|
// ---- what is on screen right now -------------------------------------
|
|
|
|
const optionLabelsOf = function(options) {
|
|
const labels = [];
|
|
Object.values(options || {}).forEach((value) => {
|
|
if (Array.isArray(value)) {
|
|
// A multi-select that is fully on says nothing worth printing;
|
|
// the interesting case is a narrowed one.
|
|
if (value.length && value.length <= 3) {
|
|
value.forEach((opt) => labels.push(opt.title || opt.id));
|
|
} else if (value.length) {
|
|
labels.push(`${value.length} selected`);
|
|
}
|
|
} else if (value && (value.title || value.id)) {
|
|
labels.push(value.title || value.id);
|
|
}
|
|
});
|
|
return labels;
|
|
};
|
|
|
|
// The search the app is showing, in the shape a saved one is stored in.
|
|
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,
|
|
channelId: session.channel.id,
|
|
channelName: session.channel.name || session.channel.id,
|
|
query: (box ? box.value : '').trim(),
|
|
options: App.session.serializeOptions(session.options),
|
|
optionLabels: optionLabelsOf(session.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 [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() {
|
|
const desc = App.savedSearches.current();
|
|
if (!desc) return null;
|
|
const wanted = App.savedSearches.signature(desc);
|
|
return App.savedSearches.getAll()
|
|
.find((entry) => App.savedSearches.signature(entry) === wanted) || null;
|
|
};
|
|
|
|
// 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(' · ');
|
|
return narrowed ? `${channel} · ${narrowed}` : channel;
|
|
};
|
|
|
|
// The ids of what the grid is showing, newest first. Saving straight after
|
|
// a load therefore starts at "nothing new", which is the truth: the reader
|
|
// is looking at it.
|
|
const visibleIds = function() {
|
|
return (App.state.loadedVideos || [])
|
|
.slice(0, SEEN_KEEP)
|
|
.map((video) => String(video && (video.id || video.url) || ''))
|
|
.filter(Boolean);
|
|
};
|
|
|
|
App.savedSearches.saveCurrent = function() {
|
|
const desc = App.savedSearches.current();
|
|
if (!desc) return null;
|
|
const already = App.savedSearches.findMatching();
|
|
if (already) return already;
|
|
const entry = clean(Object.assign({}, desc, {
|
|
id: `ss_${now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
|
|
name: autoName(desc),
|
|
createdAt: now(),
|
|
seenIds: visibleIds(),
|
|
lastViewedAt: now(),
|
|
lastCheckedAt: now(),
|
|
newCount: 0
|
|
}));
|
|
const entries = App.savedSearches.getAll();
|
|
entries.unshift(entry);
|
|
setAll(entries);
|
|
changed();
|
|
return entry;
|
|
};
|
|
|
|
App.savedSearches.remove = function(id) {
|
|
setAll(App.savedSearches.getAll().filter((entry) => entry.id !== id));
|
|
changed();
|
|
};
|
|
|
|
App.savedSearches.rename = function(id, name) {
|
|
const next = (name || '').trim();
|
|
if (!next) return null;
|
|
const entry = patch(id, { name: next.slice(0, 80) });
|
|
changed();
|
|
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();
|
|
const entry = patch(id, Object.assign({
|
|
lastViewedAt: now(),
|
|
lastCheckedAt: now(),
|
|
newCount: 0,
|
|
unreachable: false
|
|
}, seen.length ? { seenIds: seen } : {}));
|
|
changed();
|
|
return entry;
|
|
};
|
|
|
|
// Being on a saved search is viewing it, however the reader got there --
|
|
// by tapping it here, or by setting the same channel and filters by hand.
|
|
App.savedSearches.syncViewed = function() {
|
|
const match = App.savedSearches.findMatching();
|
|
if (match) App.savedSearches.markViewed(match.id);
|
|
return match;
|
|
};
|
|
|
|
App.savedSearches.totalNew = function() {
|
|
return App.savedSearches.getAll()
|
|
.reduce((sum, entry) => sum + (entry.newCount || 0), 0);
|
|
};
|
|
|
|
// What a count looks like on a badge: nothing, a number, or "9+".
|
|
App.savedSearches.badgeLabel = function(count) {
|
|
if (!count) return '';
|
|
return count > NEW_CAP ? `${NEW_CAP}+` : String(count);
|
|
};
|
|
|
|
const changed = function() {
|
|
if (App.ui && App.ui.savedSearches && typeof App.ui.savedSearches.sync === 'function') {
|
|
App.ui.savedSearches.sync();
|
|
}
|
|
};
|
|
|
|
// ---- the daily check -------------------------------------------------
|
|
|
|
// 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.
|
|
// 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 === member.server);
|
|
const serverData = serverEntry ? serverEntry.data : 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, size, extra) {
|
|
const body = Object.assign({
|
|
channel: channelId,
|
|
query: member.query || '',
|
|
page: 1,
|
|
perPage: size,
|
|
server: member.server
|
|
}, extra || {});
|
|
const response = await fetch('/api/videos', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
signal: signal
|
|
});
|
|
if (!response.ok) throw new Error(`listing request failed: ${response.status}`);
|
|
const data = await response.json();
|
|
const items = data && Array.isArray(data.items) ? data.items : [];
|
|
return items.map((item) => String(item && (item.id || item.url) || '')).filter(Boolean);
|
|
};
|
|
|
|
if (channel.isGroup) {
|
|
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 [];
|
|
return interleave(await Promise.all(
|
|
enabled.map((id) => ask(id, GROUP_PROBE_PER_PAGE))));
|
|
}
|
|
|
|
const extra = {};
|
|
Object.entries(member.options || {}).forEach(([key, value]) => {
|
|
extra[key] = Array.isArray(value) ? value.join(', ') : value;
|
|
});
|
|
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
|
|
// been shown -- counted only as far down the page as the mark reaches.
|
|
//
|
|
// `perPage` is a request, not a promise: a source whose own pages are 30
|
|
// long answers a page of 12 with 30. The reader was shown as much of that
|
|
// as the grid asked for, so an id further down than the mark goes has not
|
|
// been seen and never was new either -- it was on the same page the whole
|
|
// time. Counting those would put a number on the badge that no amount of
|
|
// looking takes off again.
|
|
const countNew = function(ids, seen) {
|
|
return ids.slice(0, seen.size).filter((id) => !seen.has(id)).length;
|
|
};
|
|
|
|
const askSource = async function(entry) {
|
|
const controller = new AbortController();
|
|
const timer = window.setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
|
try {
|
|
return await probeIds(entry, controller.signal);
|
|
} finally {
|
|
window.clearTimeout(timer);
|
|
}
|
|
};
|
|
|
|
const checkOne = async function(entry) {
|
|
try {
|
|
const ids = await askSource(entry);
|
|
const seen = new Set(entry.seenIds || []);
|
|
// A search nobody has ever opened has nothing to compare against,
|
|
// so the whole first page is its mark rather than its news.
|
|
if (!seen.size) {
|
|
patch(entry.id, { seenIds: ids.slice(0, SEEN_KEEP), lastCheckedAt: now(), newCount: 0, unreachable: false });
|
|
return;
|
|
}
|
|
patch(entry.id, { lastCheckedAt: now(), newCount: countNew(ids, seen), unreachable: false });
|
|
} catch (err) {
|
|
// A source that is down must not look like a search with nothing
|
|
// new in it, and must not reset the clock either: it is tried
|
|
// again on the next look.
|
|
patch(entry.id, { unreachable: true });
|
|
}
|
|
};
|
|
|
|
let running = null;
|
|
|
|
// Checks every saved search that is due (or all of them, with `force`).
|
|
// Resolves when they have all been written back; safe to call at any time,
|
|
// and calling it while a run is in flight joins that run.
|
|
App.savedSearches.check = function(opts) {
|
|
const force = !!(opts && opts.force);
|
|
if (running) return running;
|
|
const due = App.savedSearches.getAll().filter((entry) =>
|
|
force || !entry.lastCheckedAt || (now() - entry.lastCheckedAt) >= DAY_MS);
|
|
if (!due.length) return Promise.resolve(0);
|
|
|
|
const queue = due.slice();
|
|
const worker = async function() {
|
|
while (queue.length) {
|
|
await checkOne(queue.shift());
|
|
changed();
|
|
}
|
|
};
|
|
running = Promise.all(
|
|
Array.from({ length: Math.min(CHECK_CONCURRENCY, queue.length) }, worker)
|
|
).then(() => {
|
|
running = null;
|
|
changed();
|
|
return due.length;
|
|
}).catch(() => {
|
|
running = null;
|
|
changed();
|
|
return 0;
|
|
});
|
|
return running;
|
|
};
|
|
|
|
App.savedSearches.isChecking = function() { return !!running; };
|
|
|
|
let marking = null;
|
|
|
|
// The whole list, read. For a reader who sees the badge, decides none of
|
|
// it is for them, and wants the slate clean rather than nine rows to open
|
|
// one after another.
|
|
//
|
|
// The mark is moved on to what each source lists now rather than the count
|
|
// merely being zeroed: a count that is only zeroed comes back on the next
|
|
// check, because the videos it counted are still ones nobody was shown.
|
|
// That is one listing request per saved search, the same one the daily
|
|
// check makes.
|
|
App.savedSearches.markAllRead = function() {
|
|
if (marking) return marking;
|
|
const entries = App.savedSearches.getAll();
|
|
if (!entries.length) return Promise.resolve(0);
|
|
|
|
const queue = entries.slice();
|
|
const worker = async function() {
|
|
while (queue.length) {
|
|
const entry = queue.shift();
|
|
try {
|
|
const ids = await askSource(entry);
|
|
patch(entry.id, {
|
|
seenIds: ids.slice(0, SEEN_KEEP),
|
|
lastViewedAt: now(),
|
|
lastCheckedAt: now(),
|
|
newCount: 0,
|
|
unreachable: false
|
|
});
|
|
} catch (err) {
|
|
// The source did not answer, so the mark cannot be moved
|
|
// forward. The count still goes out -- the reader said they
|
|
// are done with it -- and the row says it could not be
|
|
// reached, so the number coming back later is explained.
|
|
patch(entry.id, { newCount: 0, lastViewedAt: now(), unreachable: true });
|
|
}
|
|
changed();
|
|
}
|
|
};
|
|
marking = Promise.all(
|
|
Array.from({ length: Math.min(CHECK_CONCURRENCY, queue.length) }, worker)
|
|
).then(() => {
|
|
marking = null;
|
|
changed();
|
|
return entries.length;
|
|
}).catch(() => {
|
|
marking = null;
|
|
changed();
|
|
return 0;
|
|
});
|
|
changed();
|
|
return marking;
|
|
};
|
|
|
|
App.savedSearches.isMarkingAllRead = function() { return !!marking; };
|
|
|
|
// ---- 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
|
|
// new (see markViewed).
|
|
App.savedSearches.apply = async function(id) {
|
|
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) {
|
|
App.ui.showError(`${entry.name}: the source ${entry.server} isn't set up on this device any more.`);
|
|
return false;
|
|
}
|
|
const channel = App.session.resolveChannelById(serverEntry.data, entry.channelId);
|
|
if (!channel) {
|
|
App.ui.showError(`${entry.name}: ${entry.server} no longer lists that channel.`);
|
|
patch(entry.id, { unreachable: true });
|
|
changed();
|
|
return false;
|
|
}
|
|
|
|
const session = {
|
|
server: entry.server,
|
|
channel: channel,
|
|
options: App.session.hydrateOptions(channel, entry.options)
|
|
};
|
|
App.storage.setSession(session);
|
|
App.session.savePreference(session);
|
|
|
|
App.videos.setSearchBox(entry.query || '');
|
|
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;
|
|
};
|
|
|
|
// ---- lifecycle -------------------------------------------------------
|
|
|
|
let started = false;
|
|
|
|
App.savedSearches.start = function() {
|
|
if (started) return;
|
|
started = true;
|
|
changed();
|
|
App.savedSearches.check();
|
|
// A tab left open for days still gets round to it.
|
|
window.setInterval(() => App.savedSearches.check(), HEARTBEAT_MS);
|
|
// And a phone that had the app in the background for a day checks as
|
|
// soon as it is looked at again, rather than at the next reload.
|
|
document.addEventListener('visibilitychange', function() {
|
|
if (document.visibilityState === 'visible') App.savedSearches.check();
|
|
});
|
|
};
|
|
})();
|