diff --git a/frontend/css/style.css b/frontend/css/style.css
index 585e6d7..1e11049 100644
--- a/frontend/css/style.css
+++ b/frontend/css/style.css
@@ -139,7 +139,8 @@ body.theme-light .top-bar {
.search-container input {
width: 100%;
- padding: 10px 44px 10px 16px;
+ /* Room on the right for the clear button and the bookmark beside it. */
+ padding: 10px 78px 10px 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 999px;
@@ -162,7 +163,7 @@ body.theme-light .top-bar {
.search-clear-btn {
position: absolute;
top: 50%;
- right: 12px;
+ right: 44px;
transform: translateY(-50%);
width: 26px;
height: 26px;
@@ -3092,3 +3093,363 @@ body.theme-light .video-card img:not(.is-loaded) {
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.privacy-veil { background: var(--bg-primary); }
}
+
+/* =====================================================================
+ Saved searches (see savedSearches.js + App.ui.savedSearches)
+
+ One hairline per row and no boxes inside boxes: the list is read as a
+ document, with the brass reserved for the one thing that is news -- how
+ many videos a saved search has picked up since it was last opened.
+ ===================================================================== */
+
+/* Line icons drawn in place rather than fetched: these sit inside buttons
+ that change colour with state, and a tinted
cannot follow. */
+.icon-line {
+ width: 22px;
+ height: 22px;
+ display: block;
+ fill: none;
+ stroke: currentColor;
+ stroke-width: 1.7;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+}
+
+/* --- The bookmark in the search bar ---------------------------------- */
+.search-save-btn {
+ position: absolute;
+ top: 50%;
+ right: 10px;
+ transform: translateY(-50%);
+ width: 30px;
+ height: 30px;
+ border: none;
+ border-radius: var(--radius-sm);
+ background: none;
+ color: var(--text-secondary);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: color 0.2s ease, background 0.2s ease, transform 0.2s ease;
+}
+
+.search-save-btn:hover { color: var(--text-primary); background: var(--bg-tertiary); }
+.search-save-btn:active { transform: translateY(-50%) scale(0.94); }
+.search-save-btn:disabled { opacity: 0.4; cursor: default; }
+
+/* Saved: the glyph fills in, which is the whole state in one look. */
+.search-save-btn.is-saved { color: var(--accent); }
+.search-save-btn.is-saved .icon-line { fill: currentColor; stroke: currentColor; }
+
+@keyframes saved-flash {
+ 0% { transform: translateY(-50%) scale(1); }
+ 35% { transform: translateY(-50%) scale(1.22); }
+ 100% { transform: translateY(-50%) scale(1); }
+}
+
+.search-save-btn.is-just-saved { animation: saved-flash 0.42s cubic-bezier(0.2, 0.7, 0.2, 1); }
+
+/* --- The header button and its count --------------------------------- */
+.saved-toggle { position: relative; }
+
+.saved-badge {
+ position: absolute;
+ top: 6px;
+ right: 5px;
+ min-width: 16px;
+ height: 16px;
+ padding: 0 4px;
+ border-radius: 999px;
+ background: var(--accent);
+ color: var(--bg-primary);
+ font-size: 9.5px;
+ font-weight: 600;
+ letter-spacing: 0.02em;
+ font-variant-numeric: tabular-nums;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 0 0 2px rgba(20, 17, 13, 0.9);
+}
+
+body.theme-light .saved-badge { box-shadow: 0 0 0 2px rgba(246, 241, 231, 0.95); }
+
+/* --- The list ---------------------------------------------------------- */
+.saved-panel {
+ position: fixed;
+ inset: 0;
+ z-index: 4200;
+ display: none;
+ align-items: flex-start;
+ justify-content: center;
+ padding: 8vh 12px 12px 12px;
+ background: rgba(10, 8, 4, 0.55);
+ -webkit-backdrop-filter: blur(6px);
+ backdrop-filter: blur(6px);
+}
+
+.saved-panel.open { display: flex; }
+
+.saved-box {
+ display: flex;
+ flex-direction: column;
+ width: min(620px, 100%);
+ max-height: min(78vh, 760px);
+ background: var(--bg-primary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-lg);
+ box-shadow: 0 30px 70px var(--shadow);
+ overflow: hidden;
+ animation: card-rise 0.2s ease both;
+}
+
+.saved-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 20px 20px 14px 20px;
+}
+
+.saved-head h3 {
+ font-family: var(--font-display);
+ font-size: 19px;
+ font-weight: 600;
+ letter-spacing: -0.015em;
+}
+
+.saved-head-note {
+ margin-top: 4px;
+ max-width: 44ch;
+ font-size: 12px;
+ line-height: 1.5;
+ color: var(--text-secondary);
+}
+
+.saved-head-actions {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-shrink: 0;
+}
+
+.saved-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0 10px;
+ overscroll-behavior: contain;
+}
+
+@keyframes saved-row-in {
+ from { opacity: 0; transform: translateY(8px); }
+ to { opacity: 1; transform: none; }
+}
+
+.saved-row {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ border-bottom: 1px solid var(--border);
+ animation: saved-row-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
+ animation-delay: calc(var(--i, 0) * 40ms);
+}
+
+.saved-list .saved-row:last-child { border-bottom: none; }
+
+.saved-row-main {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 14px 10px;
+ border: none;
+ border-radius: var(--radius-md);
+ background: none;
+ color: var(--text-primary);
+ font-family: inherit;
+ text-align: left;
+ cursor: pointer;
+ transition: background 0.18s ease;
+}
+
+.saved-row-main:hover { background: var(--bg-tertiary); }
+.saved-row-main:disabled { cursor: default; }
+
+/* The count. Brass and filled when there is something new; a hairline ring
+ and a single dot when there isn't, so an up-to-date row reads as quiet
+ rather than as a zero. */
+.saved-count {
+ flex-shrink: 0;
+ width: 32px;
+ height: 32px;
+ border-radius: 999px;
+ border: 1px solid var(--border-hover);
+ background: rgba(201, 165, 103, 0.16);
+ color: var(--accent);
+ font-size: 13px;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.saved-count.is-quiet {
+ border-color: var(--border);
+ background: transparent;
+ color: var(--text-secondary);
+}
+
+.saved-row.is-unreachable .saved-count { border-style: dashed; }
+
+.saved-row-text {
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+
+.saved-row-name {
+ font-family: var(--font-display);
+ font-size: 15px;
+ font-weight: 500;
+ letter-spacing: -0.01em;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.saved-row-meta {
+ font-size: 12px;
+ color: var(--text-secondary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.saved-row-stamp {
+ font-size: 10px;
+ letter-spacing: 0.07em;
+ text-transform: uppercase;
+ color: var(--text-secondary);
+ opacity: 0.8;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.saved-row-tools {
+ display: flex;
+ gap: 2px;
+ flex-shrink: 0;
+ padding-right: 6px;
+ opacity: 0;
+ transition: opacity 0.18s ease;
+}
+
+.saved-row:hover .saved-row-tools,
+.saved-row:focus-within .saved-row-tools { opacity: 1; }
+
+.saved-tool {
+ width: 32px;
+ height: 32px;
+ border: 1px solid transparent;
+ border-radius: var(--radius-sm);
+ background: none;
+ color: var(--text-secondary);
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: color 0.18s ease, background 0.18s ease, border-color 0.18s ease;
+}
+
+.saved-tool .icon-line { width: 18px; height: 18px; }
+.saved-tool:hover { color: var(--accent); background: var(--bg-tertiary); border-color: var(--border); }
+
+.saved-rename {
+ width: 100%;
+ padding: 5px 9px;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-hover);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ font-family: var(--font-display);
+ font-size: 15px;
+}
+
+.saved-rename:focus { outline: none; box-shadow: 0 0 0 3px var(--focus-ring); }
+
+/* Removing asks first, in the row itself -- no dialog on top of a dialog. */
+.saved-confirm {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+ padding-right: 8px;
+}
+
+.saved-confirm-text {
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.saved-confirm-btn {
+ padding: 6px 10px;
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: none;
+ color: var(--text-primary);
+ font-family: inherit;
+ font-size: 12px;
+ cursor: pointer;
+ transition: background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
+}
+
+.saved-confirm-btn:hover { background: var(--bg-tertiary); border-color: var(--border-hover); }
+
+.saved-confirm-btn.is-danger { color: #e2a2a2; }
+body.theme-light .saved-confirm-btn.is-danger { color: #9f2f2d; }
+.saved-confirm-btn.is-danger:hover { border-color: currentColor; }
+
+.saved-row.is-confirming .saved-row-main { opacity: 0.5; }
+
+.saved-empty {
+ padding: 4px 20px 20px 20px;
+ max-width: 52ch;
+ font-size: 13px;
+ line-height: 1.65;
+ color: var(--text-secondary);
+}
+
+.saved-foot {
+ padding: 14px 20px 18px 20px;
+ border-top: 1px solid var(--border);
+}
+
+.saved-add { width: 100%; }
+.saved-add:disabled { opacity: 0.55; cursor: default; }
+
+@media (max-width: 720px) {
+ .saved-panel { padding: 0; }
+
+ .saved-box {
+ width: 100%;
+ height: 100%;
+ max-height: 100%;
+ border: none;
+ border-radius: 0;
+ }
+
+ /* Nothing to hover with, so the row's tools are simply there. */
+ .saved-row-tools { opacity: 1; }
+ .saved-row-stamp { white-space: normal; }
+}
+
+@media (pointer: coarse) {
+ .saved-row-tools { opacity: 1; }
+ .search-save-btn { width: 34px; height: 34px; }
+}
diff --git a/frontend/index.html b/frontend/index.html
index a9a2a60..afdc18d 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -19,12 +19,26 @@
autocomplete="off" role="combobox" aria-expanded="false"
aria-controls="search-suggest" aria-autocomplete="list">
+
+
@@ -229,6 +243,29 @@
+
+
+
+
+
+ Nothing saved yet. Pick a channel, set your filters, type a search —
+ then press the bookmark in the search bar to keep the whole thing.
+
+
+
+
+
@@ -241,6 +278,7 @@
+
diff --git a/frontend/js/enhance.js b/frontend/js/enhance.js
index 06a0148..8db7953 100644
--- a/frontend/js/enhance.js
+++ b/frontend/js/enhance.js
@@ -185,6 +185,26 @@ App.enhance = App.enhance || {};
});
});
}
+ // Saved searches: the list itself, the one-press save, and every
+ // saved search as a row of its own so ⌘K can jump straight to one.
+ if (App.savedSearches && App.ui && App.ui.savedSearches) {
+ const saved = App.savedSearches.getAll();
+ out.push({ label: 'Saved searches', hint: saved.length ? `${saved.length} saved` : 'Nothing saved yet', run: () => App.ui.savedSearches.open() });
+ const match = App.savedSearches.findMatching();
+ out.push({
+ label: match ? `Remove saved search: ${match.name}` : 'Save the current search',
+ hint: 'Source · channel · filters · query',
+ run: () => App.ui.savedSearches.toggleCurrent()
+ });
+ saved.forEach((entry) => {
+ const label = App.savedSearches.badgeLabel(entry.newCount);
+ out.push({
+ label: entry.name,
+ hint: label ? `Saved search · ${label} new` : 'Saved search',
+ run: () => App.savedSearches.apply(entry.id)
+ });
+ });
+ }
out.push({ label: 'Reload channel', hint: 'Refresh the current feed', run: () => { if (App.videos) App.videos.resetAndReload(); } });
out.push({ label: 'Open Menu', hint: 'Source · channel · filters', run: () => { if (App.ui) App.ui.toggleDrawer('menu'); } });
out.push({ label: 'Open Settings', hint: 'Preferences', run: () => { if (App.ui) App.ui.toggleDrawer('settings'); } });
diff --git a/frontend/js/main.js b/frontend/js/main.js
index 5279702..2489964 100644
--- a/frontend/js/main.js
+++ b/frontend/js/main.js
@@ -43,6 +43,12 @@ window.App = window.App || {};
await App.videos.loadVideos();
App.favorites.syncButtons();
+ // The grid is showing something now. If that something is one of the
+ // saved searches, the reader is looking at it -- so it stops being
+ // "new" from here, and the once-a-day check starts running.
+ App.savedSearches.syncViewed();
+ App.savedSearches.start();
+
// The UI above is rendered entirely from the last known status cached in
// localStorage, so startup never blocks on (or breaks because of) a slow
// or failing status endpoint. Now fetch fresh status in the background and
diff --git a/frontend/js/savedSearches.js b/frontend/js/savedSearches.js
new file mode 100644
index 0000000..96f8589
--- /dev/null
+++ b/frontend/js/savedSearches.js
@@ -0,0 +1,425 @@
+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();
+ });
+ };
+})();
diff --git a/frontend/js/state.js b/frontend/js/state.js
index 742a429..363eac9 100644
--- a/frontend/js/state.js
+++ b/frontend/js/state.js
@@ -25,7 +25,8 @@ App.constants = {
FAVORITES_SORT_KEY: 'favoritesSort',
PREFERRED_QUALITY_KEY: 'preferredQuality',
FEED_END_BEHAVIOR_KEY: 'feedEndBehavior',
- SEARCH_HISTORY_KEY: 'searchHistory'
+ SEARCH_HISTORY_KEY: 'searchHistory',
+ SAVED_SEARCHES_KEY: 'savedSearches'
};
// Lazily injects hls.js the first time a stream actually needs it. Sessions
diff --git a/frontend/js/ui.js b/frontend/js/ui.js
index ada8b3d..9c91cf0 100644
--- a/frontend/js/ui.js
+++ b/frontend/js/ui.js
@@ -472,6 +472,386 @@ App.ui = App.ui || {};
return { bind: bind, refresh: refresh, close: close, isOpen: isOpen, shown: () => offered.slice() };
})();
+ // ---------------------------------------------------------------------
+ // Saved searches
+ //
+ // The bookmark in the search bar keeps whatever is on screen -- source,
+ // channel, filters, query -- and the bookmark in the header opens the
+ // list of them. Each row carries what is new in it since it was last
+ // opened (App.savedSearches does the counting), and tapping a row puts
+ // that whole search back.
+ // ---------------------------------------------------------------------
+ App.ui.savedSearches = (function() {
+ let bound = false;
+ // A row with its rename field open, or its "remove?" question showing.
+ // Held here so a check landing mid-edit redraws around it rather than
+ // through it.
+ let pending = null;
+
+ const el = (id) => document.getElementById(id);
+ const panel = () => el('saved-panel');
+ const isOpen = () => !!panel() && panel().classList.contains('open');
+
+ const ago = function(stamp) {
+ if (!stamp) return 'never';
+ const seconds = Math.max(0, Math.round((Date.now() - stamp) / 1000));
+ if (seconds < 90) return 'just now';
+ const minutes = Math.round(seconds / 60);
+ if (minutes < 60) return `${minutes} min ago`;
+ const hours = Math.round(minutes / 60);
+ if (hours < 24) return `${hours} h ago`;
+ const days = Math.round(hours / 24);
+ return days === 1 ? 'yesterday' : `${days} days ago`;
+ };
+
+ // Where the search points, in one line: the channel, what the filters
+ // narrow it to, and the words themselves.
+ const metaLine = function(entry) {
+ const parts = [entry.channelName || entry.channelId];
+ if (entry.optionLabels && entry.optionLabels.length) {
+ parts.push(entry.optionLabels.slice(0, 3).join(' · '));
+ }
+ parts.push(entry.query ? `“${entry.query}”` : 'no search term');
+ return parts.join(' · ');
+ };
+
+ const stampLine = function(entry) {
+ if (entry.unreachable) return 'Could not be checked — the source did not answer.';
+ const checked = `Checked ${ago(entry.lastCheckedAt)}`;
+ if (!entry.newCount) return `${checked} · nothing new`;
+ const since = entry.lastViewedAt ? ` since you opened it ${ago(entry.lastViewedAt)}` : '';
+ const label = App.savedSearches.badgeLabel(entry.newCount);
+ return `${label} new${since} · ${checked.toLowerCase()}`;
+ };
+
+ const svgIcon = function(paths, extraClass) {
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ svg.setAttribute('viewBox', '0 0 24 24');
+ svg.setAttribute('aria-hidden', 'true');
+ svg.setAttribute('class', 'icon-line' + (extraClass ? ' ' + extraClass : ''));
+ paths.forEach((d) => {
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
+ path.setAttribute('d', d);
+ svg.appendChild(path);
+ });
+ return svg;
+ };
+
+ const RENAME_ICON = ['M4.5 19.5h3L19 8a2.1 2.1 0 0 0-3-3L4.5 16.5v3z'];
+ const DELETE_ICON = ['M5 7.5h14', 'M9.5 7.5V5.75h5V7.5', 'M7 7.5l.8 11.2h8.4L17 7.5'];
+
+ const toolButton = function(act, label, paths) {
+ const button = document.createElement('button');
+ button.type = 'button';
+ button.className = 'saved-tool';
+ button.dataset.act = act;
+ button.title = label;
+ button.setAttribute('aria-label', label);
+ button.appendChild(svgIcon(paths));
+ return button;
+ };
+
+ // A row mid-rename: the name becomes a field, and leaving it (Enter or
+ // blur) keeps what was typed. Escape puts the old name back -- the
+ // normal way round for an edit in place.
+ const renameField = function(entry, row) {
+ const field = document.createElement('input');
+ field.type = 'text';
+ field.className = 'saved-rename';
+ field.value = entry.name;
+ field.maxLength = 80;
+ field.setAttribute('aria-label', 'Name for this saved search');
+ let settled = false;
+ const commit = function(keep) {
+ if (settled) return;
+ settled = true;
+ pending = null;
+ if (keep) App.savedSearches.rename(entry.id, field.value);
+ else sync();
+ };
+ field.addEventListener('keydown', (event) => {
+ event.stopPropagation();
+ if (event.key === 'Enter') { event.preventDefault(); commit(true); }
+ else if (event.key === 'Escape') { event.preventDefault(); commit(false); }
+ });
+ field.addEventListener('blur', () => commit(true));
+ field.addEventListener('click', (event) => event.stopPropagation());
+ row.classList.add('is-renaming');
+ return field;
+ };
+
+ const confirmStrip = function(entry) {
+ const strip = document.createElement('div');
+ strip.className = 'saved-confirm';
+
+ const question = document.createElement('span');
+ question.className = 'saved-confirm-text';
+ question.textContent = 'Remove this saved search?';
+
+ const remove = document.createElement('button');
+ remove.type = 'button';
+ remove.className = 'saved-confirm-btn is-danger';
+ remove.textContent = 'Remove';
+ remove.addEventListener('click', () => {
+ pending = null;
+ App.savedSearches.remove(entry.id);
+ });
+
+ const keep = document.createElement('button');
+ keep.type = 'button';
+ keep.className = 'saved-confirm-btn';
+ keep.textContent = 'Keep';
+ keep.addEventListener('click', () => { pending = null; sync(); });
+
+ strip.appendChild(question);
+ strip.appendChild(keep);
+ strip.appendChild(remove);
+ return strip;
+ };
+
+ const buildRow = function(entry, index) {
+ const row = document.createElement('div');
+ row.className = 'saved-row';
+ row.dataset.id = entry.id;
+ row.style.setProperty('--i', String(index));
+ if (entry.unreachable) row.classList.add('is-unreachable');
+
+ const main = document.createElement('button');
+ main.type = 'button';
+ main.className = 'saved-row-main';
+
+ const count = document.createElement('span');
+ const label = App.savedSearches.badgeLabel(entry.newCount);
+ count.className = 'saved-count' + (label ? '' : ' is-quiet');
+ // The quiet state is a mark, not a number: a row with nothing new
+ // should not look like a row with something in it.
+ count.textContent = label || '·';
+ if (label) count.title = `${entry.newCount} new since you last opened this`;
+
+ const text = document.createElement('span');
+ text.className = 'saved-row-text';
+
+ const name = document.createElement('span');
+ name.className = 'saved-row-name';
+ name.textContent = entry.name;
+
+ const meta = document.createElement('span');
+ meta.className = 'saved-row-meta';
+ meta.textContent = metaLine(entry);
+
+ const stamp = document.createElement('span');
+ stamp.className = 'saved-row-stamp';
+ stamp.textContent = stampLine(entry);
+
+ text.appendChild(name);
+ text.appendChild(meta);
+ text.appendChild(stamp);
+ main.appendChild(count);
+ main.appendChild(text);
+ main.addEventListener('click', () => choose(entry.id));
+ row.appendChild(main);
+
+ if (pending && pending.id === entry.id && pending.mode === 'rename') {
+ const field = renameField(entry, row);
+ name.replaceWith(field);
+ main.disabled = true;
+ requestAnimationFrame(() => { field.focus(); field.select(); });
+ return row;
+ }
+
+ if (pending && pending.id === entry.id && pending.mode === 'delete') {
+ row.classList.add('is-confirming');
+ row.appendChild(confirmStrip(entry));
+ return row;
+ }
+
+ const tools = document.createElement('div');
+ tools.className = 'saved-row-tools';
+ const rename = toolButton('rename', `Rename ${entry.name}`, RENAME_ICON);
+ rename.addEventListener('click', () => { pending = { id: entry.id, mode: 'rename' }; render(); });
+ const remove = toolButton('delete', `Remove ${entry.name}`, DELETE_ICON);
+ remove.addEventListener('click', () => { pending = { id: entry.id, mode: 'delete' }; render(); });
+ tools.appendChild(rename);
+ tools.appendChild(remove);
+ row.appendChild(tools);
+ return row;
+ };
+
+ const render = function() {
+ const list = el('saved-list');
+ const empty = el('saved-empty');
+ const note = el('saved-head-note');
+ const add = el('saved-add-btn');
+ const check = el('saved-check-btn');
+ if (!list) return;
+
+ const entries = App.savedSearches.getAll();
+ list.textContent = '';
+ entries.forEach((entry, index) => list.appendChild(buildRow(entry, index)));
+ if (empty) empty.hidden = entries.length > 0;
+
+ if (note) {
+ const total = App.savedSearches.totalNew();
+ note.textContent = !entries.length ?
+ 'Keep a search once, come back to it in one tap.' :
+ (total ?
+ `${App.savedSearches.badgeLabel(total)} new since you last looked.` :
+ 'Checked once a day. Nothing new right now.');
+ }
+ if (check) {
+ check.disabled = !entries.length || App.savedSearches.isChecking();
+ check.textContent = App.savedSearches.isChecking() ? 'Checking…' : 'Check now';
+ }
+ if (add) {
+ const match = App.savedSearches.findMatching();
+ add.disabled = !!match || !App.savedSearches.current();
+ add.textContent = match ? `Already saved as “${match.name}”` : 'Save the current search';
+ }
+ };
+
+ const choose = function(id) {
+ close();
+ App.savedSearches.apply(id);
+ };
+
+ const open = function() {
+ const box = panel();
+ if (!box) return;
+ pending = null;
+ render();
+ box.classList.add('open');
+ box.setAttribute('aria-hidden', 'false');
+ const trigger = el('saved-searches-btn');
+ if (trigger) trigger.setAttribute('aria-expanded', 'true');
+ // Opening the list is as good a moment as any to find out whether
+ // anything is due -- the daily check may have been missed by a tab
+ // that was never left open that long.
+ App.savedSearches.check();
+ const closeBtn = el('saved-close-btn');
+ if (closeBtn) requestAnimationFrame(() => closeBtn.focus());
+ };
+
+ const close = function() {
+ const box = panel();
+ // Where the keyboard goes next: back to the button that opened
+ // this, not to the top of the page behind it.
+ const returnFocus = !!(box && box.contains(document.activeElement));
+ if (box) {
+ box.classList.remove('open');
+ box.setAttribute('aria-hidden', 'true');
+ }
+ pending = null;
+ const trigger = el('saved-searches-btn');
+ if (trigger) {
+ trigger.setAttribute('aria-expanded', 'false');
+ if (returnFocus) trigger.focus();
+ }
+ };
+
+ // The bookmark in the search bar: lit when what is on screen is
+ // already saved, and pressing it then takes it off the list again.
+ const toggleCurrent = function() {
+ const match = App.savedSearches.findMatching();
+ if (match) {
+ App.savedSearches.remove(match.id);
+ return;
+ }
+ const saved = App.savedSearches.saveCurrent();
+ if (!saved) return;
+ const button = el('search-save-btn');
+ if (button) {
+ button.classList.remove('is-just-saved');
+ // Restarted rather than left to run: saving twice in a row
+ // should flash twice.
+ void button.offsetWidth;
+ button.classList.add('is-just-saved');
+ }
+ };
+
+ // Keeps every piece of the feature showing the same thing: the
+ // bookmark's state, the header's count, and the list if it is open.
+ const sync = function() {
+ const button = el('search-save-btn');
+ if (button) {
+ const match = App.savedSearches.findMatching();
+ const available = !!App.savedSearches.current();
+ button.classList.toggle('is-saved', !!match);
+ button.setAttribute('aria-pressed', match ? 'true' : 'false');
+ button.disabled = !available;
+ const title = match ? `Saved as “${match.name}” — tap to remove` : 'Save this search';
+ button.title = title;
+ button.setAttribute('aria-label', title);
+ }
+
+ const badge = el('saved-searches-badge');
+ const trigger = el('saved-searches-btn');
+ if (badge) {
+ const total = App.savedSearches.totalNew();
+ const label = App.savedSearches.badgeLabel(total);
+ badge.textContent = label;
+ badge.hidden = !label;
+ if (trigger) {
+ const count = App.savedSearches.getAll().length;
+ trigger.setAttribute('aria-label', label ?
+ `Saved searches, ${total > App.savedSearches.NEW_CAP ? 'more than ' + App.savedSearches.NEW_CAP : total} new` :
+ (count ? `Saved searches (${count})` : 'Saved searches'));
+ }
+ }
+
+ // A rename in progress is the reader's text, not ours to redraw.
+ if (isOpen() && !(pending && pending.mode === 'rename')) render();
+ };
+
+ const bind = function() {
+ if (bound) return;
+ const box = panel();
+ const trigger = el('saved-searches-btn');
+ if (!box || !trigger) return;
+ bound = true;
+
+ trigger.addEventListener('click', () => { if (isOpen()) close(); else open(); });
+ const closeBtn = el('saved-close-btn');
+ if (closeBtn) closeBtn.addEventListener('click', close);
+ box.addEventListener('click', (event) => { if (event.target === box) close(); });
+
+ const saveBtn = el('search-save-btn');
+ if (saveBtn) saveBtn.addEventListener('click', toggleCurrent);
+
+ const add = el('saved-add-btn');
+ if (add) {
+ add.addEventListener('click', () => {
+ const saved = App.savedSearches.saveCurrent();
+ if (saved) pending = { id: saved.id, mode: 'rename' };
+ render();
+ });
+ }
+
+ const check = el('saved-check-btn');
+ if (check) {
+ check.addEventListener('click', () => {
+ App.savedSearches.check({ force: true });
+ render();
+ });
+ }
+
+ document.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape' && isOpen()) {
+ event.stopPropagation();
+ close();
+ }
+ });
+ };
+
+ return {
+ bind: bind,
+ sync: sync,
+ open: open,
+ close: close,
+ isOpen: isOpen,
+ toggleCurrent: toggleCurrent
+ };
+ })();
+
// ---------------------------------------------------------------------
// Channel picker
//
@@ -1043,6 +1423,10 @@ App.ui = App.ui || {};
App.videos.resetAndReload();
};
}
+
+ // Source, channel and filters are all part of what a saved search is,
+ // so whenever the menu redraws, so does the bookmark.
+ if (App.ui.savedSearches) App.ui.savedSearches.sync();
};
App.ui.renderFilters = function(container, session) {
@@ -1301,7 +1685,11 @@ App.ui = App.ui || {};
// debounce the actual reload so typing doesn't wipe the grid and
// fire a backend request on every keystroke.
App.ui.searchHistory.bind();
+ App.ui.savedSearches.bind();
searchInput.addEventListener('input', updateClearVisibility);
+ // The bookmark is about this search, so it follows the words as
+ // they are typed, not just the channel and filters.
+ searchInput.addEventListener('input', () => App.ui.savedSearches.sync());
searchInput.addEventListener('input', () => {
if (searchDebounce) clearTimeout(searchDebounce);
searchDebounce = setTimeout(() => {
diff --git a/frontend/js/videos.js b/frontend/js/videos.js
index 50aa170..7a979b7 100644
--- a/frontend/js/videos.js
+++ b/frontend/js/videos.js
@@ -1322,20 +1322,24 @@ App.videos = App.videos || {};
return (best || cards[0]).dataset.videoId || null;
};
+ // Puts a query in the box without searching for it. The clear button is
+ // kept in sync here rather than by re-dispatching an `input` event, which
+ // would re-trigger the debounced reload listener behind it.
+ App.videos.setSearchBox = function(value) {
+ const searchInput = document.getElementById('search-input');
+ if (!searchInput) return;
+ if (searchInput.value !== value) searchInput.value = value;
+ const clearBtn = document.getElementById('search-clear-btn');
+ if (clearBtn) {
+ const hasValue = searchInput.value.trim().length > 0;
+ clearBtn.classList.toggle('is-visible', hasValue);
+ clearBtn.disabled = !hasValue;
+ }
+ };
+
App.videos.handleSearch = function(value) {
if (typeof value === 'string') {
- const searchInput = document.getElementById('search-input');
- if (searchInput && searchInput.value !== value) {
- searchInput.value = value;
- }
- // Keep the clear button in sync without re-dispatching an `input`
- // event (which would re-trigger the debounced reload listener).
- const clearBtn = document.getElementById('search-clear-btn');
- if (searchInput && clearBtn) {
- const hasValue = searchInput.value.trim().length > 0;
- clearBtn.classList.toggle('is-visible', hasValue);
- clearBtn.disabled = !hasValue;
- }
+ App.videos.setSearchBox(value);
}
// Every search worth running is a search worth remembering: this is
// the one place they all come through -- the box, a tag, the command
diff --git a/tests/smoke_saved_searches.py b/tests/smoke_saved_searches.py
new file mode 100644
index 0000000..1d799b9
--- /dev/null
+++ b/tests/smoke_saved_searches.py
@@ -0,0 +1,323 @@
+#!/usr/bin/env python3
+"""A whole search -- source, channel, filters, query -- can be kept and come back to.
+
+Run against a locally running backend:
+
+ backend/main.py &
+ .venv/bin/python tests/smoke_saved_searches.py
+
+Both the status and the listing are fixtures, so the test owns what "new"
+means: it changes the ids the listing hands back and then asks the client how
+many of them it has never shown.
+"""
+import json
+import sys
+from playwright.sync_api import sync_playwright
+
+BASE = "http://127.0.0.1:5000/"
+SERVER = "https://fixture.test"
+CHANNEL = "clips"
+
+STATUS = {
+ "online": True,
+ "channels": [{
+ "id": CHANNEL,
+ "name": "Fixture Clips",
+ "description": "A channel that only exists for this test.",
+ "options": [{
+ "id": "sort",
+ "title": "Sort",
+ "options": [
+ {"id": "latest", "title": "Latest"},
+ {"id": "views", "title": "Most viewed"},
+ ],
+ }],
+ }],
+}
+
+SEED = """([server, channel]) => {
+ localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
+ localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
+ localStorage.removeItem('session');
+ localStorage.removeItem('searchHistory');
+ localStorage.removeItem('savedSearches');
+}"""
+
+SAVED = """() => JSON.parse(localStorage.getItem('savedSearches') || '[]')"""
+
+BADGE = """() => {
+ const badge = document.getElementById('saved-searches-badge');
+ return { hidden: badge.hidden, text: badge.textContent };
+}"""
+
+ROWS = """() => Array.from(document.querySelectorAll('#saved-panel .saved-row')).map((row) => ({
+ name: row.querySelector('.saved-row-name') ? row.querySelector('.saved-row-name').textContent : '',
+ count: row.querySelector('.saved-count').textContent,
+ quiet: row.querySelector('.saved-count').classList.contains('is-quiet'),
+ meta: row.querySelector('.saved-row-meta').textContent,
+ stamp: row.querySelector('.saved-row-stamp').textContent
+}))"""
+
+IS_SAVED = """() => document.getElementById('search-save-btn').classList.contains('is-saved')"""
+PANEL_OPEN = """() => document.getElementById('saved-panel').classList.contains('open')"""
+SESSION = """() => {
+ const s = JSON.parse(localStorage.getItem('session'));
+ return { channel: s.channel.id, sort: s.options.sort ? s.options.sort.id : null };
+}"""
+
+
+class Checks:
+ def __init__(self):
+ self.failed = 0
+
+ def ok(self, label, condition, detail=""):
+ mark = "PASS" if condition else "FAIL"
+ if not condition:
+ self.failed += 1
+ print(f" [{mark}] {label}" + (f" -- {detail}" if detail and not condition else ""))
+
+
+def main():
+ c = Checks()
+ asked = []
+ # Which ids the listing hands back. Shifting this window is how the test
+ # says "three new videos were uploaded".
+ offset = {"at": 0, "fail": False}
+
+ def items(query):
+ start = offset["at"]
+ return [{"id": f"vid-{i}", "title": f"{query or 'anything'} {i}",
+ "url": f"https://fixture.test/v{i}.mp4", "thumb": "", "channel": CHANNEL,
+ "duration": 1, "isLive": False, "tags": []}
+ for i in range(start, start + 12)]
+
+ with sync_playwright() as p:
+ browser = p.chromium.launch(args=[
+ "--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
+ "--renderer-process-limit=1", "--js-flags=--max-old-space-size=512",
+ ])
+ page = browser.new_page(viewport={"width": 1180, "height": 920})
+ crashes = []
+ page.on("pageerror", lambda e: crashes.append(str(e)))
+
+ def status(route):
+ route.fulfill(status=200, content_type="application/json", body=json.dumps(STATUS))
+
+ def listing(route):
+ try:
+ body = json.loads(route.request.post_data or "{}")
+ except Exception:
+ body = {}
+ asked.append(body)
+ if offset["fail"]:
+ route.fulfill(status=500, content_type="application/json", body="{}")
+ return
+ route.fulfill(status=200, content_type="application/json", body=json.dumps(
+ {"items": items(body.get("query", "")), "pageInfo": {"hasNextPage": False}}))
+
+ page.route("**/api/status", status)
+ page.route("**/api/videos", listing)
+ page.goto(BASE, wait_until="domcontentloaded")
+ page.evaluate(SEED, [SERVER, CHANNEL])
+ page.goto(BASE, wait_until="load")
+ page.wait_for_selector(".video-card", timeout=30000)
+ page.wait_for_timeout(400)
+
+ print("\nthe feature is there at all")
+ c.ok("the module loaded", page.evaluate("() => typeof App.savedSearches.saveCurrent") == 'function')
+ c.ok("with nothing saved, the header count is out", page.evaluate(BADGE)["hidden"] is True)
+ c.ok("and the bookmark is unlit", page.evaluate(IS_SAVED) is False)
+
+ print("\nkeeping the search that is on screen")
+ page.fill("#search-input", "")
+ page.type("#search-input", "sauna", delay=30)
+ page.wait_for_timeout(900)
+ page.click("#search-save-btn")
+ page.wait_for_timeout(200)
+ saved = page.evaluate(SAVED)
+ c.ok("one search was kept", len(saved) == 1, str(saved))
+ entry = saved[0] if saved else {}
+ c.ok("with the words in it", entry.get("query") == "sauna", str(entry.get("query")))
+ c.ok("the channel it was run against", entry.get("channelId") == CHANNEL, str(entry.get("channelId")))
+ c.ok("the source", entry.get("server") == SERVER, str(entry.get("server")))
+ c.ok("and the filters as they stood", entry.get("options") == {"sort": "latest"}, str(entry.get("options")))
+ c.ok("it is named after the search", entry.get("name") == "sauna", str(entry.get("name")))
+ c.ok("what is on screen counts as seen", len(entry.get("seenIds") or []) == 12, str(len(entry.get("seenIds") or [])))
+ c.ok("so nothing is new yet", entry.get("newCount") == 0 and page.evaluate(BADGE)["hidden"] is True)
+ c.ok("and the bookmark is lit", page.evaluate(IS_SAVED) is True)
+
+ print("\nthe bookmark is about this search, not just this channel")
+ page.fill("#search-input", "")
+ page.type("#search-input", "pool", delay=30)
+ page.wait_for_timeout(300)
+ c.ok("typing something else puts it out", page.evaluate(IS_SAVED) is False)
+ page.fill("#search-input", "")
+ page.type("#search-input", "sauna", delay=30)
+ page.wait_for_timeout(300)
+ c.ok("coming back to it lights it again", page.evaluate(IS_SAVED) is True)
+
+ print("\nand a filter is part of it too")
+ page.evaluate("""() => {
+ const s = JSON.parse(localStorage.getItem('session'));
+ s.options.sort = { id: 'views', title: 'Most viewed' };
+ localStorage.setItem('session', JSON.stringify(s));
+ App.ui.renderMenu();
+ }""")
+ page.wait_for_timeout(200)
+ c.ok("a different sort is a different search", page.evaluate(IS_SAVED) is False)
+
+ print("\nthree videos turn up while nobody is looking")
+ offset["at"] = 3
+ asked.clear()
+ page.evaluate("() => App.savedSearches.check({ force: true })")
+ page.wait_for_timeout(900)
+ saved = page.evaluate(SAVED)
+ c.ok("the check asked for the saved search, not what is on screen",
+ any(b.get("query") == "sauna" and b.get("sort") == "latest" for b in asked), str(asked))
+ c.ok("three of them are new", saved[0].get("newCount") == 3, str(saved[0].get("newCount")))
+ badge = page.evaluate(BADGE)
+ c.ok("the header says so", badge["hidden"] is False and badge["text"] == "3", str(badge))
+
+ print("\na busy day")
+ offset["at"] = 40
+ page.evaluate("() => App.savedSearches.check({ force: true })")
+ page.wait_for_timeout(900)
+ saved = page.evaluate(SAVED)
+ badge = page.evaluate(BADGE)
+ c.ok("every one of them is new", saved[0].get("newCount") == 12, str(saved[0].get("newCount")))
+ c.ok("the badge stops counting at 9+", badge["text"] == "9+", str(badge))
+
+ print("\nonce a day, not once a page view")
+ asked.clear()
+ page.evaluate("() => App.savedSearches.check()")
+ page.wait_for_timeout(500)
+ c.ok("a search checked a moment ago is left alone", asked == [], str(asked))
+ page.evaluate("""() => {
+ const all = JSON.parse(localStorage.getItem('savedSearches'));
+ all[0].lastCheckedAt = Date.now() - 2 * 24 * 60 * 60 * 1000;
+ localStorage.setItem('savedSearches', JSON.stringify(all));
+ }""")
+ page.evaluate("() => App.savedSearches.check()")
+ page.wait_for_timeout(900)
+ c.ok("one last checked two days ago is asked about",
+ any(b.get("query") == "sauna" for b in asked), str(asked))
+
+ print("\nthe list")
+ page.click("#saved-searches-btn")
+ page.wait_for_timeout(300)
+ rows = page.evaluate(ROWS)
+ c.ok("it opens", page.evaluate(PANEL_OPEN) is True)
+ c.ok("with the saved search in it", len(rows) == 1 and rows[0]["name"] == "sauna", str(rows))
+ c.ok("carrying its count", rows[0]["count"] == "9+" and rows[0]["quiet"] is False, str(rows[0]))
+ c.ok("and saying where it points",
+ "Fixture Clips" in rows[0]["meta"] and "Latest" in rows[0]["meta"] and "sauna" in rows[0]["meta"],
+ rows[0]["meta"])
+
+ print("\ntapping it puts the whole search back")
+ asked.clear()
+ page.click("#saved-panel .saved-row-main")
+ page.wait_for_timeout(1200)
+ c.ok("the list closes", page.evaluate(PANEL_OPEN) is False)
+ c.ok("the words are back in the box",
+ page.evaluate("() => document.getElementById('search-input').value") == "sauna")
+ session = page.evaluate(SESSION)
+ c.ok("the filter is back as it was", session == {"channel": CHANNEL, "sort": "latest"}, str(session))
+ c.ok("and that is what was fetched",
+ any(b.get("query") == "sauna" and b.get("sort") == "latest" for b in asked), str(asked))
+ saved = page.evaluate(SAVED)
+ c.ok("opening it is viewing it, so the count goes out",
+ saved[0].get("newCount") == 0 and page.evaluate(BADGE)["hidden"] is True, str(saved[0]))
+ c.ok("and what it showed is the new mark",
+ "vid-40" in (saved[0].get("seenIds") or []), str((saved[0].get("seenIds") or [])[:3]))
+ c.ok("so the bookmark is lit again", page.evaluate(IS_SAVED) is True)
+
+ print("\nnothing new, said quietly")
+ page.click("#saved-searches-btn")
+ page.wait_for_timeout(300)
+ rows = page.evaluate(ROWS)
+ c.ok("no number where there is no news",
+ rows[0]["quiet"] is True and "nothing new" in rows[0]["stamp"].lower(), str(rows[0]))
+
+ print("\nrenaming one")
+ page.click("#saved-panel .saved-tool[data-act='rename']")
+ page.wait_for_timeout(250)
+ page.fill("#saved-panel .saved-rename", "Sauna, newest first")
+ page.keyboard.press("Enter")
+ page.wait_for_timeout(300)
+ saved = page.evaluate(SAVED)
+ c.ok("the new name is kept", saved[0].get("name") == "Sauna, newest first", str(saved[0].get("name")))
+ c.ok("and shown", page.evaluate(ROWS)[0]["name"] == "Sauna, newest first")
+
+ print("\nsaving a second one from the list itself")
+ page.evaluate("() => App.ui.savedSearches.close()")
+ page.fill("#search-input", "")
+ page.type("#search-input", "steam", delay=30)
+ page.wait_for_timeout(900)
+ page.click("#saved-searches-btn")
+ page.wait_for_timeout(250)
+ page.click("#saved-add-btn")
+ page.wait_for_timeout(300)
+ saved = page.evaluate(SAVED)
+ c.ok("there are two now", len(saved) == 2, str([s.get("name") for s in saved]))
+ c.ok("the new one is first and named after its query",
+ saved[0].get("query") == "steam", str(saved[0].get("name")))
+ c.ok("and it opens ready to be named",
+ page.evaluate("() => !!document.querySelector('#saved-panel .saved-rename')"))
+ page.keyboard.press("Escape")
+ page.wait_for_timeout(200)
+
+ print("\nremoving one asks first")
+ page.click("#saved-panel .saved-row:nth-of-type(1) .saved-tool[data-act='delete']")
+ page.wait_for_timeout(250)
+ c.ok("the row asks", page.evaluate(
+ "() => !!document.querySelector('#saved-panel .saved-confirm')"))
+ page.click("#saved-panel .saved-confirm-btn:not(.is-danger)")
+ page.wait_for_timeout(250)
+ c.ok("keeping keeps it", len(page.evaluate(SAVED)) == 2)
+ page.click("#saved-panel .saved-row:nth-of-type(1) .saved-tool[data-act='delete']")
+ page.wait_for_timeout(250)
+ page.click("#saved-panel .saved-confirm-btn.is-danger")
+ page.wait_for_timeout(250)
+ saved = page.evaluate(SAVED)
+ c.ok("removing removes it", len(saved) == 1 and saved[0].get("query") == "sauna", str(saved))
+
+ print("\na source that does not answer")
+ page.evaluate("""() => {
+ const all = JSON.parse(localStorage.getItem('savedSearches'));
+ all[0].newCount = 0;
+ localStorage.setItem('savedSearches', JSON.stringify(all));
+ }""")
+ offset["fail"] = True
+ page.evaluate("() => App.savedSearches.check({ force: true })")
+ page.wait_for_timeout(1200)
+ saved = page.evaluate(SAVED)
+ c.ok("is not a search with nothing new in it",
+ saved[0].get("unreachable") is True and saved[0].get("newCount") == 0, str(saved[0]))
+ rows = page.evaluate(ROWS)
+ c.ok("and the row says as much", "could not be checked" in rows[0]["stamp"].lower(), str(rows[0]["stamp"]))
+ offset["fail"] = False
+
+ print("\nacross a reload")
+ page.evaluate("""() => {
+ const all = JSON.parse(localStorage.getItem('savedSearches'));
+ all[0].newCount = 4;
+ all[0].unreachable = false;
+ all[0].lastCheckedAt = Date.now();
+ localStorage.setItem('savedSearches', JSON.stringify(all));
+ }""")
+ page.goto(BASE, wait_until="load")
+ page.wait_for_selector(".video-card", timeout=30000)
+ page.wait_for_timeout(600)
+ badge = page.evaluate(BADGE)
+ c.ok("the count is on screen straight from storage",
+ badge["hidden"] is False and badge["text"] == "4", str(badge))
+ c.ok("the search survived too", len(page.evaluate(SAVED)) == 1)
+
+ c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
+ browser.close()
+
+ print(f"\n{'OK' if not c.failed else 'FAILED'}: {c.failed} check(s) failed")
+ return 1 if c.failed else 0
+
+
+sys.exit(main())