Compare commits

..

2 Commits

Author SHA1 Message Date
Simon
6dac1028a3 Take a thumbnail out of the video when the listed one is gone
A favorite outlives its thumbnail. Sources sign their CDN paths and the
signature expires, so a video kept months ago has a `thumb` that 404s and
a card that is a grey box with a title under it. Asking the source page
for a fresh picture covers the case where the page still exists; nothing
covered the case where it doesn't.

The video does, because playback re-resolves from the page URL every time.
So once every route to the listed picture is exhausted, a frame a quarter
of the way in becomes the card's picture -- past the black lead-in, and a
fair guess at what the video looks like. It goes through our own proxy, so
the canvas can be read back, is scaled to card width and JPEG'd to a
handful of KB, and is kept: later mounts and later sessions paint it
without a request, instead of asking again for a picture that is known to
be gone.

Only for favorites, once per item, one at a time, and never for a live
stream or a manifest. Two things worth knowing, both found with the
browser rather than reasoned about: a browser fires `seeked` for its own
initial seek to zero, so taking the frame on the first one captures the
opening of every video; and the same item is on screen in more than one
place, so the captures are deduped by a promise rather than the first
asker getting the picture and the rest being told there isn't one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-10-20 07:33:38 +00:00
Simon
e8eac70f4d Write a check into the list instead of rebuilding it
A check asks each saved search in turn and writes back what it found, and
the list was built again from scratch on every one of those writes. So a
background check -- which has no business being visible at all beyond a
new count and a new time -- replayed the whole list's entry animation,
threw away the reader's scroll position, and took the pointer out of
whatever row it was over, several times over.

Rows are now matched by id and told what changed: the count, the name, the
line underneath, nothing else. A row is only built when it is new, gone, or
changes shape, and the entry animation belongs to a row that has arrived
rather than to every row every time. One mid-rename or mid-confirm is left
alone entirely, so a check can no longer type over the reader.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-10-20 07:33:27 +00:00
11 changed files with 805 additions and 19 deletions

View File

@@ -3279,6 +3279,11 @@ body.theme-light .saved-badge { box-shadow: 0 0 0 2px rgba(246, 241, 231, 0.95);
align-items: center;
gap: 4px;
border-bottom: 1px solid var(--border);
}
/* Rows animate in when they arrive, and only then: a row already on screen
that a background check wrote a new count into has not arrived anywhere. */
.saved-row.is-new {
animation: saved-row-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
animation-delay: calc(var(--i, 0) * 40ms);
}

View File

@@ -276,6 +276,7 @@
<script src="static/js/player.js"></script>
<script src="static/js/favorites.js"></script>
<script src="static/js/favoritesView.js"></script>
<script src="static/js/posters.js"></script>
<script src="static/js/sqlite.js"></script>
<script src="static/js/hottubBackup.js"></script>
<script src="static/js/videos.js"></script>

View File

@@ -494,7 +494,7 @@ App.favorites = App.favorites || {};
`;
const thumb = card.querySelector('img');
if (App.videos && typeof App.videos.attachThumbnail === 'function') {
App.videos.attachThumbnail(thumb, item.thumb, App.videos.sourcePageUrl(item));
App.videos.attachThumbnail(thumb, item.thumb, App.videos.sourcePageUrl(item), item);
}
card.onclick = () => {
if (card.classList.contains('is-loading')) return;

View File

@@ -807,7 +807,7 @@ App.feed = App.feed || {};
</div>
`;
const poster = pane.querySelector('.feed-poster');
App.videos.attachThumbnail(poster, v.thumb, App.videos.sourcePageUrl(v));
App.videos.attachThumbnail(poster, v.thumb, App.videos.sourcePageUrl(v), v);
const slideVideo = pane.querySelector('.feed-video');
bindTimeline(pane, slideVideo);
bindSharedControls(pane, slideVideo, v);
@@ -1584,7 +1584,7 @@ App.feed = App.feed || {};
const titleText = pane.querySelector('.feed-title-text');
if (titleText) titleText.textContent = v.title || '';
const poster = pane.querySelector('.feed-poster');
if (poster) App.videos.attachThumbnail(poster, v.thumb, App.videos.sourcePageUrl(v));
if (poster) App.videos.attachThumbnail(poster, v.thumb, App.videos.sourcePageUrl(v), v);
loadSlideSource(pane, v, true);
setPipMetadata(v);
};

356
frontend/js/posters.js Normal file
View File

