Choose a channel by what it is, not by its name in a list

A <select> of ninety channels tells the reader almost nothing: a wall of
bare names, nothing to say which site a name belongs to or what it
carries, and no way to look for one. The server has been sending far more
than the name all along -- a favicon, a description, tags, its own
groupings, and whether a channel still says "work in progress" -- so the
picker shows that.

It opens as a dialog in the site's own style: a search field, then the
server's groups as sections, each led by an "All <group>" row that browses
the whole group. Search matches the name, the id, the description, the
tags and the group, so "jav" finds Tokyo Motion (whose name never says it)
and "leaks" finds the OnlyFans mirrors. Arrows and Enter walk the list,
Escape closes it, and opening it with nothing typed scrolls to the channel
already being read. On a phone it fills the screen and leaves the field
unfocused -- the keyboard would cover the thing being chosen from.

Favicons load through attachThumbnail, so they get the same route race,
proxy fallback and retries as every other remote picture, with the
channel's initial behind them for the ones that never arrive.

The <select> was also where the command palette read its channel actions,
so the list lives in App.ui.channels now and the palette asks for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
This commit is contained in:
Simon
2026-09-19 08:27:43 +00:00
parent b478c51551
commit a795442634
5 changed files with 834 additions and 83 deletions

199
tests/smoke_channels.py Normal file
View File

