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