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. Twice // the probe below, so a listing that shifts by a page between visits is // still measured against something. const SEEN_KEEP = 24; // 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; // 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 ---------------------------------------------------------- const clean = function(entry) { if (!entry || typeof entry !== 'object') return null; if (!entry.id || !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; }; // 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; 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) }; }; // Two searches are the same search when they would send the same request. App.savedSearches.signature = function(desc) { if (!desc) return ''; const options = desc.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 [desc.server, desc.channelId, (desc.query || '').toLowerCase().trim()] .concat(parts).join('||'); }; // 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. const autoName = function(desc) { 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; }; // 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. const probeIds = async function(entry, signal) { const serverEntry = App.storage.getServerEntries() .find((candidate) => candidate.url === entry.server); const serverData = serverEntry ? serverEntry.data : null; const channel = serverData ? App.session.resolveChannelById(serverData, entry.channelId) : null; if (!channel) throw new Error('channel is no longer listed'); const ask = async function(channelId, perPage, extra) { const body = Object.assign({ channel: channelId, query: entry.query || '', page: 1, perPage: perPage, server: entry.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 = entry.options ? entry.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 []; const perChannel = await Promise.all(enabled.map((id) => ask(id, GROUP_PROBE_PER_PAGE))); // Interleaved, so "the newest" means the same thing it does in the // grid: one from each channel in turn. const merged = []; const longest = perChannel.reduce((max, ids) => Math.max(max, ids.length), 0); for (let i = 0; i < longest; i++) { perChannel.forEach((ids) => { if (ids[i]) merged.push(ids[i]); }); } return merged; } const extra = {}; Object.entries(entry.options || {}).forEach(([key, value]) => { extra[key] = Array.isArray(value) ? value.join(', ') : value; }); return ask(entry.channelId, PROBE_PER_PAGE, extra); }; const checkOne = async function(entry) { const controller = new AbortController(); const timer = window.setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS); try { const ids = await probeIds(entry, controller.signal); 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; } const fresh = ids.filter((id) => !seen.has(id)).length; patch(entry.id, { lastCheckedAt: now(), newCount: fresh, 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 }); } finally { window.clearTimeout(timer); } }; 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; }; // ---- opening one ----------------------------------------------------- // 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; 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(); }); }; })();