Compare commits

5 Commits

Author SHA1 Message Date
Simon
ba6045a2b1 Keep a favorite's formats when saving it
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-11-02 22:50:40 +00:00
Simon
93463bce3a Ask the provider again for a favorite yt-dlp can't resolve
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-11-02 22:29:00 +00:00
Simon
2fcc17877d Let saved searches from different sources be read as one
A saved search is one source, one channel on it, that channel's filters
and the words. Two of them on different sources had no way to be read
together, and a channel group is not it: every channel in a group belongs
to the same server and shares one query, where the interesting pair is
"uncensored JAV over here" and "amateur 1080p over there".

So a saved search can now hold several of those instead of one. Opening it
feeds a single grid from all of them, taken alternately -- one from each in
turn, the way a group zips its channels -- rather than one source's page
and then the next. Each member keeps its own source, channel, filters and
words, because having its own words is most of why it was worth saving
separately.

Every entry is read as a list of members, a plain search being a merge of
one. That is what lets the daily check, mark-all-read and the bookmark go
on working without learning what a merge is: they ask each member and zip
the answers, so the badge counts what the grid would have shown.

The search box is turned off while a merge is open and says why -- there
is nothing for one box to say for several members. A tag or an uploader
tapped on a card steps out of the merge onto one real channel instead,
since otherwise it would reload the same videos and look broken.

A merge answers to no single server, so it is not stored as a channel
preference and is not looked up from a server's status: its members ride
on the session, and a reload rebuilds it from the saved search so a rename
is picked up rather than frozen in. One source failing drops that member
and leaves the rest readable. Merging a merge flattens it, so "the
searches in it" means the same however it was assembled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-11-01 07:59:12 +00:00
Simon
c60f5e01dc Re-anchor a moving reel on where it is, not what it was playing
A viewport change re-anchors the reel on the video that is playing,
resolved by id rather than by a scroll position the change has already
invalidated. That is what keeps a rotation on the same video.

Mid-swipe it names the wrong one. setActive runs from onScroll's next
frame, so a resize landing before that frame still reads the video being
swiped *away from* -- and realignToActive writes its position straight to
scrollTop, hauling the reel back to it. A phone slides its chrome on every
scroll, so the resize lands mid-swipe on nearly every swipe: the reel went
where the thumb put it, far enough to show the top of the next video at
the bottom of the screen, and was then written back a beat later.

The input guard in onScroll could not catch this. There is no scroll event
to guard -- the write happens inside the resize.

So while the reader is demonstrably driving, take the reel's own position
as the authority and re-anchor on the step it is nearest at the new
height. It still lands on a snap point, so nothing is left straddling two
screens, but the swipe stands. Only a step's worth of disagreement counts
as theirs: a scroller just re-parented into or out of the
picture-in-picture window reads 0, and following that would land them on
the first video instead of their own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-10-27 22:26:39 +00:00
Simon
7033288bc4 Hold the slides for the whole turn, not until it first pauses
The slides are held at the size a turn is heading for so the picture steps
once, straight into the shape it keeps, instead of being re-letterboxed
through every shape the viewport passes on its way round.

The hold came off once the height had gone six frames unchanged, which
stood in for "the viewport has stopped moving". It does not: a turn
arrives in steps, and the gap between two of them is routinely longer
than six frames -- on a phone and in a loaded tab alike. So the pin came
off mid-turn, the slides took an intermediate shape, and the picture
jumped twice on the way round. That is the stutter the pin exists to
remove, and TURN_HOLD_FRAMES was meant to be "long enough that a viewport
animating round in steps is not mistaken for one that has arrived" -- six
frames is not.

The device does say when a turn is underway, so believe it: while the
announcement stands the pin holds, and the frame count is left to end the
pins nothing announced, where there is no signal to go on and no turn to
ruin. The realign window has to stop concluding mid-turn too, since
closing it releases the pin by the other door; SETTLE_MAX_MS outlasts the
announcement and still guarantees both of them end.

The cost is that an announced turn keeps the old size until the
announcement lapses rather than letting go the moment the viewport looks
still. That buys the one step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-10-27 22:26:20 +00:00
12 changed files with 1068 additions and 66 deletions

View File