@@ -0,0 +1,356 @@
window.App = window.App || {};
App.posters = App.posters || {};
// ---------------------------------------------------------------------------
// Captured posters
//
// A favorite outlives its thumbnail. The picture a listing hands out is a
// signed CDN path on most sources, and the signature expires -- so a video kept
// months ago has a `thumb` that 404s, and the card that stood for it is a grey
// box with a title under it. Asking the source page for a fresh one (the
// /api/poster repair in videos.js) covers the case where the page still exists
// and still shows a picture; nothing covers the case where it doesn't.
//
// The video itself does, though: it is still playable, because playback
// re-resolves from the page URL every time. So once every route to the listed
// picture is exhausted, a frame a quarter of the way into the clip becomes the
// card's picture -- far enough in to be past a title card or a black lead-in,
// and a fair guess at what the video looks like.
//
// A frame costs a resolve and the stretch of video around that point, which is
// why this happens once per item, for favorites only, and never again: the
// frame is scaled down, JPEG'd, and kept in localStorage, so the next mount and
// every later session paint it straight away without touching the network.
// ---------------------------------------------------------------------------
(function() {
const INDEX_KEY = App.constants.POSTER_CACHE_KEY;
const ENTRY_PREFIX = `${INDEX_KEY}:`;
// A thumbnail box is around 300 CSS px wide in the grid and half a phone
// screen in the favorites bar, so this is a picture at roughly card size
// and not a frame of video: the point of the cache is to be small.
const WIDTH = 360;
const QUALITY = 0.72;
// How far into the clip the frame comes from.
const SEEK_FRACTION = 0.25;
// What the whole cache may take. Stored base64, so the string is a third
// larger than the JPEG behind it -- around 20 KB an entry, which leaves
// room for a hundred or so of them inside a localStorage budget that is
// shared with the favorites themselves.
const BUDGET_BYTES = 3 * 1024 * 1024;
// How long a seek gets before the frame it was decoding anyway will do.
const SEEK_PATIENCE_MS = 6000;
// A capture is one seek into one file. If that has not produced a frame by
// now, the source is not going to give us one.
const FRAME_TIMEOUT_MS = 20000;
// How many of the ranked sources are worth trying before giving up on an
// item for this session.
const SOURCE_TRIES = 2;
// Both halves of a 64-bit-ish hash of the item's identity: a cache key has
// to be short enough to be a localStorage key and distinct enough that two
// favorites never share a picture.
const hash = function(text) {
let fnv = 0x811c9dc5;
let djb = 5381;
for (let i = 0; i < text.length; i++) {
const code = text.charCodeAt(i);
fnv = (fnv ^ code) >>> 0;
fnv = (fnv + ((fnv << 1) + (fnv << 4) + (fnv << 7) + (fnv << 8) + (fnv << 24))) >>> 0;
djb = (((djb << 5) + djb) + code) >>> 0;
}
return fnv.toString(16).padStart(8, '0') + djb.toString(16).padStart(8, '0');
};
// The identity to cache under: the item's own URL, normalized the way
// favorites normalize one, so the same video saved from a listing and
// imported from a backup lands on the same picture.
const keyOf = function(video) {
if (!video || typeof video !== 'object') return '';
const url = video.url || (video.meta && video.meta.url) || '';
const basis = (url && App.favorites && App.favorites.urlKey(url)) || url ||
(App.favorites && App.favorites.getKey(video)) || video.id || '';
return basis ? hash(String(basis)) : '';
};
// ---- the cache -------------------------------------------------------
// [{ k, n, at }], oldest capture first. Read once and kept: a card asks
// whether it has a picture as it mounts, and parsing the index again for
// every card of a scrolling grid is the one thing this must not cost.
let index = null;
const readIndex = function() {
if (index) return index;
try {
const parsed = JSON.parse(localStorage.getItem(INDEX_KEY));
index = Array.isArray(parsed) ?
parsed.filter((entry) => entry && entry.k).map((entry) => ({
k: String(entry.k),
n: Number(entry.n) || 0,
at: Number(entry.at) || 0
})) : [];
} catch (err) {
index = [];
}
return index;
};
const writeIndex = function() {
try {
localStorage.setItem(INDEX_KEY, JSON.stringify(index));
} catch (err) { /* nothing here is worth failing a card over */ }
};
const drop = function(entry) {
try {
localStorage.removeItem(ENTRY_PREFIX + entry.k);
} catch (err) { /* already gone */ }
};
const store = function(key, src) {
const list = readIndex().filter((entry) => entry.k !== key);
list.push({ k: key, n: src.length, at: Date.now() });
index = list;
// Oldest captures go first. Not least-recently-seen: tracking that
// would mean writing to storage every time a card mounts, which is a
// worse trade than occasionally re-capturing a picture.
let total = list.reduce((sum, entry) => sum + entry.n, 0);
while (total > BUDGET_BYTES && index.length > 1) {
const oldest = index.shift();
total -= oldest.n;
drop(oldest);
}
try {
localStorage.setItem(ENTRY_PREFIX + key, src);
} catch (err) {
// Out of room, and not necessarily because of us -- the store is
// shared with the favorites and everything else. Make room by
// giving up the oldest third of the cache and try once more.
const giveUp = Math.max(1, Math.floor(index.length / 3));
index.splice(0, giveUp).forEach(drop);
try {
localStorage.setItem(ENTRY_PREFIX + key, src);
} catch (again) {
index = index.filter((entry) => entry.k !== key);
writeIndex();
return '';
}
}
writeIndex();
return src;
};
const read = function(key) {
try {
return localStorage.getItem(ENTRY_PREFIX + key) || '';
} catch (err) {
return '';
}
};
// The picture already captured for this item, if there is one.
App.posters.cached = function(video) {
const key = keyOf(video);
if (!key) return '';
// The index says what we have without touching the entries, so an item
// with no captured picture -- almost all of them -- costs a set lookup
// rather than a read of a data URL out of storage.
if (!readIndex().some((entry) => entry.k === key)) return '';
const src = read(key);
if (!src) {
index = readIndex().filter((entry) => entry.k !== key);
writeIndex();
}
return src;
};
App.posters.forget = function(video) {
const key = keyOf(video);
if (!key) return;
readIndex().filter((entry) => entry.k === key).forEach(drop);
index = readIndex().filter((entry) => entry.k !== key);
writeIndex();
};
App.posters.clear = function() {
readIndex().forEach(drop);
index = [];
writeIndex();
};
App.posters.stats = function() {
const list = readIndex();
return { count: list.length, bytes: list.reduce((sum, entry) => sum + entry.n, 0) };
};
// ---- taking the frame ------------------------------------------------
// Draws what the element is showing, at card size rather than video size.
const drawFrame = function(el) {
const width = el.videoWidth;
const height = el.videoHeight;
if (!width || !height) throw new Error('nothing was decoded');
const scale = Math.min(1, WIDTH / width);
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(width * scale));
canvas.height = Math.max(1, Math.round(height * scale));
canvas.getContext('2d').drawImage(el, 0, 0, canvas.width, canvas.height);
// Tainted canvas throws here, which is why the frame is taken from our
// own proxy rather than from the CDN: /api/stream is same-origin, so
// the pixels can be read back.
const src = canvas.toDataURL('image/jpeg', QUALITY);
if (!src || src.length < 512) throw new Error('the frame came out empty');
return src;
};
const grabFrame = function(url, duration) {
return new Promise((resolve, reject) => {
const el = document.createElement('video');
el.muted = true;
el.defaultMuted = true;
el.playsInline = true;
el.preload = 'auto';
el.crossOrigin = 'anonymous';
el.setAttribute('aria-hidden', 'true');
// Off-screen, but in the document: an element the browser does not
// consider part of a page is under no obligation to decode
// anything.
el.style.cssText = 'position:fixed;left:-9999px;top:0;width:2px;height:2px;' +
'opacity:0;pointer-events:none';
let settled = false;
let timer = null;
let patience = null;
// Where we asked to be, and whether we asked at all. A browser
// fires `seeked` for its own initial seek to zero as the file
// opens, and taking the frame on that one would mean every
// captured picture is the first frame of the video -- usually a
// black one -- however far in we meant to go.
let target = 0;
let asked = false;
const done = function(err, src) {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
if (patience) clearTimeout(patience);
// Told to stop downloading before it leaves, or the request
// outlives the element that made it.
el.removeAttribute('src');
try { el.load(); } catch (stop) { /* nothing to stop */ }
el.remove();
if (err) reject(err);
else resolve(src);
};
const take = function() {
try { done(null, drawFrame(el)); } catch (err) { done(err); }
};
timer = setTimeout(() => done(new Error('no frame in time')), FRAME_TIMEOUT_MS);
el.addEventListener('error', () => done(new Error('the source would not play')));
el.addEventListener('loadedmetadata', () => {
const length = Number(el.duration) || Number(duration) || 0;
target = (length > 0 && isFinite(length)) ? length * SEEK_FRACTION : 0;
// Nowhere to seek to -- a stream of unknown length -- so the
// first frame it decodes is the one we get.
if (target <= 0) return;
asked = true;
try { el.currentTime = target; } catch (err) { done(err); }
// Some sources cannot be seeked in at all: no byte ranges, or
// a length the browser does not trust. They still have an
// opening frame, and a picture of the opening beats a grey box
// -- but only once the seek has had its chance.
patience = setTimeout(() => {
if (el.readyState >= 2) take();
}, SEEK_PATIENCE_MS);
});
el.addEventListener('seeked', () => {
// Near enough: a seek lands on the nearest frame it can decode,
// which is the keyframe before the mark, not the mark itself.
if (asked && el.currentTime < target - 1) return;
take();
});
el.addEventListener('loadeddata', () => { if (!asked) take(); });
document.body.appendChild(el);
el.src = url;
});
};
const captureOne = async function(video) {
// Favorites keep no resolved formats on purpose (their URLs expire), so
// this is where the item becomes playable again.
try {
await App.videos.ensureFormats(video);
} catch (err) { /* the page-URL fallback below may still play */ }
const sources = App.videos.resolveStreamSources(video, {
applyPreferredQuality: false,
// The smallest format there is: this is going to end up 360 px
// wide, so a 1080p stream would be bytes spent on pixels that get
// thrown away.
cheapest: true
}).filter((source) => {
// A manifest is a playlist of segments, not something a bare
// <video> can open outside Safari, and the player's hls.js is a
// heavier thing than a thumbnail is worth.
const kind = App.videos.classifySource(source);
return !kind.isHls && !source.isLive;
}).slice(0, SOURCE_TRIES);
for (let i = 0; i < sources.length; i++) {
const url = App.videos.buildStreamUrlFromSource(sources[i]);
if (!url) continue;
try {
return await grabFrame(url, Number(video.duration) || 0);
} catch (err) {
// Try the next format; if that was the last one, the item has
// no picture and no amount of asking changes it.
}
}
return '';
};
// The same item is on screen in more than one place -- the favorites bar
// and the grid behind it, a card and the reel's own poster -- and all of
// them ask. They wait on the one capture and all get the picture; the
// alternative is the first one to ask getting a picture and the rest being
// told there isn't one.
const inFlight = new Map();
// And one try per item per session once it has come back empty: a failed
// capture is a resolve plus a download, and a grid that scrolls past the
// same dead favorite ten times must not pay for it ten times.
const failed = new Set();
// One capture at a time. The reader is watching something; a thumbnail has
// no business competing with it for bandwidth.
let queue = Promise.resolve();
App.posters.capture = function(video) {
const key = keyOf(video);
if (!key) return Promise.resolve('');
const have = App.posters.cached(video);
if (have) return Promise.resolve(have);
const running = inFlight.get(key);
if (running) return running;
if (failed.has(key)) return Promise.resolve('');
// A live stream has no quarter mark, and whatever it is showing now is
// not what it will be showing when the card is next looked at.
if (video.isLive || (video.meta && video.meta.isLive)) return Promise.resolve('');
const run = queue.then(() => captureOne(video)).then((src) => {
inFlight.delete(key);
if (!src) {
failed.add(key);
return '';
}
return store(key, src);
}, () => {
inFlight.delete(key);
failed.add(key);
return '';
});
inFlight.set(key, run);
queue = run.catch(() => '');
return run;
};
})();

