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
This commit is contained in:
Simon
2026-11-01 07:59:12 +00:00
parent c60f5e01dc
commit 2fcc17877d
8 changed files with 857 additions and 31 deletions

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