@@ -160,6 +160,13 @@ body.theme-light .top-bar {
color: var(--text-secondary);
}
/* A merge reads its members' own words, so the box has nothing to say for it.
Dimmed rather than hidden: the placeholder is where it says why. */
.search-container input.is-inert {
opacity: 0.6;
cursor: default;
}
.search-clear-btn {
position: absolute;
top: 50%;
@@ -3337,6 +3344,22 @@ body.theme-light .saved-badge { box-shadow: 0 0 0 2px rgba(246, 241, 231, 0.95);
.saved-row.is-unreachable .saved-count { border-style: dashed; }
/* Picking rows to merge. The count becomes a tick box: empty until the row is
chosen, so a glance down the list says which ones are going in. */
.saved-count.saved-pick {
border-color: var(--border-hover);
background: transparent;
color: var(--accent);
font-size: 15px;
}
.saved-row.is-picked .saved-count.saved-pick {
background: rgba(201, 165, 103, 0.16);
border-color: var(--accent);
}
.saved-row.is-picked { background: var(--bg-tertiary); }
.saved-row-text {
min-width: 0;
display: flex;

View File

@@ -259,6 +259,7 @@
<p class="saved-head-note" id="saved-head-note"></p>
</div>
<div class="saved-head-actions">
<button id="saved-select-btn" class="btn-link" type="button">Merge…</button>
<button id="saved-read-btn" class="btn-link" type="button">Mark all read</button>
<button id="saved-check-btn" class="btn-link" type="button">Check now</button>
<button id="saved-close-btn" class="close-btn" type="button" aria-label="Close">✕</button>
@@ -270,6 +271,7 @@
then press the bookmark in the search bar to keep the whole thing.
</div>
<div class="saved-foot">
<button id="saved-merge-btn" class="btn-secondary saved-add" type="button" hidden>Merge selected</button>
<button id="saved-add-btn" class="btn-secondary saved-add" type="button">Save the current search</button>
</div>
</div>

View File

@@ -16,13 +16,9 @@ App.favorites = App.favorites || {};
const raw = localStorage.getItem(FAVORITES_KEY);
const parsed = raw ? JSON.parse(raw) : [];
if (!Array.isArray(parsed)) return [];
// Two things are repaired on the way in, and written back once if
// One thing is repaired on the way in, and written back once if
// anything changed, so the fix happens exactly one time:
//
// `meta`, from older versions, is a blob of resolved formats whose
// URLs are signed and long expired -- dropped so no code path can
// reach for one; everything re-resolves from `url` at play time.
//
// `favoriteDate` didn't exist before sorting needed it. There's no
// way to recover when an old favorite was actually saved, so it
// gets now: they sort together, as one batch, at the point the
@@ -31,10 +27,9 @@ App.favorites = App.favorites || {};
const now = new Date().toISOString();
const items = parsed.map((item) => {
if (!item || typeof item !== 'object') return item;
if (!item.meta && item.favoriteDate) return item;
if (item.favoriteDate) return item;
const clean = Object.assign({}, item);
delete clean.meta;
if (!clean.favoriteDate) clean.favoriteDate = now;
clean.favoriteDate = now;
repaired = true;
return clean;
});
@@ -103,6 +98,18 @@ App.favorites = App.favorites || {};
return video.key || meta.key || video.id || meta.id || video.url || meta.url || null;
};
const formatsOf = function(video, meta) {
const src = Array.isArray(video.formats) && video.formats.length ? video
: (meta && Array.isArray(meta.formats) && meta.formats.length ? meta : null);
if (!src) return undefined;
return {
url: src.url || video.url || '',
formats: src.formats,
http_headers: src.http_headers || {},
isLive: !!(src.isLive || video.isLive)
};
};
App.favorites.normalize = function(video) {
const key = App.favorites.getKey(video);
if (!key) return null;
@@ -110,9 +117,7 @@ App.favorites = App.favorites || {};
return {
key,
id: video.id || null,
// The page/source URL (e.g. the YouTube watch URL), not a resolved
// CDN media URL -- those expire, so favorites must always re-resolve
// via the server at play time instead of caching a stream link.
// The page/source URL (e.g. the YouTube watch URL).
url: video.url || (meta && meta.url) || '',
title: video.title || '',
thumb: video.thumb || '',
@@ -122,13 +127,10 @@ App.favorites = App.favorites || {};
isLive: !!(video.isLive || (meta && meta.isLive)),
// When it was saved. An import carries the date the other client
// recorded; anything saved here is saved now.
favoriteDate: video.favoriteDate || new Date().toISOString()
// No `meta` field: persisting resolved formats would freeze their
// (expiring) CDN URLs into localStorage. Leaving it unset makes a
// favorite look like a fresh, unresolved listing item again, so
// playback/download/info all re-resolve through the backend from
// `url` -- see resolveStreamSources' no-formats fallback, which the
// backend resolves live via yt-dlp (main.py stream_video).
favoriteDate: video.favoriteDate || new Date().toISOString(),
// The formats the video was saved with, so it plays without being
// resolved again. Only when there are some.
meta: formatsOf(video, meta)
};
};
@@ -251,7 +253,7 @@ App.favorites = App.favorites || {};
'uploader', 'duration', 'isLive', 'favoriteDate'];
const sameEntry = function(a, b) {
return ENTRY_FIELDS.every((field) =>
return JSON.stringify(a.meta || null) === JSON.stringify(b.meta || null) && ENTRY_FIELDS.every((field) =>
String(a[field] === undefined || a[field] === null ? '' : a[field]) ===
String(b[field] === undefined || b[field] === null ? '' : b[field]));
};
@@ -499,9 +501,6 @@ App.favorites = App.favorites || {};
card.onclick = () => {
if (card.classList.contains('is-loading')) return;
card.classList.add('is-loading');
// Ignore any stale `meta` a favorite saved before this fix may
// still carry in localStorage -- always re-resolve from `item`
// (id/url) so playback never reuses an expired stream URL.
App.player.open(item, { originEl: card });
};
const favoriteBtn = card.querySelector('.favorite-btn');
@@ -525,9 +524,6 @@ App.favorites = App.favorites || {};
showInfoBtn.onclick = (event) => {
event.stopPropagation();
App.videos.closeAllMenus();
// Favorites deliberately store no resolved metadata; the
// panel opens on what the entry holds and resolves the rest
// itself.
App.ui.openInfo(item);
};
}

View File

@@ -1413,16 +1413,40 @@ App.feed = App.feed || {};
// video playing/focused rather than snapping to a neighbour.
const realignToActive = function() {
if (stepCount() === 0) return;
const h = slideHeight();
const scroller = getScroller();
// Anchoring by id is what keeps a rotation on the same video, because
// the scroll position it would otherwise read has been invalidated by
// the very change being answered. But mid-swipe that id names the video
// being *left*: setActive runs from onScroll's next frame, so a resize
// landing before it still reads the old one -- and writing its position
// hauls the reel back to it. A phone slides its chrome on every scroll,
// so that happened on nearly every swipe.
//
// While the reader is demonstrably driving, the reel's own position is
// the better authority: re-anchor on the step it is nearest at the new
// height. That still puts it on a snap point -- no slide left straddling
// two screens -- without undoing the swipe.
let index = state.feedActiveIndex;
if (state.feedActiveVideoId != null) {
const found = (state.loadedVideos || [])
.findIndex((v) => String(v.id) === String(state.feedActiveVideoId));
if (found >= 0) index = Math.floor(found / paneCount());
}
const driving = Date.now() - lastInputAt < INPUT_GRACE_MS;
if (driving && scroller && h) {
// A swipe moves by a step, so only a step's worth of disagreement
// is the reader's doing. Anything further apart is the position
// being meaningless rather than ahead -- a scroller that has just
// been re-parented into (or out of) the picture-in-picture window
// reads 0, and following that would land the reel on the first
// video instead of theirs.
const atScroll = clampIndex(Math.round(scroller.scrollTop / h));
if (atScroll >= 0 && Math.abs(atScroll - index) <= 1) index = atScroll;
}
index = clampIndex(index);
if (index < 0) return;
state.feedActiveIndex = index;
const h = slideHeight();
const start = windowBounds(index).start;
// Only write what actually differs. This runs on every frame of a
// turn, and a spacer height or a scrollTop written back unchanged
@@ -1431,7 +1455,6 @@ App.feed = App.feed || {};
const spacer = getTopSpacer();
const spacerHeight = `${start * h}px`;
if (spacer && spacer.style.height !== spacerHeight) spacer.style.height = spacerHeight;
const scroller = getScroller();
const top = index * h;
if (scroller && Math.abs(scroller.scrollTop - top) > 0.5) scroller.scrollTop = top;
const activeSlide = slidesByIndex.get(index);
@@ -1492,15 +1515,29 @@ App.feed = App.feed || {};
if (!turnPin && !turnResolved && isTurn(settleFromShape, shape)) {
pinTurn(settleFromShape);
}
// A turn the device announced is not over because the viewport paused.
// It arrives in steps, and the gap between two of them is routinely
// longer than the handful of frames that stands in for "stopped" --
// which is how the pin came off mid-turn and let the picture take an
// intermediate shape, the one thing it is here to prevent. So while
// the announcement stands the pin holds, and the frame count is left
// to end the pins nothing announced: a window dragged across the
// square, where there is no signal to go on and no turn to ruin.
const announced = Date.now() - turnSignalAt < TURN_SIGNAL_MS;
// The viewport has stopped moving, so what it is now is better than
// what the pin guessed it would be. Letting go here, with the realign
// below in the same frame, is the one step the picture takes.
if (turnPin && settleStableFrames >= TURN_HOLD_FRAMES) {
if (turnPin && !announced && settleStableFrames >= TURN_HOLD_FRAMES) {
releaseTurn();
turnResolved = true;
}
realignToActive();
if (settleStableFrames < SETTLE_STABLE_FRAMES &&
// The window may not conclude mid-turn either: closing it releases the
// pin on the way out, which would put the picture through the same
// intermediate shape by the other door. An announced turn therefore
// holds the window open too, and SETTLE_MAX_MS -- longer than the
// announcement -- is what still guarantees it ends.
if ((announced || settleStableFrames < SETTLE_STABLE_FRAMES) &&
(Date.now() - settleStartedAt) < SETTLE_MAX_MS) {
resizeSettleRaf = requestAnimationFrame(settleStep);
return;

View File

@@ -31,6 +31,8 @@ App.savedSearches = App.savedSearches || {};
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;
@@ -48,9 +50,44 @@ App.savedSearches = App.savedSearches || {};
// ---- 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 || !entry.server || !entry.channelId) 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',
@@ -90,6 +127,27 @@ App.savedSearches = App.savedSearches || {};
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.
@@ -126,6 +184,25 @@ App.savedSearches = App.savedSearches || {};
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,
@@ -137,19 +214,29 @@ App.savedSearches = App.savedSearches || {};
};
};
// 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 || {};
// 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 [desc.server, desc.channelId, (desc.query || '').toLowerCase().trim()]
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() {
@@ -162,10 +249,19 @@ App.savedSearches = App.savedSearches || {};
// 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(' · ');
const narrowed = (desc.optionLabels || []).slice(0, 2).join(' · ');
return narrowed ? `${channel} · ${narrowed}` : channel;
};
@@ -213,6 +309,61 @@ App.savedSearches = App.savedSearches || {};
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();
@@ -256,20 +407,34 @@ App.savedSearches = App.savedSearches || {};
// 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) {
// 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 === entry.server);
.find((candidate) => candidate.url === member.server);
const serverData = serverEntry ? serverEntry.data : null;
const channel = serverData ? App.session.resolveChannelById(serverData, entry.channelId) : 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, perPage, extra) {
const ask = async function(channelId, size, extra) {
const body = Object.assign({
channel: channelId,
query: entry.query || '',
query: member.query || '',
page: 1,
perPage: perPage,
server: entry.server
perPage: size,
server: member.server
}, extra || {});
const response = await fetch('/api/videos', {
method: 'POST',
@@ -284,26 +449,36 @@ App.savedSearches = App.savedSearches || {};
};
if (channel.isGroup) {
const selected = entry.options ? entry.options[App.session.GROUP_CHANNELS_OPTION_ID] : null;
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 [];
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;
return interleave(await Promise.all(
enabled.map((id) => ask(id, GROUP_PROBE_PER_PAGE))));
}
const extra = {};
Object.entries(entry.options || {}).forEach(([key, value]) => {
Object.entries(member.options || {}).forEach(([key, value]) => {
extra[key] = Array.isArray(value) ? value.join(', ') : value;
});
return ask(entry.channelId, PROBE_PER_PAGE, extra);
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
@@ -441,6 +616,53 @@ App.savedSearches = App.savedSearches || {};
// ---- 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
@@ -449,6 +671,8 @@ App.savedSearches = App.savedSearches || {};
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) {

View File

@@ -15,7 +15,8 @@ App.state = {
feedMuted: true,
feedActiveIndex: -1,
feedActiveVideoId: null,
groupCursors: null
groupCursors: null,
mergedCursors: null
};
// Local storage keys used across modules.

View File

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

View File

@@ -511,7 +511,25 @@ App.ui = App.ui || {};
// Where the search points, in one line: the channel, what the filters
// narrow it to, and the words themselves.
const hostOf = function(url) {
try { return new URL(url).host; } catch (err) { return String(url || ''); }
};
const metaLine = function(entry) {
// A merge has no channel or words of its own -- what it is, is the
// searches in it, so those are what the row says. Each one names
// where it looks and which source it looks on, because two members
// differing only by source is the whole reason to merge.
if (App.savedSearches.isMerged(entry)) {
const shown = entry.members.slice(0, 3).map((member) => {
const where = member.channelName || member.channelId;
const words = member.query ? ` “${member.query}”` : '';
return `${where}${words} · ${hostOf(member.server)}`;
});
const rest = entry.members.length - shown.length;
const tail = rest > 0 ? ` + ${rest} more` : '';
return `${entry.members.length} searches · ${shown.join(' + ')}${tail}`;
}
const parts = [entry.channelName || entry.channelId];
if (entry.optionLabels && entry.optionLabels.length) {
parts.push(entry.optionLabels.slice(0, 3).join(' · '));
@@ -614,10 +632,18 @@ App.ui = App.ui || {};
return strip;
};
// Which of the three shapes a row is in. A row only has to be built
// again when it changes shape; otherwise the one on screen is updated
// where it stands (see render).
// Picking rows to merge. A merge is made here rather than from a row's
// own menu because it is about several rows at once -- and because
// "which ones" is the whole question.
let selecting = false;
let picked = new Set();
// Which shape a row is in. A row only has to be built again when it
// changes shape; otherwise the one on screen is updated where it
// stands (see render). Being picked is a shape too: the row swaps its
// count for a tick and its tools for nothing.
const modeOf = function(entry) {
if (selecting) return picked.has(entry.id) ? 'pick-on' : 'pick-off';
return (pending && pending.id === entry.id) ? pending.mode : 'plain';
};
@@ -662,9 +688,30 @@ App.ui = App.ui || {};
text.appendChild(stamp);
main.appendChild(count);
main.appendChild(text);
main.addEventListener('click', () => choose(entry.id));
main.addEventListener('click', () => {
if (selecting) {
if (picked.has(entry.id)) picked.delete(entry.id);
else picked.add(entry.id);
render();
return;
}
choose(entry.id);
});
row.appendChild(main);
// While picking, a tap means "this one too" rather than "open
// this", so the row says so: a tick where the count was, and no
// rename or remove to catch the thumb on the way past.
if (selecting) {
row.classList.add('is-selecting');
row.classList.toggle('is-picked', picked.has(entry.id));
count.classList.remove('is-quiet');
count.classList.add('saved-pick');
count.textContent = picked.has(entry.id) ? '✓' : '';
count.removeAttribute('title');
return row;
}
if (pending && pending.id === entry.id && pending.mode === 'rename') {
const field = renameField(entry, row);
name.replaceWith(field);
@@ -737,6 +784,8 @@ App.ui = App.ui || {};
const add = el('saved-add-btn');
const check = el('saved-check-btn');
const read = el('saved-read-btn');
const select = el('saved-select-btn');
const mergeBtn = el('saved-merge-btn');
if (!list) return;
const entries = App.savedSearches.getAll();
@@ -781,13 +830,33 @@ App.ui = App.ui || {};
read.disabled = marking || !App.savedSearches.totalNew();
read.textContent = marking ? 'Marking…' : 'Mark all read';
}
if (select) {
// There is nothing to merge with fewer than two of them.
select.hidden = entries.length < 2;
select.textContent = selecting ? 'Cancel' : 'Merge…';
}
if (mergeBtn) {
mergeBtn.hidden = !selecting;
mergeBtn.disabled = picked.size < 2;
mergeBtn.textContent = picked.size < 2
? 'Pick two or more to merge'
: `Merge ${picked.size} selected`;
}
if (add) {
// While picking, the merge is the only action that means
// anything down here.
add.hidden = selecting;
const match = App.savedSearches.findMatching();
add.disabled = !!match || !App.savedSearches.current();
add.textContent = match ? `Already saved as “${match.name}”` : 'Save the current search';
}
};
const stopSelecting = function() {
selecting = false;
picked = new Set();
};
const choose = function(id) {
close();
App.savedSearches.apply(id);
@@ -797,6 +866,7 @@ App.ui = App.ui || {};
const box = panel();
if (!box) return;
pending = null;
stopSelecting();
render();
box.classList.add('open');
box.setAttribute('aria-hidden', 'false');
@@ -820,6 +890,7 @@ App.ui = App.ui || {};
box.setAttribute('aria-hidden', 'true');
}
pending = null;
stopSelecting();
const trigger = el('saved-searches-btn');
if (trigger) {
trigger.setAttribute('aria-expanded', 'false');
@@ -905,6 +976,32 @@ App.ui = App.ui || {};
});
}
const select = el('saved-select-btn');
if (select) {
select.addEventListener('click', () => {
if (selecting) stopSelecting();
else { selecting = true; picked = new Set(); pending = null; }
render();
});
}
const mergeBtn = el('saved-merge-btn');
if (mergeBtn) {
mergeBtn.addEventListener('click', () => {
// In the order they are listed, not the order they were
// ticked: the list is the reader's own arrangement.
const order = App.savedSearches.getAll()
.map((entry) => entry.id)
.filter((id) => picked.has(id));
const made = App.savedSearches.merge(order);
stopSelecting();
// Straight into renaming it: a merge's own name is the
// only thing about it nobody has said yet.
if (made) pending = { id: made.id, mode: 'rename' };
render();
});
}
const check = el('saved-check-btn');
if (check) {
check.addEventListener('click', () => {
@@ -1285,8 +1382,36 @@ App.ui = App.ui || {};
})();
// Settings + menu rendering.
// A merge carries its members' own words, and one box cannot stand for
// several (see fetchMergedBatch, which does not read it). So rather than
// leave a live-looking box that changes nothing, it is turned off and says
// why. Switching to any single channel turns it back on.
let searchPlaceholder = null;
App.ui.syncSearchForSession = function(session) {
const searchInput = document.getElementById('search-input');
if (!searchInput) return;
// Kept from the markup the first time through, so putting it back does
// not depend on remembering what it said.
if (searchPlaceholder === null) searchPlaceholder = searchInput.placeholder;
const merged = !!(session && session.channel && session.channel.isMerged);
searchInput.disabled = merged;
searchInput.classList.toggle('is-inert', merged);
if (merged) {
searchInput.value = '';
searchInput.placeholder = 'Each search in this merge keeps its own words';
} else {
searchInput.placeholder = searchPlaceholder;
}
const clearBtn = document.getElementById('search-clear-btn');
if (clearBtn && merged) {
clearBtn.classList.remove('is-visible');
clearBtn.disabled = true;
}
};
App.ui.renderMenu = function() {
const session = App.storage.getSession();
App.ui.syncSearchForSession(session);
const serverEntries = App.storage.getServerEntries();
const sourceSelect = document.getElementById('source-select');
const filtersContainer = document.getElementById('filters-container');

View File

@@ -755,6 +755,94 @@ App.videos = App.videos || {};
};
};
// Fetches one page from every search in a merge and zips them together,
// the way a group does its channels -- except that a merge's members share
// nothing. Each one carries its own server, channel, filters and words, so
// each gets its own request built from itself rather than from the session.
//
// The search box is deliberately not consulted: a merge is several
// searches, each with its own words, and one box cannot speak for all of
// them (see applyMerged).
const fetchMergedBatch = async function(session, signal) {
const merged = session.channel;
const all = Array.isArray(merged.members) ? merged.members : [];
// The per-merge "Searches" multi-select, so a member can be switched
// off while browsing. Falls back to all of them when nothing is
// selected.
const selected = session.options ? session.options[App.session.MERGED_MEMBERS_OPTION_ID] : null;
const enabledIds = Array.isArray(selected) && selected.length > 0
? selected.map((opt) => opt.id)
: all.map((member) => member.memberId);
const members = all.filter((member) => enabledIds.includes(member.memberId));
const signature = members.map((member) => member.memberId).join(',');
if (!state.mergedCursors || state.mergedCursors.mergeId !== merged.id ||
state.mergedCursors.signature !== signature) {
state.mergedCursors = {
mergeId: merged.id,
signature: signature,
members: members.map((member) => ({ member: member, page: 1, hasNextPage: true }))
};
}
const active = state.mergedCursors.members.filter((cursor) => cursor.hasNextPage);
if (active.length === 0) {
return { items: [], hasNextPage: false };
}
const results = await Promise.all(active.map(async (cursor) => {
const member = cursor.member;
const page = claimPage(cursor);
const body = {
channel: member.channelId,
query: member.query || '',
page: page,
perPage: GROUP_CHANNEL_PAGE_SIZE,
server: member.server
};
Object.entries(member.options || {}).forEach(([key, value]) => {
body[key] = Array.isArray(value) ? value.join(', ') : value;
});
try {
const response = await fetch('/api/videos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: signal
});
const data = await response.json();
const items = data && Array.isArray(data.items) ? data.items : [];
cursor.hasNextPage = items.length > 0 &&
(data && data.pageInfo ? data.pageInfo.hasNextPage !== false : true);
return items;
} catch (err) {
returnPage(cursor, page);
// One source failing is not the merge failing: the others
// still have pages, and a merge that drops to one member is
// still worth reading. An aborted load is the exception --
// that is the grid being reset under us, and it has to
// propagate or the reset races the batch it cancelled.
if (err && err.name === 'AbortError') throw err;
cursor.hasNextPage = false;
return [];
}
}));
const interleaved = [];
const maxLen = results.reduce((max, items) => Math.max(max, items.length), 0);
for (let i = 0; i < maxLen; i++) {
results.forEach((items) => {
if (items[i]) interleaved.push(items[i]);
});
}
return {
items: interleaved,
hasNextPage: state.mergedCursors.members.some((cursor) => cursor.hasNextPage)
};
};
const fetchChannelBatch = async function(session, signal) {
const searchInput = document.getElementById('search-input');
const query = searchInput ? searchInput.value : "";
@@ -796,6 +884,7 @@ App.videos = App.videos || {};
};
const fetchNextBatch = function(session, signal) {
if (session.channel.isMerged) return fetchMergedBatch(session, signal);
return session.channel.isGroup
? fetchGroupBatch(session, signal)
: fetchChannelBatch(session, signal);
@@ -1398,7 +1487,43 @@ App.videos = App.videos || {};
}
};
// Steps out of a merge onto one real channel: its first member whose
// source is still set up. Returns false when none of them is, in which
// case the merge is left alone -- there is nothing better to show.
App.videos.leaveMerge = function() {
const session = App.storage.getSession();
if (!session || !session.channel || !session.channel.isMerged) return false;
const entries = App.storage.getServerEntries();
for (const member of (session.channel.members || [])) {
const serverEntry = entries.find((candidate) => candidate.url === member.server);
const data = serverEntry ? serverEntry.data : null;
const channel = data ? App.session.resolveChannelById(data, member.channelId) : null;
if (!channel) continue;
const next = {
server: member.server,
channel: channel,
options: App.session.hydrateOptions(channel, member.options || {})
};
App.storage.setSession(next);
App.session.savePreference(next);
if (App.ui && typeof App.ui.renderMenu === 'function') App.ui.renderMenu();
return true;
}
return false;
};
App.videos.handleSearch = function(value) {
// Searching while a merge is open leaves it. A merge is held together
// by its members' own words (see fetchMergedBatch, which does not read
// the box), so there is nowhere in it to put new ones -- and a tag or
// an uploader tapped from a card would otherwise reload the same
// videos and look like nothing happened. The source and channel it
// lands on are the merge's first member, which is also the one the
// session was already pointed at.
const open = App.storage.getSession();
if (open && open.channel && open.channel.isMerged && typeof value === 'string' && value.trim()) {
App.videos.leaveMerge();
}
if (typeof value === 'string') {
App.videos.setSearchBox(value);
}
@@ -1428,6 +1553,7 @@ App.videos = App.videos || {};
state.renderedVideoIds.clear();
state.loadedVideos = [];
state.groupCursors = null;
state.mergedCursors = null;
App.virtualGrid.reset();
if (App.feed && typeof App.feed.reset === 'function') {
App.feed.reset();
@@ -2641,6 +2767,39 @@ App.videos = App.videos || {};
}
};
// Some providers (rule34video among them) hand the listing their media
// formats themselves, and yt-dlp can't extract their pages. A favorite keeps
// no formats, so for those /api/resolve comes back empty -- ask the
// providers again instead, the way the listing did: search the channel for
// the title and take the item that is this video.
const formatsFromProvider = async function(video) {
const idHead = String(video.id || '').split(':')[0];
const channelId = (idHead && idHead !== String(video.id) ? idHead : String(video.channel || '')).toLowerCase();
if (!channelId) return null;
const servers = (App.storage.getConfig().servers || []).map((entry) => Object.keys(entry)[0]);
const wanted = App.favorites && App.favorites.urlKey ? App.favorites.urlKey(video.url) : video.url;
const same = (item) => item && (item.id === video.id ||
(item.url && (App.favorites && App.favorites.urlKey ? App.favorites.urlKey(item.url) : item.url) === wanted));
for (const server of servers) {
try {
const response = await fetch('/api/videos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel: channelId, query: video.title || '', page: 1, perPage: 24, server })
});
if (!response.ok) continue;
const data = await response.json();
const found = (data && Array.isArray(data.items) ? data.items : []).find(same);
if (found && hasFormats(found)) {
return { url: found.url, formats: found.formats, http_headers: found.http_headers || {}, isLive: !!found.isLive };
}
} catch (err) {
// Next server.
}
}
return null;
};
App.videos.ensureFormats = function(video) {
if (!video || typeof video !== 'object') return Promise.resolve(null);
if (hasFormats(video.meta)) return Promise.resolve(video.meta);
@@ -2667,13 +2826,14 @@ App.videos = App.videos || {};
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: video.url })
});
if (!response.ok) return null;
const data = await response.json();
return hasFormats(data) ? data : null;
if (response.ok) {
const data = await response.json();
if (hasFormats(data)) return data;
}
} catch (err) {
// Best-effort: playback still works through the proxy.
return null;
}
return await formatsFromProvider(video);
})();
metaCache.set(cacheKey, promise);
}

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""A favorite whose site yt-dlp can't extract still gets its formats.
Providers like rule34video hand the listing their formats; a favorite keeps
none, and /api/resolve comes back empty for them. ensureFormats must then ask
the providers again and take the item that is this video.
backend/main.py &
.venv/bin/python tests/smoke_favorite_formats.py
"""
import json
import sys
from playwright.sync_api import sync_playwright
BASE = "http://127.0.0.1:5000/"
SERVER = "https://a.fixture.test"
FAV = {"key": "rule34video:4511669", "id": "rule34video:4511669",
"url": "https://rule34video.com/video/4511669/sindel-cassie-sonya/",
"title": "Sindel, Cassie, Sonya", "thumb": "", "channel": "Rule34video",
"uploader": "Nyl", "duration": 510, "isLive": False,
"favoriteDate": "2026-11-02T22:00:05.356Z"}
STATUS = {"id": "a", "name": "a", "online": True, "channels": [
{"id": "rule34video", "name": "Rule34video", "description": "x", "options": []}]}
FMT = [{"url": "https://rule34video.com/get_file/1/x/4511669_720p.mp4/", "height": 720, "ext": "mp4"}]
asked = []
def videos(route):
body = json.loads(route.request.post_data or "{}")
asked.append(body)
items = [{"id": "rule34video:1", "url": "https://rule34video.com/video/1/other/", "formats": []},
{"id": FAV["id"], "url": FAV["url"], "formats": FMT}]
route.fulfill(status=200, content_type="application/json", body=json.dumps({"items": items}))
def main():
failed = 0
with sync_playwright() as p:
b = p.chromium.launch(args=["--no-sandbox", "--disable-gpu"])
page = b.new_page()
page.route(f"{SERVER}/api/status", lambda r: r.fulfill(
status=200, content_type="application/json", body=json.dumps(STATUS)))
page.route("**/api/resolve", lambda r: r.fulfill(
status=200, content_type="application/json",
body='{"formats":[],"http_headers":{},"isLive":false,"url":null}'))
page.route("**/api/videos", videos)
page.goto(BASE, wait_until="domcontentloaded")
page.evaluate("""([s, f]) => {
localStorage.setItem('config', JSON.stringify({servers: [{[s]: {}}]}));
localStorage.setItem('favorites', JSON.stringify([f]));
}""", [SERVER, FAV])
page.goto(BASE, wait_until="load")
meta = page.evaluate("""async (f) => {
const v = Object.assign({}, f);
const m = await App.videos.ensureFormats(v);
return { m, attached: !!v.meta };
}""", FAV)
ok = bool(meta["m"]) and meta["m"]["formats"] == FMT and meta["attached"]
print(f" [{'PASS' if ok else 'FAIL'}] favorite gets the provider's formats {meta}")
failed += 0 if ok else 1
ok = bool(asked) and asked[0]["channel"] == "rule34video" and asked[0]["server"] == SERVER
print(f" [{'PASS' if ok else 'FAIL'}] asked the right channel and server {asked[:1]}")
failed += 0 if ok else 1
saved = page.evaluate("""(item) => {
localStorage.setItem('favorites', '[]');
App.favorites.toggle(Object.assign({}, item, { formats: [{ url: 'https://x/y.mp4', height: 720 }] }));
return App.favorites.getAll()[0];
}""", FAV)
ok = bool(saved) and bool(saved.get("meta")) and saved["meta"]["formats"][0]["url"] == "https://x/y.mp4"
print(f" [{'PASS' if ok else 'FAIL'}] a saved favorite keeps its formats")
failed += 0 if ok else 1
b.close()
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -90,6 +90,22 @@ RESNAP = """(top) => {
document.getElementById('feed-scroll').scrollTop = top;
}"""
# A swipe, and the chrome reporting itself before the feed has caught up.
# setActive runs from onScroll's next frame, so doing both in one block --
# no frame boundary in between -- leaves feedActiveVideoId still naming the
# video being left, which is the state a phone resize routinely lands in.
# onResize then re-anchors synchronously, with no scroll event for the
# input guard in onScroll to see.
SWIPE_THEN_RESIZE = """() => {
const s = document.getElementById('feed-scroll');
const before = App.state.feedActiveIndex;
s.dispatchEvent(new WheelEvent('wheel', { deltaY: 120, bubbles: true }));
s.scrollTop = s.scrollTop + s.clientHeight;
const moved = s.scrollTop;
window.dispatchEvent(new Event('resize'));
return { before: before, moved: moved, after: s.scrollTop, h: s.clientHeight };
}"""
class Checks:
def __init__(self):
@@ -187,6 +203,22 @@ def main():
c.ok("re-anchored onto its own snap point",
abs(resnapped["scrollTop"] - resnapped["expected"]) <= 1, str(resnapped))
print("\nthe chrome reporting itself before the feed has caught up")
page.wait_for_timeout(900)
pre = page.evaluate(WHERE)
got = page.evaluate(SWIPE_THEN_RESIZE)
page.wait_for_timeout(2200)
kept = page.evaluate(WHERE)
print(f" (swiped to {got['moved']}px; the resize left it at {got['after']}px)")
c.ok("the resize did not haul the reel back to the video being left",
got["after"] >= got["moved"] - 1,
f"{got['moved']} -> {got['after']}")
c.ok("the swipe still landed on the next step",
kept["index"] == pre["index"] + 1,
f"step {pre['index']} -> {kept['index']}")
c.ok("and on that step's snap point",
abs(kept["scrollTop"] - kept["expected"]) <= 1, str(kept))
print("\na little height on a near-square viewport is not a rotation")
page.set_viewport_size(SQUARE)
page.wait_for_timeout(1800)
@@ -202,8 +234,8 @@ def main():
c.ok("and the reel rests on its snap point",
abs(settled["scrollTop"] - settled["expected"]) <= 1, str(settled))
c.ok("still on the same video",
settled["videoId"] == resnapped["videoId"],
f"{resnapped['videoId']} -> {settled['videoId']}")
settled["videoId"] == kept["videoId"],
f"{kept['videoId']} -> {settled['videoId']}")
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
browser.close()

View File

@@ -0,0 +1,263 @@
#!/usr/bin/env python3
"""Merging saved searches from different providers into one listing.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_merge_searches.py
Two fixture providers, one saved search on each, merged into one. What the
merge has to do that a channel group cannot: a group's channels all live on
one server and share one query, where these two share nothing at all. So the
load-bearing check is that the grid comes back holding videos from *both*
providers, taken alternately rather than one provider's page and then the
other's.
Both providers are fixtures, so "interleaved" is something the test can read
off the ids rather than infer: provider A answers a-0, a-1 ... and B answers
b-0, b-1 ...
"""
import json
import sys
from playwright.sync_api import sync_playwright
BASE = "http://127.0.0.1:5000/"
SERVER_A = "https://a.fixture.test"
SERVER_B = "https://b.fixture.test"
CHANNEL_A = "alpha"
CHANNEL_B = "beta"
# fetchMergedBatch asks each member for this many (GROUP_CHANNEL_PAGE_SIZE).
PER_MEMBER = 4
def status(name, channel_id, channel_name):
return {
"id": name,
"name": name,
"online": True,
"channels": [{
"id": channel_id,
"name": channel_name,
"description": "A channel that only exists for this test.",
"options": [],
}],
}
STATUS_A = status("alpha-co", CHANNEL_A, "Alpha Tube")
STATUS_B = status("beta-co", CHANNEL_B, "Beta Tube")
# Two saved searches, one per provider, seeded straight into the store -- the
# saving of a search is smoke_saved_searches' business, not this test's.
SEED = """([a, b, ca, cb]) => {
localStorage.setItem('config', JSON.stringify({
servers: [{ [a]: {} }, { [b]: {} }]
}));
localStorage.removeItem('session');
localStorage.setItem('favorites', JSON.stringify([]));
localStorage.setItem('savedSearches', JSON.stringify([
{ id: 'ss_a', name: 'Alpha pick', server: a, channelId: ca,
channelName: 'Alpha Tube', query: 'reef', options: {}, optionLabels: [],
createdAt: 1, seenIds: [], lastViewedAt: 1, lastCheckedAt: 1, newCount: 0 },
{ id: 'ss_b', name: 'Beta pick', server: b, channelId: cb,
channelName: 'Beta Tube', query: 'ridge', options: {}, optionLabels: [],
createdAt: 2, seenIds: [], lastViewedAt: 2, lastCheckedAt: 2, newCount: 0 }
]));
}"""
GRID_IDS = """() => (App.state.loadedVideos || []).map(v => v.id)"""
SESSION = """() => {
const s = App.storage.getSession();
return {
merged: !!(s && s.channel && s.channel.isMerged),
channelId: s && s.channel ? s.channel.id : null,
members: s && s.channel && s.channel.members
? s.channel.members.map(m => m.server + '|' + m.channelId + '|' + m.query)
: null,
name: s && s.channel ? s.channel.name : null
};
}"""
STORE = """() => App.savedSearches.getAll().map(e => ({
id: e.id, name: e.name,
members: e.members ? e.members.map(m => m.server) : 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 install_routes(page, seen):
page.route(f"{SERVER_A}/api/status", lambda route: route.fulfill(
status=200, content_type="application/json", body=json.dumps(STATUS_A)))
page.route(f"{SERVER_B}/api/status", lambda route: route.fulfill(
status=200, content_type="application/json", body=json.dumps(STATUS_B)))
def videos(route):
try:
body = json.loads(route.request.post_data or "{}")
except ValueError:
body = {}
server = body.get("server") or ""
page_no = int(body.get("page") or 1)
per = int(body.get("perPage") or 12)
tag = "a" if server == SERVER_A else ("b" if server == SERVER_B else "?")
seen.append({"server": server, "channel": body.get("channel"),
"query": body.get("query"), "page": page_no, "perPage": per})
start = (page_no - 1) * per
items = [{
"id": f"{tag}-{start + i}",
"title": f"{tag} clip {start + i}",
"url": f"{server}/v{start + i}",
"thumb": "", "channel": body.get("channel"),
"duration": 30, "isLive": False, "tags": [],
} for i in range(per)]
route.fulfill(status=200, content_type="application/json",
body=json.dumps({"items": items, "pageInfo": {"hasNextPage": True}}))
page.route("**/api/videos", videos)
def main():
c = Checks()
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": 1280, "height": 900})
crashes = []
page.on("pageerror", lambda e: crashes.append(str(e)))
seen = []
install_routes(page, seen)
page.goto(BASE, wait_until="domcontentloaded")
page.evaluate(SEED, [SERVER_A, SERVER_B, CHANNEL_A, CHANNEL_B])
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=30000)
page.wait_for_timeout(800)
print("\nmerging the two")
page.click("#saved-searches-btn")
page.wait_for_selector("#saved-panel.open", timeout=10000)
rows = page.eval_on_selector_all(".saved-row", "els => els.length")
c.ok("both saved searches are listed", rows == 2, str(rows))
page.click("#saved-select-btn")
page.wait_for_timeout(200)
page.click('.saved-row[data-id="ss_a"] .saved-row-main')
page.click('.saved-row[data-id="ss_b"] .saved-row-main')
page.wait_for_timeout(200)
label = page.eval_on_selector("#saved-merge-btn", "el => el.textContent")
c.ok("the merge action counts what is picked",
"2" in label, label)
page.click("#saved-merge-btn")
page.wait_for_timeout(400)
# The new merge opens straight into its rename field; Enter keeps the
# name it was given.
page.keyboard.press("Enter")
page.wait_for_timeout(300)
store = page.evaluate(STORE)
c.ok("the two originals were replaced by one merge", len(store) == 1, str(store))
c.ok("and it holds a member from each provider",
len(store) == 1 and sorted(store[0]["members"] or []) == sorted([SERVER_A, SERVER_B]),
str(store))
print("\nopening it")
seen.clear()
page.click(f'.saved-row[data-id="{store[0]["id"]}"] .saved-row-main')
page.wait_for_timeout(2500)
session = page.evaluate(SESSION)
c.ok("the session is a merge", session["merged"], str(session))
c.ok("carrying both members, each with its own words",
sorted(session["members"] or []) == sorted([
f"{SERVER_A}|{CHANNEL_A}|reef", f"{SERVER_B}|{CHANNEL_B}|ridge"]),
str(session["members"]))
asked = {(r["server"], r["query"]) for r in seen}
c.ok("both providers were asked, each for its own search",
asked == {(SERVER_A, "reef"), (SERVER_B, "ridge")}, str(asked))
ids = page.evaluate(GRID_IDS)
kinds = [i.split("-")[0] for i in ids]
c.ok("the grid holds videos from both providers",
"a" in kinds and "b" in kinds, str(ids[:10]))
# The point of the exercise: alternated, not one provider then the other.
head = kinds[:2 * PER_MEMBER]
c.ok("taken alternately rather than one provider at a time",
head == ["a", "b"] * PER_MEMBER, str(head))
print("\nthe search box stays out of it")
box = page.evaluate("""() => {
const el = document.getElementById('search-input');
return { disabled: el.disabled, value: el.value, placeholder: el.placeholder };
}""")
c.ok("the box is off while a merge is open", box["disabled"], str(box))
c.ok("and says why", "own words" in box["placeholder"], str(box["placeholder"]))
print("\nit survives a reload")
page.reload(wait_until="load")
page.wait_for_selector(".video-card", timeout=30000)
page.wait_for_timeout(2000)
again = page.evaluate(SESSION)
c.ok("still a merge after reloading", again["merged"], str(again))
c.ok("with the same members",
sorted(again["members"] or []) == sorted(session["members"] or []),
str(again["members"]))
reloaded = [i.split("-")[0] for i in page.evaluate(GRID_IDS)]
c.ok("and still fed by both providers",
"a" in reloaded and "b" in reloaded, str(reloaded[:10]))
print("\nturning one member off")
page.evaluate("""(host) => {
const s = App.storage.getSession();
const keep = s.channel.members.filter(m => m.server.indexOf(host) !== -1);
s.options = { __mergedMembers: keep.map(m => ({ id: m.memberId, title: m.channelName })) };
App.storage.setSession(s);
}""", "a.fixture")
page.evaluate("() => { App.videos.resetGrid(); return App.videos.loadVideos(); }")
page.wait_for_timeout(2000)
only = [i.split("-")[0] for i in page.evaluate(GRID_IDS)]
c.ok("only the member left on is asked",
only and set(only) == {"a"}, str(only[:10]))
print("\nsearching from inside a merge steps out of it")
# A tag or an uploader tapped on a card comes through handleSearch.
# There is nowhere in a merge to put new words, so rather than reload
# the same videos and look broken, it lands on one real channel.
page.evaluate("() => App.videos.handleSearch('anemone')")
page.wait_for_timeout(2000)
after = page.evaluate(SESSION)
c.ok("the session is no longer a merge", not after["merged"], str(after))
c.ok("it landed on a member's own channel",
after["channelId"] in (CHANNEL_A, CHANNEL_B), str(after["channelId"]))
box2 = page.evaluate("""() => {
const el = document.getElementById('search-input');
return { disabled: el.disabled, value: el.value };
}""")
c.ok("and the box is usable again", not box2["disabled"], str(box2))
c.ok("holding what was searched for", box2["value"] == "anemone", str(box2))
searched = [r for r in seen if r["query"] == "anemone"]
c.ok("which is what the provider was asked for", bool(searched), str(seen[-3:]))
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
browser.close()
print(f"\n{'FAILED' if c.failed else 'OK'}: {c.failed} check(s) failed")
return 1 if c.failed else 0
if __name__ == "__main__":
sys.exit(main())