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
This commit is contained in:
@@ -276,6 +276,7 @@
|
|||||||
<script src="static/js/player.js"></script>
|
<script src="static/js/player.js"></script>
|
||||||
<script src="static/js/favorites.js"></script>
|
<script src="static/js/favorites.js"></script>
|
||||||
<script src="static/js/favoritesView.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/sqlite.js"></script>
|
||||||
<script src="static/js/hottubBackup.js"></script>
|
<script src="static/js/hottubBackup.js"></script>
|
||||||
<script src="static/js/videos.js"></script>
|
<script src="static/js/videos.js"></script>
|
||||||
|
|||||||
@@ -494,7 +494,7 @@ App.favorites = App.favorites || {};
|
|||||||
`;
|
`;
|
||||||
const thumb = card.querySelector('img');
|
const thumb = card.querySelector('img');
|
||||||
if (App.videos && typeof App.videos.attachThumbnail === 'function') {
|
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 = () => {
|
card.onclick = () => {
|
||||||
if (card.classList.contains('is-loading')) return;
|
if (card.classList.contains('is-loading')) return;
|
||||||
|
|||||||
@@ -807,7 +807,7 @@ App.feed = App.feed || {};
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
const poster = pane.querySelector('.feed-poster');
|
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');
|
const slideVideo = pane.querySelector('.feed-video');
|
||||||
bindTimeline(pane, slideVideo);
|
bindTimeline(pane, slideVideo);
|
||||||
bindSharedControls(pane, slideVideo, v);
|
bindSharedControls(pane, slideVideo, v);
|
||||||
@@ -1584,7 +1584,7 @@ App.feed = App.feed || {};
|
|||||||
const titleText = pane.querySelector('.feed-title-text');
|
const titleText = pane.querySelector('.feed-title-text');
|
||||||
if (titleText) titleText.textContent = v.title || '';
|
if (titleText) titleText.textContent = v.title || '';
|
||||||
const poster = pane.querySelector('.feed-poster');
|
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);
|
loadSlideSource(pane, v, true);
|
||||||
setPipMetadata(v);
|
setPipMetadata(v);
|
||||||
};
|
};
|
||||||
|
|||||||
356
frontend/js/posters.js
Normal file
356
frontend/js/posters.js
Normal 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;
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -26,7 +26,10 @@ App.constants = {
|
|||||||
PREFERRED_QUALITY_KEY: 'preferredQuality',
|
PREFERRED_QUALITY_KEY: 'preferredQuality',
|
||||||
FEED_END_BEHAVIOR_KEY: 'feedEndBehavior',
|
FEED_END_BEHAVIOR_KEY: 'feedEndBehavior',
|
||||||
SEARCH_HISTORY_KEY: 'searchHistory',
|
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
|
// Lazily injects hls.js the first time a stream actually needs it. Sessions
|
||||||
|
|||||||
@@ -270,9 +270,24 @@ App.videos = App.videos || {};
|
|||||||
// being asked.
|
// being asked.
|
||||||
const posterAsked = new Map(); // page URL -> promise of a replacement, or ''
|
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 repairThumbnail = function(img, token) {
|
||||||
const page = img.dataset.thumbPage || '';
|
const page = img.dataset.thumbPage || '';
|
||||||
if (!page) return;
|
if (!page) { capturePoster(img, token); return; }
|
||||||
let pending = posterAsked.get(page);
|
let pending = posterAsked.get(page);
|
||||||
if (!pending) {
|
if (!pending) {
|
||||||
pending = fetch(`/api/poster?url=${encodeURIComponent(page)}`)
|
pending = fetch(`/api/poster?url=${encodeURIComponent(page)}`)
|
||||||
@@ -282,7 +297,9 @@ App.videos = App.videos || {};
|
|||||||
posterAsked.set(page, pending);
|
posterAsked.set(page, pending);
|
||||||
}
|
}
|
||||||
pending.then((replacement) => {
|
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
|
// No second repair for this mount: the ladder below must not come
|
||||||
// back here and ask the same page for the same picture forever.
|
// back here and ask the same page for the same picture forever.
|
||||||
delete img.dataset.thumbPage;
|
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
|
// 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
|
// item's own page, asked for a replacement if the picture turns out to be
|
||||||
// gone.
|
// 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
|
// An address that isn't one is the same thing as no thumbnail: the card
|
||||||
// keeps its placeholder rather than chasing it. See usableThumbUrl.
|
// keeps its placeholder rather than chasing it. See usableThumbUrl.
|
||||||
const directUrl = usableThumbUrl(url || (img && img.dataset.thumb) || '');
|
const directUrl = usableThumbUrl(url || (img && img.dataset.thumb) || '');
|
||||||
if (!img) return;
|
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
|
// 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
|
// 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.
|
// 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.dataset.alt = img.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
|
// 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
|
// 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
|
// 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 proxyUrl = App.videos.buildImageProxyUrl(directUrl);
|
||||||
const host = imageHostOf(directUrl);
|
const host = imageHostOf(directUrl);
|
||||||
const route = imageRoutes.get(host);
|
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) {
|
if (route === IMAGE_PROXY) {
|
||||||
attachRetry(img, retryPlan(directUrl, proxyUrl, IMAGE_PROXY), token);
|
attachRetry(img, retryPlan(directUrl, proxyUrl, IMAGE_PROXY), token);
|
||||||
@@ -590,6 +627,7 @@ App.videos = App.videos || {};
|
|||||||
img.dataset.thumbToken = String(++thumbSeq);
|
img.dataset.thumbToken = String(++thumbSeq);
|
||||||
delete img.dataset.alt;
|
delete img.dataset.alt;
|
||||||
delete img.dataset.thumbPage;
|
delete img.dataset.thumbPage;
|
||||||
|
img._thumbVideo = null;
|
||||||
detachRetry(img);
|
detachRetry(img);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1126,7 +1164,7 @@ App.videos = App.videos || {};
|
|||||||
// the same frame, so loading a thumbnail for it -- let alone racing one
|
// the same frame, so loading a thumbnail for it -- let alone racing one
|
||||||
// -- would be pure waste.
|
// -- would be pure waste.
|
||||||
if (!(options && options.skipThumbnail)) {
|
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;
|
return card;
|
||||||
};
|
};
|
||||||
@@ -1824,7 +1862,7 @@ App.videos = App.videos || {};
|
|||||||
if (!card || !v) return;
|
if (!card || !v) return;
|
||||||
unfilled.delete(i);
|
unfilled.delete(i);
|
||||||
const img = cardRefs(card).img;
|
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
|
// Once the thumbnail loads, drop the 16:9 placeholder so it shows at
|
||||||
// its true aspect ratio, clear the shimmer, then correct the height.
|
// its true aspect ratio, clear the shimmer, then correct the height.
|
||||||
if (img) {
|
if (img) {
|
||||||
|
|||||||
BIN
tests/fixtures/wide.mp4
vendored
Normal file
BIN
tests/fixtures/wide.mp4
vendored
Normal file
Binary file not shown.
288
tests/smoke_poster_capture.py
Normal file
288
tests/smoke_poster_capture.py
Normal 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())
|
||||||
Reference in New Issue
Block a user