@@ -0,0 +1,199 @@
#!/usr/bin/env python3
"""Channel picker smoke tests.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_channels.py
The picker replaced a <select>, so what's checked here is what the <select>
couldn't do -- show what a channel *is* (favicon, description, tags, group) and
let it be searched -- plus the two things it could: switching the channel, and
feeding the command palette its list.
"""
import sys
from playwright.sync_api import sync_playwright
BASE = "http://127.0.0.1:5000/"
SERVER = "https://hottub.spacemoehre.de"
CHANNEL = "xvideos"
SEED = """([server, channel]) => {
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
localStorage.removeItem('session');
localStorage.setItem('favorites', JSON.stringify([]));
}"""
ROWS = """() => {
const list = document.getElementById('channel-picker-list');
const rows = Array.from(list.querySelectorAll('.channel-row'));
return {
sections: Array.from(list.querySelectorAll('.channel-section')).map((s) => s.textContent),
count: rows.length,
withNote: rows.filter((r) => !!r.querySelector('.channel-row-note')).length,
withTags: rows.filter((r) => !!r.querySelector('.channel-tag')).length,
withIconEl: rows.filter((r) => !!r.querySelector('img.channel-favicon')).length,
iconsLoaded: rows.filter((r) => {
const img = r.querySelector('img.channel-favicon');
return img && img.naturalWidth > 0;
}).length,
current: rows.filter((r) => r.classList.contains('is-current'))
.map((r) => r.dataset.channelId),
groupRows: rows.filter((r) => r.dataset.channelId.startsWith('group:')).length,
emptyHidden: document.getElementById('channel-picker-empty').hidden,
};
}"""
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 boot(page):
page.goto(BASE, wait_until="domcontentloaded")
page.evaluate(SEED, [SERVER, CHANNEL])
page.goto(BASE, wait_until="load")
try:
page.wait_for_selector(".video-card", timeout=90000)
except Exception:
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=90000)
page.wait_for_timeout(2500)
def main():
c = Checks()
with sync_playwright() as p:
browser = p.chromium.launch(args=[
"--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
])
page = browser.new_page(viewport={"width": 1400, "height": 1000})
crashes = []
page.on("pageerror", lambda e: crashes.append(str(e)))
boot(page)
print("\nthe menu says which channel is being read")
page.evaluate("() => App.ui.toggleDrawer('menu')")
page.wait_for_timeout(2500)
trigger = page.evaluate("""() => ({
name: document.getElementById('channel-trigger-name').textContent,
note: document.getElementById('channel-trigger-note').textContent,
iconLoaded: document.getElementById('channel-trigger-icon').naturalWidth > 0,
})""")
c.ok("the trigger names the current channel", trigger["name"] == "XVideos", str(trigger))
c.ok("and says something about it", len(trigger["note"]) > 10, str(trigger))
c.ok("and shows its favicon", trigger["iconLoaded"], str(trigger))
print("\nopening it lists the channels with what the server knows")
page.click("#channel-picker-btn")
page.wait_for_timeout(3000)
rows = page.evaluate(ROWS)
c.ok("every channel is listed", rows["count"] > 50, str(rows["count"]))
c.ok("grouped under the server's own headings", len(rows["sections"]) > 1,
str(rows["sections"][:4]))
c.ok("each group can be browsed whole", rows["groupRows"] > 1, str(rows["groupRows"]))
c.ok("rows carry a description", rows["withNote"] > rows["count"] // 2, str(rows))
c.ok("rows carry tags", rows["withTags"] > rows["count"] // 2, str(rows))
c.ok("rows carry a favicon", rows["withIconEl"] > rows["count"] // 2, str(rows))
c.ok("and the favicons actually load", rows["iconsLoaded"] > 10,
f"{rows['iconsLoaded']} of {rows['withIconEl']} loaded")
c.ok("the current channel is marked", rows["current"] == [CHANNEL], str(rows["current"]))
print("\nsearching narrows it")
page.fill("#channel-search", "hentai")
page.wait_for_timeout(600)
found = page.evaluate(ROWS)
c.ok("fewer rows than before", 0 < found["count"] < rows["count"], str(found["count"]))
# A row can match on its own text or on the group it sits under -- the
# heading is as much a fact about the channel as its description.
c.ok("every row left standing matches what was typed",
page.evaluate("""() => {
let heading = '';
return Array.from(document.getElementById('channel-picker-list').children)
.every((node) => {
if (node.classList.contains('channel-section')) {
heading = node.textContent.toLowerCase();
return true;
}
const text = (node.textContent + ' ' + node.dataset.channelId + ' ' + heading);
return text.toLowerCase().includes('hentai');
});
}"""))
# Description and tags are searched too, not just the name.
page.fill("#channel-search", "leaks")
page.wait_for_timeout(600)
by_tag = page.evaluate(ROWS)
c.ok("a tag finds channels whose name doesn't say it", by_tag["count"] > 0,
str(by_tag["count"]))
page.fill("#channel-search", "zzzznothing")
page.wait_for_timeout(600)
nothing = page.evaluate(ROWS)
c.ok("a search with no hits says so",
nothing["count"] == 0 and not nothing["emptyHidden"], str(nothing))
print("\npicking one switches the feed")
page.fill("#channel-search", "eporner")
page.wait_for_timeout(600)
page.click(".channel-row")
page.wait_for_timeout(1500)
c.ok("the picker closes",
page.evaluate("() => !document.getElementById('channel-picker').classList.contains('open')"))
session = page.evaluate("() => App.storage.getSession().channel.id")
c.ok("the session moved to it", session == "eporner", session)
c.ok("the trigger followed",
page.evaluate("() => document.getElementById('channel-trigger-name').textContent") == "EPorner")
page.wait_for_selector(".video-card", timeout=90000)
c.ok("and the grid reloaded",
page.evaluate("() => App.state.loadedVideos.length > 0"))
print("\nthe keyboard works too")
page.click("#channel-picker-btn")
page.wait_for_timeout(1200)
page.fill("#channel-search", "beeg")
page.wait_for_timeout(500)
page.press("#channel-search", "ArrowDown")
page.press("#channel-search", "ArrowUp")
page.press("#channel-search", "Enter")
page.wait_for_timeout(1500)
c.ok("Enter picks the highlighted row",
page.evaluate("() => App.storage.getSession().channel.id") == "beeg",
page.evaluate("() => App.storage.getSession().channel.id"))
page.click("#channel-picker-btn")
page.wait_for_timeout(800)
page.press("#channel-search", "Escape")
page.wait_for_timeout(300)
c.ok("Escape closes it without changing anything",
page.evaluate("""() => !document.getElementById('channel-picker').classList.contains('open')
&& App.storage.getSession().channel.id === 'beeg'"""))
print("\nthe command palette still offers the channels")
entries = page.evaluate("() => App.ui.channels.entries().length")
c.ok("the picker hands out its list", entries > 50, str(entries))
page.evaluate("() => App.enhance.openPalette()")
page.wait_for_timeout(400)
page.fill("#cmdk-input", "Redtube")
page.wait_for_timeout(400)
page.press("#cmdk-input", "Enter")
page.wait_for_timeout(1500)
c.ok("and choosing one from there switches the channel",
page.evaluate("() => App.storage.getSession().channel.id") == "redtube",
page.evaluate("() => App.storage.getSession().channel.id"))
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())