-
+
+
+
+
Video Info
No additional info available.
diff --git a/frontend/js/ui.js b/frontend/js/ui.js
index deebe9c..f449446 100644
--- a/frontend/js/ui.js
+++ b/frontend/js/ui.js
@@ -69,6 +69,62 @@ App.ui = App.ui || {};
// Which video the panel is currently showing, so a slow resolve that lands
// after the user moved on doesn't redraw someone else's panel.
let infoVideo = null;
+ // Exactly what the panel is showing, as one object -- what the copy button
+ // hands over. Kept beside the rendering rather than rebuilt on click, so
+ // the two can't disagree about what "this video" means.
+ let infoPayload = null;
+
+ // The clipboard proper needs a secure context, which a home-screen app on
+ // https has and a plain-http LAN address does not -- hence the old
+ // execCommand path behind it, which only works on a selection in the
+ // document.
+ const copyText = async function(text) {
+ if (navigator.clipboard && window.isSecureContext) {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch (err) { /* fall through to the old way */ }
+ }
+ const scratch = document.createElement('textarea');
+ scratch.value = text;
+ scratch.setAttribute('readonly', '');
+ // Off-screen but focusable: display:none or visibility:hidden would
+ // leave nothing to select, and a visible one would scroll the page.
+ scratch.style.position = 'fixed';
+ scratch.style.top = '-1000px';
+ scratch.style.opacity = '0';
+ document.body.appendChild(scratch);
+ try {
+ scratch.select();
+ return document.execCommand('copy');
+ } catch (err) {
+ return false;
+ } finally {
+ scratch.remove();
+ }
+ };
+
+ let copyResetTimer = null;
+
+ App.ui.copyInfoJson = async function() {
+ const button = document.getElementById('info-copy');
+ if (!infoPayload) return false;
+ const copied = await copyText(JSON.stringify(infoPayload, null, 2));
+ if (!copied) {
+ App.ui.showError('Could not copy to the clipboard.');
+ return false;
+ }
+ if (button) {
+ button.classList.add('is-copied');
+ button.textContent = 'Copied';
+ if (copyResetTimer) clearTimeout(copyResetTimer);
+ copyResetTimer = setTimeout(() => {
+ button.classList.remove('is-copied');
+ button.textContent = 'Copy JSON';
+ }, 1600);
+ }
+ return true;
+ };
const appendInfoHeading = function(list, label) {
const heading = document.createElement('div');
@@ -131,12 +187,16 @@ App.ui = App.ui || {};
if (title) title.textContent = item.title || (resolved && resolved.title) || 'Video Info';
+ // `meta` gets its own section below rather than a row of JSON.
+ const own = Object.assign({}, item);
+ delete own.meta;
+ const section = opts.info ? 'extractor' : 'resolved';
+ infoPayload = Object.assign({}, own);
+ if (resolved && typeof resolved === 'object') infoPayload[section] = resolved;
+
let rows = 0;
if (list) {
list.innerHTML = "";
- // `meta` gets its own section below rather than a row of JSON.
- const own = Object.assign({}, item);
- delete own.meta;
rows += appendInfoRows(list, own);
if (resolved && typeof resolved === 'object') {
@@ -166,6 +226,13 @@ App.ui = App.ui || {};
// nothing (or at a spinner) while it does.
App.ui.openInfo = function(video) {
infoVideo = video;
+ // A fresh panel, so the button stops saying it copied the last one.
+ const copyBtn = document.getElementById('info-copy');
+ if (copyBtn) {
+ if (copyResetTimer) clearTimeout(copyResetTimer);
+ copyBtn.classList.remove('is-copied');
+ copyBtn.textContent = 'Copy JSON';
+ }
const canResolve = !!(App.videos && typeof App.videos.fetchFullInfo === 'function');
App.ui.showInfo(video, { pending: canResolve });
if (!canResolve) return;
@@ -179,6 +246,7 @@ App.ui = App.ui || {};
const modal = document.getElementById('info-modal');
if (!modal) return;
infoVideo = null;
+ infoPayload = null;
modal.classList.remove('open');
modal.setAttribute('aria-hidden', 'true');
};
@@ -1098,5 +1166,12 @@ App.ui = App.ui || {};
App.ui.closeInfo();
});
}
+
+ const infoCopy = document.getElementById('info-copy');
+ if (infoCopy) {
+ infoCopy.addEventListener('click', () => {
+ App.ui.copyInfoJson();
+ });
+ }
};
})();
diff --git a/tests/smoke_channels.py b/tests/smoke_channels.py
index 59eedf9..dbb7c63 100644
--- a/tests/smoke_channels.py
+++ b/tests/smoke_channels.py
@@ -73,7 +73,14 @@ def main():
c = Checks()
with sync_playwright() as p:
browser = p.chromium.launch(args=[
- "--no-sandbox", "--disable-dev-shm-usage", "--disable-gpu",
+ "--no-sandbox",
+ "--disable-dev-shm-usage",
+ "--disable-gpu",
+ # Lean flags, matching smoke_grid: this runs alongside the app's own
+ # server and a default Chromium spikes hard enough at startup to get
+ # itself killed on a constrained box.
+ "--renderer-process-limit=1",
+ "--js-flags=--max-old-space-size=512",
])
page = browser.new_page(viewport={"width": 1400, "height": 1000})
crashes = []
diff --git a/tests/smoke_info.py b/tests/smoke_info.py
new file mode 100644
index 0000000..2e9cd10
--- /dev/null
+++ b/tests/smoke_info.py
@@ -0,0 +1,151 @@
+#!/usr/bin/env python3
+"""Info panel smoke tests: what the panel shows, and what Copy JSON hands over.
+
+Run against a locally running backend:
+
+ backend/main.py &
+ .venv/bin/python tests/smoke_info.py
+
+The listing is served by this script so the expected JSON is known exactly --
+including the awkward parts of a real item (nested http_headers, a tag array,
+a zero, an empty string).
+"""
+import json
+import os
+import sys
+from playwright.sync_api import sync_playwright
+
+BASE = os.environ.get("JACUZZI_BASE", "http://127.0.0.1:5000/")
+SERVER = "https://hottubapp.io"
+CHANNEL = "xvideos"
+
+ITEM = {
+ "id": "test:copy-me",
+ "title": "Summer Col and Damion Dayski",
+ "url": "https://example.test/proxy/test/post/copy-me.html",
+ "channel": "test",
+ "duration": 0,
+ "isLive": False,
+ "views": 0,
+ "uploader": "",
+ "thumb": "https://cdn.example.test/thumb.png",
+ "tags": ["sex", "bigtits", "bigass", "hardcore"],
+ "preview": "https://example.test/proxy/test/post/copy-me.html",
+ "http_headers": {"Referer": "https://example.test/"},
+}
+
+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([]));
+}"""
+
+
+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 main():
+ c = Checks()
+ with sync_playwright() as p:
+ browser = p.chromium.launch(args=[
+ "--no-sandbox",
+ "--disable-dev-shm-usage",
+ "--disable-gpu",
+ # Lean flags, matching smoke_grid: this runs alongside the app's own
+ # server and a default Chromium spikes hard enough at startup to get
+ # itself killed on a constrained box.
+ "--renderer-process-limit=1",
+ "--js-flags=--max-old-space-size=512",
+ ])
+ context = browser.new_context(viewport={"width": 1400, "height": 1000})
+ # The real clipboard, so the button's own path is what runs.
+ context.grant_permissions(["clipboard-read", "clipboard-write"])
+ page = context.new_page()
+ crashes = []
+ page.on("pageerror", lambda e: crashes.append(str(e)))
+
+ page.route("**/api/videos", lambda route: route.fulfill(
+ status=200, content_type="application/json",
+ body=json.dumps({"items": [ITEM], "pageInfo": {"hasNextPage": False}})))
+ page.route("**/api/resolve*", lambda route: route.fulfill(
+ status=200, content_type="application/json",
+ body=json.dumps({"formats": [], "http_headers": {}, "isLive": False, "url": None})))
+
+ page.goto(BASE, wait_until="domcontentloaded")
+ page.evaluate(SEED, [SERVER, CHANNEL])
+ page.goto(BASE, wait_until="load")
+ page.wait_for_selector(".video-card", timeout=60000)
+ page.wait_for_timeout(1500)
+
+ print("\nthe panel opens with a copy button")
+ page.evaluate("""() => {
+ const v = App.state.loadedVideos.find((x) => x.id === 'test:copy-me');
+ App.ui.openInfo(v);
+ }""")
+ page.wait_for_timeout(500)
+ c.ok("the panel is open",
+ page.evaluate("() => document.getElementById('info-modal').classList.contains('open')"))
+ c.ok("the button is visible", page.is_visible("#info-copy"))
+ c.ok("and reads Copy JSON",
+ page.inner_text("#info-copy").strip() == "Copy JSON",
+ page.inner_text("#info-copy"))
+
+ print("\nclicking it puts the item on the clipboard")
+ page.click("#info-copy")
+ page.wait_for_timeout(600)
+ clipboard = page.evaluate("() => navigator.clipboard.readText()")
+ try:
+ copied = json.loads(clipboard)
+ except ValueError:
+ copied = None
+ c.ok("what landed there is JSON", copied is not None, clipboard[:120])
+ if copied:
+ missing = [k for k, v in ITEM.items() if k not in copied]
+ c.ok("with every field the item has", not missing, str(missing))
+ c.ok("values intact, nested ones included",
+ copied.get("http_headers") == ITEM["http_headers"]
+ and copied.get("tags") == ITEM["tags"], str(copied.get("http_headers")))
+ c.ok("a zero is a zero, not a dash",
+ copied.get("duration") == 0 and copied.get("views") == 0, str(copied.get("duration")))
+ c.ok("and an empty string stays empty",
+ copied.get("uploader") == "", repr(copied.get("uploader")))
+ c.ok("it is pretty-printed, not one line", "\n" in clipboard)
+ c.ok("the button says so", page.inner_text("#info-copy").strip() == "Copied",
+ page.inner_text("#info-copy"))
+
+ print("\nand it goes back to normal")
+ page.wait_for_timeout(2000)
+ c.ok("the label resets", page.inner_text("#info-copy").strip() == "Copy JSON",
+ page.inner_text("#info-copy"))
+
+ print("\nthe resolved payload rides along once it lands")
+ page.evaluate("""() => {
+ const v = App.state.loadedVideos.find((x) => x.id === 'test:copy-me');
+ App.ui.showInfo(v, { info: { extractorField: 'yes', formats: [] } });
+ }""")
+ page.wait_for_timeout(300)
+ page.click("#info-copy")
+ page.wait_for_timeout(600)
+ second = json.loads(page.evaluate("() => navigator.clipboard.readText()"))
+ c.ok("the extractor's fields are in there too",
+ (second.get("extractor") or {}).get("extractorField") == "yes",
+ str(list(second.keys())))
+ c.ok("and the item's own are still there", second.get("id") == ITEM["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())