View File

@@ -26,7 +26,10 @@ App.constants = {
PREFERRED_QUALITY_KEY: 'preferredQuality',
FEED_END_BEHAVIOR_KEY: 'feedEndBehavior',
SEARCH_HISTORY_KEY: 'searchHistory',
SAVED_SEARCHES_KEY: 'savedSearches'
SAVED_SEARCHES_KEY: 'savedSearches',
// Thumbnails captured out of a video whose listed picture is gone:
// the index, plus one entry per picture under `<key>:<hash>`.
POSTER_CACHE_KEY: 'posterCache'
};
// Lazily injects hls.js the first time a stream actually needs it. Sessions

View File

@@ -609,10 +609,19 @@ 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).
const modeOf = function(entry) {
return (pending && pending.id === entry.id) ? pending.mode : 'plain';
};
const buildRow = function(entry, index) {
const row = document.createElement('div');
row.className = 'saved-row';
// Only a row that was not on screen a moment ago animates in.
row.className = 'saved-row is-new';
row.dataset.id = entry.id;
row.dataset.mode = modeOf(entry);
row.style.setProperty('--i', String(index));
if (entry.unreachable) row.classList.add('is-unreachable');
@@ -674,6 +683,45 @@ App.ui = App.ui || {};
tools.appendChild(rename);
tools.appendChild(remove);
row.appendChild(tools);
// What updateRow writes to, so it doesn't have to search the row
// again on every check.
row._parts = { count: count, name: name, meta: meta, stamp: stamp,
rename: rename, remove: remove };
return row;
};
// The same row, told what changed. A check writes a new count and a new
// "checked" time on every saved search it asks about; rebuilding the
// list for that would play the whole list's entry animation again,
// drop the reader's scroll position and take the pointer out of
// whatever it was over. A background check has no business doing any
// of that, so nothing is replaced -- the few strings that differ are
// written in place.
const updateRow = function(row, entry, index) {
const parts = row._parts;
if (!parts) return row;
row.classList.remove('is-new');
row.style.setProperty('--i', String(index));
row.classList.toggle('is-unreachable', !!entry.unreachable);
const label = App.savedSearches.badgeLabel(entry.newCount);
const countText = label || '·';
if (parts.count.textContent !== countText) parts.count.textContent = countText;
parts.count.classList.toggle('is-quiet', !label);
if (label) parts.count.title = `${entry.newCount} new since you last opened this`;
else parts.count.removeAttribute('title');
if (parts.name.textContent !== entry.name) {
parts.name.textContent = entry.name;
parts.rename.title = `Rename ${entry.name}`;
parts.rename.setAttribute('aria-label', `Rename ${entry.name}`);
parts.remove.title = `Remove ${entry.name}`;
parts.remove.setAttribute('aria-label', `Remove ${entry.name}`);
}
const meta = metaLine(entry);
if (parts.meta.textContent !== meta) parts.meta.textContent = meta;
const stamp = stampLine(entry);
if (parts.stamp.textContent !== stamp) parts.stamp.textContent = stamp;
return row;
};
@@ -687,8 +735,27 @@ App.ui = App.ui || {};
if (!list) return;
const entries = App.savedSearches.getAll();
list.textContent = '';
entries.forEach((entry, index) => list.appendChild(buildRow(entry, index)));
const onScreen = new Map();
Array.from(list.children).forEach((row) => onScreen.set(row.dataset.id, row));
entries.forEach((entry, index) => {
const had = onScreen.get(entry.id);
onScreen.delete(entry.id);
// A row mid-rename or mid-confirm is a different row: it has
// its own field and buttons, so that one is built.
let row;
if (had && had.dataset.mode === modeOf(entry)) {
row = updateRow(had, entry, index);
} else {
row = buildRow(entry, index);
// A row that changed shape where it stood has not arrived
// either -- it is the same row, asking something.
if (had) row.classList.remove('is-new');
}
if (list.children[index] !== row) {
list.insertBefore(row, list.children[index] || null);
}
});
onScreen.forEach((row) => row.remove());
if (empty) empty.hidden = entries.length > 0;
if (note) {

View File

@@ -270,9 +270,24 @@ App.videos = App.videos || {};
// being asked.
const posterAsked = new Map(); // page URL -> promise of a replacement, or ''
// Last of all, once the listed picture is gone and the page has no
// better one to offer: a frame out of the video itself. Only for something
// the reader kept -- a capture costs a resolve and a stretch of video,
// which is not worth spending on a card that scrolls past once -- and the
// picture is cached, so an item pays for it once and no later session pays
// at all. See App.posters.
const capturePoster = function(img, token) {
const video = img._thumbVideo;
if (!video || !App.posters || !App.favorites || !App.favorites.has(video)) return;
App.posters.capture(video).then((src) => {
if (!src || img.dataset.thumbToken !== token) return;
showThumbnail(img, src, token);
});
};
const repairThumbnail = function(img, token) {
const page = img.dataset.thumbPage || '';
if (!page) return;
if (!page) { capturePoster(img, token); return; }
let pending = posterAsked.get(page);
if (!pending) {
pending = fetch(`/api/poster?url=${encodeURIComponent(page)}`)
@@ -282,7 +297,9 @@ App.videos = App.videos || {};
posterAsked.set(page, pending);
}
pending.then((replacement) => {
if (!replacement || img.dataset.thumbToken !== token) return;
if (img.dataset.thumbToken !== token) return;
// The page had nothing either, so there is one thing left to try.
if (!replacement) { capturePoster(img, token); return; }
// No second repair for this mount: the ladder below must not come
// back here and ask the same page for the same picture forever.
delete img.dataset.thumbPage;
@@ -529,11 +546,14 @@ App.videos = App.videos || {};
// racing the two the first time that host is seen. `page` is optional: the
// item's own page, asked for a replacement if the picture turns out to be
// gone.
App.videos.attachThumbnail = function(img, url, page) {
App.videos.attachThumbnail = function(img, url, page, video) {
// An address that isn't one is the same thing as no thumbnail: the card
// keeps its placeholder rather than chasing it. See usableThumbUrl.
const directUrl = usableThumbUrl(url || (img && img.dataset.thumb) || '');
if (!img) return;
// The item this picture is of, kept for the capture at the end of the
// ladder (see capturePoster).
img._thumbVideo = video || null;
// Held back until there is an image to caption -- see showThumbnail. An
// item with no thumbnail keeps an empty alt: the card's own title sits
// directly beneath the box, so there is nothing for it to add.
@@ -541,7 +561,30 @@ App.videos = App.videos || {};
img.dataset.alt = img.alt;
img.alt = '';
}
if (!directUrl) return;
// Every attach is a new generation, so work started for a previous one
// stops being able to touch this element.
const token = String(++thumbSeq);
img.dataset.thumbToken = token;
if (page) img.dataset.thumbPage = page;
else delete img.dataset.thumbPage;
// A picture captured for this item earlier wins outright. The cache
// only ever holds one because every route to the listed picture had
// already failed, so going back to the wire for it would be a request
// that is known to fail, on every mount, for the rest of the item's
// life.
const captured = App.posters ? App.posters.cached(video) : '';
if (captured) {
showThumbnail(img, captured, token);
return;
}
if (!directUrl) {
// Nothing listed to fail, so there is nothing to walk a ladder
// down: a frame out of the video is the only picture this item is
// ever going to have.
capturePoster(img, token);
return;
}
// A cross-origin Referer is what most hotlink protection keys on, and an
// image needs none. Sending none is what lets the direct route work at
// all on a fair number of providers -- and the direct route is the one
@@ -551,12 +594,6 @@ App.videos = App.videos || {};
const proxyUrl = App.videos.buildImageProxyUrl(directUrl);
const host = imageHostOf(directUrl);
const route = imageRoutes.get(host);
// Every attach is a new generation, so work started for a previous one
// stops being able to touch this element.
const token = String(++thumbSeq);
img.dataset.thumbToken = token;
if (page) img.dataset.thumbPage = page;
else delete img.dataset.thumbPage;
if (route === IMAGE_PROXY) {
attachRetry(img, retryPlan(directUrl, proxyUrl, IMAGE_PROXY), token);
@@ -590,6 +627,7 @@ App.videos = App.videos || {};
img.dataset.thumbToken = String(++thumbSeq);
delete img.dataset.alt;
delete img.dataset.thumbPage;
img._thumbVideo = null;
detachRetry(img);
};
@@ -1126,7 +1164,7 @@ App.videos = App.videos || {};
// the same frame, so loading a thumbnail for it -- let alone racing one
// -- would be pure waste.
if (!(options && options.skipThumbnail)) {
App.videos.attachThumbnail(cardRefs(card).img, v.thumb, App.videos.sourcePageUrl(v));
App.videos.attachThumbnail(cardRefs(card).img, v.thumb, App.videos.sourcePageUrl(v), v);
}
return card;
};
@@ -1824,7 +1862,7 @@ App.videos = App.videos || {};
if (!card || !v) return;
unfilled.delete(i);
const img = cardRefs(card).img;
App.videos.attachThumbnail(img, v.thumb, App.videos.sourcePageUrl(v));
App.videos.attachThumbnail(img, v.thumb, App.videos.sourcePageUrl(v), v);
// Once the thumbnail loads, drop the 16:9 placeholder so it shows at
// its true aspect ratio, clear the shimmer, then correct the height.
if (img) {

BIN
tests/fixtures/wide.mp4 vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,288 @@
#!/usr/bin/env python3
"""A favorite whose thumbnail has expired gets a frame out of the video instead.
Run against a locally running backend:
backend/main.py &
.venv/bin/python tests/smoke_poster_capture.py
Everything the client would reach for is a fixture, and every one of them is
dead on purpose: the listed picture 404s on both routes, the image proxy 404s,
and the source page has no replacement to offer. That is the state a favorite
saved months ago is actually in -- the signature on its CDN thumbnail expired --
and what the test asks is that the card still ends up with a picture of the
video, scaled down, kept, and not fetched again.
"""
import json
import os
import re
import sys
from playwright.sync_api import sync_playwright
# 1280x720, eight seconds. Wide on purpose: the capture has to scale down to
# card size, and a fixture no wider than the cache's own width would never show
# whether it does.
CLIP = os.path.join(os.path.dirname(__file__), "fixtures", "wide.mp4")
BASE = "http://127.0.0.1:5000/"
SERVER = "https://fixture.test"
CHANNEL = "clips"
# What App.posters scales a frame down to, and what it keeps the cache under.
POSTER_WIDTH = 360
CACHE_KEY = "posterCache"
STATUS = {
"online": True,
"channels": [{
"id": CHANNEL,
"name": "Fixture Clips",
"description": "A channel that only exists for this test.",
"options": [],
}],
}
# The favorite is the first item of the listing, saved with the thumbnail it
# had at the time -- the one that no longer loads.
SEED = """([server, channel]) => {
localStorage.setItem('config', JSON.stringify({ servers: [{ [server]: {} }] }));
localStorage.setItem('preferences', JSON.stringify({ [server]: { channelId: channel } }));
localStorage.removeItem('session');
localStorage.removeItem('posterCache');
Object.keys(localStorage).forEach((k) => {
if (k.indexOf('posterCache:') === 0) localStorage.removeItem(k);
});
localStorage.setItem('favorites', JSON.stringify([{
key: 'vid-0',
id: 'vid-0',
url: 'https://fixture.test/v0.mp4',
title: 'kept clip 0',
thumb: 'https://pictures.fixture.test/gone/0.jpg',
channel: channel,
uploader: '',
duration: 8,
isLive: false,
favoriteDate: new Date().toISOString()
}]));
}"""
# The picture on a card, and whether it is one: a broken <img> has no
# intrinsic size.
SHOT = """(id) => {
const card = document.querySelector(`.video-card[data-video-id="${id}"]`);
if (!card) return null;
const img = card.querySelector('.video-thumb img');
return {
src: (img.getAttribute('src') || '').slice(0, 24),
length: (img.getAttribute('src') || '').length,
width: img.naturalWidth,
height: img.naturalHeight
};
}"""
CACHE = """() => {
const index = JSON.parse(localStorage.getItem('posterCache') || '[]');
return index.map((entry) => ({
bytes: entry.n,
stored: (localStorage.getItem(`posterCache:${entry.k}`) || '').slice(0, 24),
length: (localStorage.getItem(`posterCache:${entry.k}`) || '').length
}));
}"""
PIXEL = """([id, x, y]) => new Promise((resolve) => {
const card = document.querySelector(`.video-card[data-video-id="${id}"]`);
const img = card ? card.querySelector('.video-thumb img') : null;
if (!img) { resolve(null); return; }
const probe = new Image();
probe.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = probe.width;
canvas.height = probe.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(probe, 0, 0);
const px = ctx.getImageData(x, y, 1, 1).data;
resolve({ r: px[0], g: px[1], b: px[2] });
};
probe.onerror = () => resolve(null);
probe.src = img.getAttribute('src');
})"""
CAPTURED = """(id) => {
const card = document.querySelector(`.video-card[data-video-id="${id}"]`);
if (!card) return false;
const img = card.querySelector('.video-thumb img');
return (img.getAttribute('src') || '').indexOf('data:image/jpeg') === 0;
}"""
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 items():
# The Referer is what gives an item a source page, which is the repair that
# comes before a capture. A favorite has none -- normalize() keeps no
# headers, since theirs expire -- so a saved item goes straight to the
# frame, while the same item in the listing walks the whole ladder.
return [{"id": f"vid-{i}", "title": f"clip {i}",
"url": f"https://fixture.test/v{i}.mp4",
"thumb": f"https://pictures.fixture.test/gone/{i}.jpg",
"http_headers": {"Referer": "https://page.fixture.test/"},
"channel": CHANNEL, "duration": 8, "isLive": False, "tags": []}
for i in range(3)]
def main():
c = Checks()
streamed = []
posters = []
with sync_playwright() as p:
browser = p.chromium.launch(args=[
"--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
"--autoplay-policy=no-user-gesture-required",
"--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)))
page.route("**/api/status", lambda route: route.fulfill(
status=200, content_type="application/json", body=json.dumps(STATUS)))
page.route("**/api/videos", lambda route: route.fulfill(
status=200, content_type="application/json",
body=json.dumps({"items": items(), "pageInfo": {"hasNextPage": False}})))
# Every way to the listed picture is gone: the provider, and our own
# image proxy standing in front of it.
page.route(re.compile(r"pictures\.fixture\.test"),
lambda route: route.fulfill(status=404, body=""))
page.route(re.compile(r"/api/image"),
lambda route: route.fulfill(status=404, body=""))
# And the source page has no fresher one to read off.
def poster(route):
posters.append(route.request.url)
route.fulfill(status=200, content_type="application/json",
body=json.dumps({"thumb": ""}))
page.route(re.compile(r"/api/poster"), poster)
# The video itself, which is the one thing that still works. Served
# the way the real proxy serves it -- byte ranges and all -- because a
# response a browser cannot seek in is one it will not capture a frame
# a quarter of the way into.
def stream(route):
streamed.append(route.request.url)
body = open(CLIP, "rb").read()
asked = route.request.headers.get("range") or ""
span = re.match(r"bytes=(\d+)-(\d*)", asked)
if span:
start = int(span.group(1))
end = int(span.group(2)) if span.group(2) else len(body) - 1
chunk = body[start:end + 1]
route.fulfill(status=206, body=chunk, headers={
"Content-Type": "video/mp4",
"Accept-Ranges": "bytes",
"Content-Range": f"bytes {start}-{end}/{len(body)}",
"Content-Length": str(len(chunk)),
})
return
route.fulfill(status=200, body=body, headers={
"Content-Type": "video/mp4",
"Accept-Ranges": "bytes",
"Content-Length": str(len(body)),
})
page.route(re.compile(r"/api/stream"), stream)
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)
print("\nthe listed picture is gone, on every route there is")
c.ok("the module is there", page.evaluate("() => typeof App.posters.capture") == 'function')
c.ok("nothing is cached to begin with", page.evaluate(CACHE) == [], str(page.evaluate(CACHE)))
print("\nso the card takes a frame out of the video")
page.wait_for_function(CAPTURED, arg="vid-0", timeout=40000)
shot = page.evaluate(SHOT, "vid-0")
c.ok("the favorite's thumbnail is a captured frame",
shot and shot["src"].startswith("data:image/jpeg"), str(shot))
c.ok("and it decoded, so it is a picture and not an empty box",
shot["height"] > 0, str(shot))
c.ok("scaled down to card width, not kept at video size",
shot["width"] == POSTER_WIDTH, str(shot["width"]))
c.ok("the aspect ratio came through",
abs(shot["height"] - round(POSTER_WIDTH * 720 / 1280)) <= 1, str(shot))
c.ok("it was asked for at the quarter mark, from the video itself",
any("/api/stream" in url for url in streamed), str(streamed[:2]))
c.ok("and the source page was asked for a fresh picture before that",
any("/api/poster" in url for url in posters), str(posters[:2]))
print("\nthe frame is the one a quarter of the way in")
# The fixture is three bands of flat colour, so the frame says when it
# was taken: red for the first second and a half, orange from there to
# two and a half -- which is where a quarter of eight seconds falls --
# and navy for the rest. A capture that drew the first frame it decoded
# instead of seeking would come out red, and one that waited for the
# middle, navy.
frame = page.evaluate(PIXEL, ["vid-0", 180, 100])
c.ok("the colour is the one the clip shows two seconds in",
frame is not None and frame["r"] > 190 and 100 < frame["g"] < 180 and frame["b"] < 110,
str(frame))
c.ok("so it is neither the opening frame nor the middle",
frame is not None and frame["g"] > 80 and not (frame["b"] > frame["r"]), str(frame))
print("\nand keeps it")
cached = page.evaluate(CACHE)
c.ok("one picture is in the cache", len(cached) == 1, str(cached))
c.ok("stored as a JPEG data URL",
cached[0]["stored"].startswith("data:image/jpeg"), str(cached[0]["stored"]))
c.ok("small enough to keep a hundred of them",
0 < cached[0]["length"] < 60000, str(cached[0]["length"]))
c.ok("and the index says how big it is",
cached[0]["bytes"] == cached[0]["length"], str(cached[0]))
print("\nthe videos nobody kept are left alone")
others = [page.evaluate(SHOT, "vid-1"), page.evaluate(SHOT, "vid-2")]
c.ok("a card that is not a favorite captures nothing",
all(not (shot and shot["src"].startswith("data:")) for shot in others), str(others))
c.ok("so exactly one video was fetched for a picture",
len(streamed) == 1, str(streamed))
print("\nand the next session does not fetch it again")
streamed.clear()
posters.clear()
page.goto(BASE, wait_until="load")
page.wait_for_selector(".video-card", timeout=30000)
page.wait_for_timeout(1500)
shot = page.evaluate(SHOT, "vid-0")
c.ok("the kept picture is on the card straight away",
shot and shot["src"].startswith("data:image/jpeg"), str(shot))
c.ok("without going back to the video", streamed == [], str(streamed))
c.ok("or to the source page", posters == [], str(posters))
c.ok("and it is still the same one picture in the cache",
len(page.evaluate(CACHE)) == 1, str(page.evaluate(CACHE)))
print("\nthe favorites bar shows it too")
bar = page.evaluate("""() => {
const img = document.querySelector('.favorite-card img');
return img ? (img.getAttribute('src') || '').slice(0, 24) : null;
}""")
c.ok("the bar card is the captured frame as well",
bar is not None and bar.startswith("data:image/jpeg"), str(bar))
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())

View File

@@ -58,6 +58,20 @@ ROWS = """() => Array.from(document.querySelectorAll('#saved-panel .saved-row'))
stamp: row.querySelector('.saved-row-stamp').textContent
}))"""
MARK_ROWS = """() => {
// A mark on the elements themselves: a rebuilt row cannot carry it.
const rows = Array.from(document.querySelectorAll('#saved-panel .saved-row'));
rows.forEach((row, i) => { row.dataset.probe = `p${i}`; });
return rows.length;
}"""
PROBES = """() => Array.from(document.querySelectorAll('#saved-panel .saved-row')).map((row) => ({
probe: row.dataset.probe || '',
count: row.querySelector('.saved-count').textContent,
fresh: row.classList.contains('is-new'),
stamp: row.querySelector('.saved-row-stamp').textContent
}))"""
READ_BTN = """() => {
const button = document.getElementById('saved-read-btn');
return { disabled: button.disabled, text: button.textContent };
@@ -378,6 +392,20 @@ def main():
c.ok("and the button turns itself off with nothing left to clear",
page.evaluate(READ_BTN)["disabled"] is True, str(page.evaluate(READ_BTN)))
print("\na check writes into the list without rebuilding it")
c.ok("the list is open with a row in it", page.evaluate(MARK_ROWS) == 1)
offset["at"] = offset["at"] + 2
page.evaluate("() => App.savedSearches.check({ force: true })")
page.wait_for_timeout(1000)
rows = page.evaluate(PROBES)
c.ok("the row is the same element it was before the check",
len(rows) == 1 and rows[0]["probe"] == "p0", str(rows))
c.ok("with the count written into it where it stood",
rows[0]["count"] == "2", str(rows[0]["count"]))
c.ok("and the time of the check with it",
"checked" in rows[0]["stamp"].lower(), str(rows[0]["stamp"]))
c.ok("nothing animated itself back in", rows[0]["fresh"] is False, str(rows[0]))
c.ok("nothing threw along the way", not crashes, str(crashes[:2]))
browser.close()