Add advisory mode for kids LLM review, show reviewer notes on jobs, allow sun-kissed
This commit is contained in:
@@ -44,6 +44,7 @@ SCOPES = [
|
|||||||
MASK = "********"
|
MASK = "********"
|
||||||
MIN_FREE_GB = 2
|
MIN_FREE_GB = 2
|
||||||
LATIN_LANGS = {"en", "es", "fr", "de", "it", "pt", "nl", "sv", "no", "da", "fi", "pl", "cs", "ro", "hu", "tr", "id", "ms", "vi", "tl"}
|
LATIN_LANGS = {"en", "es", "fr", "de", "it", "pt", "nl", "sv", "no", "da", "fi", "pl", "cs", "ro", "hu", "tr", "id", "ms", "vi", "tl"}
|
||||||
|
REVIEW_MODES = ("advisory", "block", "off")
|
||||||
|
|
||||||
GLOBAL_DEFAULTS = {
|
GLOBAL_DEFAULTS = {
|
||||||
"active_profile": "default",
|
"active_profile": "default",
|
||||||
@@ -55,7 +56,7 @@ GLOBAL_DEFAULTS = {
|
|||||||
"vision_model": "llama3.2-vision",
|
"vision_model": "llama3.2-vision",
|
||||||
"ollama_timeout": 300,
|
"ollama_timeout": 300,
|
||||||
"gemini_api_key": "",
|
"gemini_api_key": "",
|
||||||
"veo_model": "veo-3.0-fast-generate-001",
|
"veo_model": "veo-3.1-fast-generate-preview",
|
||||||
"aspect_ratio": "9:16",
|
"aspect_ratio": "9:16",
|
||||||
"target_seconds": 60,
|
"target_seconds": 60,
|
||||||
"clip_seconds": 8,
|
"clip_seconds": 8,
|
||||||
@@ -67,6 +68,7 @@ PROFILE_DEFAULTS = {
|
|||||||
"channel_niche": "",
|
"channel_niche": "",
|
||||||
"made_for_kids": False,
|
"made_for_kids": False,
|
||||||
"kids_manual_review": True,
|
"kids_manual_review": True,
|
||||||
|
"kids_llm_review": "advisory",
|
||||||
"upload_category_id": "24",
|
"upload_category_id": "24",
|
||||||
"privacy_status": "private",
|
"privacy_status": "private",
|
||||||
"trending_query": "",
|
"trending_query": "",
|
||||||
@@ -90,6 +92,16 @@ Include a gentle lesson or positive message such as kindness, sharing, curiosity
|
|||||||
Visuals are bright, colorful, calm, friendly animation in safe, cheerful settings, with no flashing or strobing light.
|
Visuals are bright, colorful, calm, friendly animation in safe, cheerful settings, with no flashing or strobing light.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
REVIEW_RULES = {
|
||||||
|
"violence": "violence, weapons, injuries, blood, death, or dangerous acts a child could copy",
|
||||||
|
"scary": "scary, creepy, or disturbing imagery or events",
|
||||||
|
"humans": "realistic humans or real children as characters",
|
||||||
|
"adult": "romance, kissing, alcohol, tobacco, drugs, or other adult themes",
|
||||||
|
"commercial": "brand names, real products, toys for sale, or product placement",
|
||||||
|
"contact": "asking viewers to comment, like, subscribe, share personal information, visit a website, or leave YouTube",
|
||||||
|
"negative": "bullying, meanness, or a message that teaches bad behavior",
|
||||||
|
}
|
||||||
|
|
||||||
KIDS_VEO_SUFFIX = (
|
KIDS_VEO_SUFFIX = (
|
||||||
" Child-friendly cartoon animation with cute non-human characters, soft bright colors, calm pacing, "
|
" Child-friendly cartoon animation with cute non-human characters, soft bright colors, calm pacing, "
|
||||||
"safe cheerful setting, nothing scary or dangerous, no realistic people, no logos, no flashing lights."
|
"safe cheerful setting, nothing scary or dangerous, no realistic people, no logos, no flashing lights."
|
||||||
@@ -102,7 +114,7 @@ Return JSON only: {"safe": true or false, "reason": "short explanation"}"""
|
|||||||
KIDS_BANNED = re.compile(
|
KIDS_BANNED = re.compile(
|
||||||
r"\b(kill\w*|blood\w*|bleed\w*|guns?|knife|knives|swords?|weapons?|bombs?|murder\w*|dead|die|dies|dying|death|"
|
r"\b(kill\w*|blood\w*|bleed\w*|guns?|knife|knives|swords?|weapons?|bombs?|murder\w*|dead|die|dies|dying|death|"
|
||||||
r"scary|spooky|terrif\w*|horror|creepy|nightmare\w*|ghost\w*|haunt\w*|zombie\w*|demon\w*|devil\w*|bhoot|"
|
r"scary|spooky|terrif\w*|horror|creepy|nightmare\w*|ghost\w*|haunt\w*|zombie\w*|demon\w*|devil\w*|bhoot|"
|
||||||
r"sexy|kiss\w*|beer|wine|drunk|cigar\w*|smok\w*|drugs?|vape\w*|hate)\b",
|
r"sexy|(?<!sun-)(?<!sun )kiss\w*|beer|wine|drunk|cigar\w*|smok\w*|drugs?|vape\w*|hate)\b",
|
||||||
re.I,
|
re.I,
|
||||||
)
|
)
|
||||||
KIDS_CONTACT = re.compile(
|
KIDS_CONTACT = re.compile(
|
||||||
@@ -591,6 +603,17 @@ Return JSON only: {{"tags": ["15 to 25 tags, most specific first"]}}"""
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_text(t):
|
||||||
|
return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9 ]", " ", str(t).lower())).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def script_text(script):
|
||||||
|
parts = [script["title"], script["description"], script["style"]]
|
||||||
|
for sc in script["scenes"]:
|
||||||
|
parts += [sc["visual"], sc["narration"]]
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
def kids_rule_issues(script):
|
def kids_rule_issues(script):
|
||||||
issues = []
|
issues = []
|
||||||
texts = [script["title"], script["description"], script["style"]]
|
texts = [script["title"], script["description"], script["style"]]
|
||||||
@@ -610,31 +633,62 @@ def kids_rule_issues(script):
|
|||||||
|
|
||||||
def kids_llm_review(s, script):
|
def kids_llm_review(s, script):
|
||||||
scenes = "\n".join(f"{i}. Visual: {sc['visual']}\n Narration: {sc['narration']}" for i, sc in enumerate(script["scenes"], 1))
|
scenes = "\n".join(f"{i}. Visual: {sc['visual']}\n Narration: {sc['narration']}" for i, sc in enumerate(script["scenes"], 1))
|
||||||
prompt = f"""You are a strict compliance reviewer for YouTube videos that are made for kids, applying COPPA and YouTube's quality principles for kids content.
|
rules = "\n".join(f"{k}: {v}" for k, v in REVIEW_RULES.items())
|
||||||
Check this Short against every rule below. Reject it if any rule is broken or anything could upset, endanger, or mislead a young child.
|
prompt = f"""You are a careful reviewer for a YouTube channel of cartoon stories for young children.
|
||||||
|
Check the script below against each rule. A rule is only broken if specific words in the script clearly break it.
|
||||||
|
Do not judge capitalization, punctuation, target age, art style, or anything not listed in the rules. Those are checked elsewhere.
|
||||||
|
|
||||||
|
Rules (id: what is not allowed):
|
||||||
|
{rules}
|
||||||
|
|
||||||
{KIDS_RULES}
|
|
||||||
Title: {script['title']}
|
Title: {script['title']}
|
||||||
Description: {script['description']}
|
Description: {script['description']}
|
||||||
Visual style: {script['style']}
|
Visual style: {script['style']}
|
||||||
Scenes:
|
Scenes:
|
||||||
{scenes}
|
{scenes}
|
||||||
|
|
||||||
Return JSON only: {{"pass": true or false, "issues": ["each specific problem"]}}"""
|
For every broken rule, copy the exact words from the script that break it.
|
||||||
|
Return JSON only: {{"violations": [{{"rule": "rule id", "quote": "exact words copied from the script", "reason": "short explanation"}}]}}
|
||||||
|
Return {{"violations": []}} if no rule is broken."""
|
||||||
try:
|
try:
|
||||||
data = ollama_json(s, prompt, temperature=0.1, num_predict=1024)
|
data = ollama_json(s, prompt, temperature=0.1, num_predict=1024)
|
||||||
except Cancelled:
|
except Cancelled:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, [f"compliance reviewer error: {e}"]
|
return [f"compliance reviewer error: {e}"], 0
|
||||||
issues = [str(i) for i in data.get("issues", []) if str(i).strip()] if isinstance(data.get("issues"), list) else []
|
raw = data.get("violations", [])
|
||||||
return truthy(data.get("pass")), issues
|
if not isinstance(raw, list):
|
||||||
|
return ["compliance reviewer returned an invalid response"], 0
|
||||||
|
haystack = normalize_text(script_text(script))
|
||||||
|
verified, ignored = [], 0
|
||||||
|
for v in raw:
|
||||||
|
if not isinstance(v, dict):
|
||||||
|
ignored += 1
|
||||||
|
continue
|
||||||
|
rule = str(v.get("rule", "")).strip().lower()
|
||||||
|
quote = normalize_text(v.get("quote", ""))
|
||||||
|
if rule not in REVIEW_RULES or len(quote) < 3 or quote not in haystack:
|
||||||
|
ignored += 1
|
||||||
|
logger.debug("Ignored unverified reviewer finding: %s", v)
|
||||||
|
continue
|
||||||
|
verified.append(f"{rule}: '{v.get('quote')}' ({v.get('reason', '')})")
|
||||||
|
return verified, ignored
|
||||||
|
|
||||||
|
|
||||||
|
def review_mode(s):
|
||||||
|
mode = s["kids_llm_review"] if s["kids_llm_review"] in REVIEW_MODES else "advisory"
|
||||||
|
if mode == "advisory" and not s["kids_manual_review"]:
|
||||||
|
return "block"
|
||||||
|
return mode
|
||||||
|
|
||||||
|
|
||||||
def produce_script(s, src):
|
def produce_script(s, src):
|
||||||
kids = bool(s["made_for_kids"])
|
kids = bool(s["made_for_kids"])
|
||||||
feedback = []
|
feedback = []
|
||||||
attempts = 4 if kids else 1
|
attempts = 4 if kids else 1
|
||||||
|
mode = review_mode(s)
|
||||||
|
if kids and mode != s["kids_llm_review"]:
|
||||||
|
logger.info("LLM review switched to block mode because manual review is off")
|
||||||
for attempt in range(1, attempts + 1):
|
for attempt in range(1, attempts + 1):
|
||||||
check()
|
check()
|
||||||
set_stage("writing script")
|
set_stage("writing script")
|
||||||
@@ -651,12 +705,24 @@ def produce_script(s, src):
|
|||||||
logger.info("Tags: %s", ", ".join(tags))
|
logger.info("Tags: %s", ", ".join(tags))
|
||||||
set_stage("kids compliance review")
|
set_stage("kids compliance review")
|
||||||
issues = kids_rule_issues(script)
|
issues = kids_rule_issues(script)
|
||||||
ok, llm_issues = kids_llm_review(s, script)
|
notes, ignored = ([], 0) if mode == "off" else kids_llm_review(s, script)
|
||||||
if not ok:
|
if ignored:
|
||||||
issues += llm_issues or ["reviewer rejected the script without details"]
|
logger.info("Ignored %d reviewer findings that did not quote the script or match a rule", ignored)
|
||||||
|
if mode == "block":
|
||||||
|
issues += notes
|
||||||
if not issues:
|
if not issues:
|
||||||
logger.info("Kids compliance review passed on attempt %d", attempt)
|
if notes:
|
||||||
return script, tags, {"kids_checks": True, "script_review": "passed", "attempts": attempt, "dropped_tags": dropped}
|
logger.warning("Reviewer notes (advisory, check during manual review): %s", "; ".join(notes))
|
||||||
|
logger.info("Kids compliance review passed on attempt %d (LLM review: %s)", attempt, mode)
|
||||||
|
return script, tags, {
|
||||||
|
"kids_checks": True,
|
||||||
|
"script_review": "passed",
|
||||||
|
"attempts": attempt,
|
||||||
|
"dropped_tags": dropped,
|
||||||
|
"reviewer_mode": mode,
|
||||||
|
"reviewer_notes": notes,
|
||||||
|
"reviewer_ignored_findings": ignored,
|
||||||
|
}
|
||||||
logger.warning("Kids compliance review failed (attempt %d/%d): %s", attempt, attempts, "; ".join(issues))
|
logger.warning("Kids compliance review failed (attempt %d/%d): %s", attempt, attempts, "; ".join(issues))
|
||||||
feedback = issues
|
feedback = issues
|
||||||
raise RuntimeError(f"Script failed kids compliance review after {attempts} attempts")
|
raise RuntimeError(f"Script failed kids compliance review after {attempts} attempts")
|
||||||
@@ -784,7 +850,11 @@ def run_job(s):
|
|||||||
prepare_storage(s)
|
prepare_storage(s)
|
||||||
logger.info("Profile: %s | Niche: %s | Made for kids: %s | Upload category: %s", s["name"], s["channel_niche"] or "none", kids, s["upload_category_id"])
|
logger.info("Profile: %s | Niche: %s | Made for kids: %s | Upload category: %s", s["name"], s["channel_niche"] or "none", kids, s["upload_category_id"])
|
||||||
if kids:
|
if kids:
|
||||||
logger.info("Kids compliance active: script rules, word filters, LLM review, frame vision checks, made-for-kids flag%s", ", manual review hold" if s["kids_manual_review"] else "")
|
logger.info(
|
||||||
|
"Kids compliance active: script rules, word filters, LLM review (%s), frame vision checks, made-for-kids flag%s",
|
||||||
|
review_mode(s),
|
||||||
|
", manual review hold" if s["kids_manual_review"] else "",
|
||||||
|
)
|
||||||
set_stage("fetching trending videos")
|
set_stage("fetching trending videos")
|
||||||
trending = fetch_trending(s)
|
trending = fetch_trending(s)
|
||||||
state = load_state()
|
state = load_state()
|
||||||
@@ -822,7 +892,7 @@ def run_job(s):
|
|||||||
finalized = False
|
finalized = False
|
||||||
try:
|
try:
|
||||||
script, tags, compliance = produce_script(s, src)
|
script, tags, compliance = produce_script(s, src)
|
||||||
update_history(job_id, title=script["title"])
|
update_history(job_id, title=script["title"], notes="; ".join(compliance.get("reviewer_notes", []))[:400])
|
||||||
write_json(job_dir / "script.json", {"profile": s["name"], "source": src, "script": script, "tags": tags})
|
write_json(job_dir / "script.json", {"profile": s["name"], "source": src, "script": script, "tags": tags})
|
||||||
client = genai.Client(api_key=s["gemini_api_key"])
|
client = genai.Client(api_key=s["gemini_api_key"])
|
||||||
orientation = "Vertical" if s["aspect_ratio"] == "9:16" else "Widescreen"
|
orientation = "Vertical" if s["aspect_ratio"] == "9:16" else "Widescreen"
|
||||||
@@ -992,6 +1062,8 @@ async def post_settings(request: Request):
|
|||||||
p[k] = coerce(PROFILE_DEFAULTS, k, v)
|
p[k] = coerce(PROFILE_DEFAULTS, k, v)
|
||||||
g["storage_dir"] = g["storage_dir"].strip().strip('"') or "videos"
|
g["storage_dir"] = g["storage_dir"].strip().strip('"') or "videos"
|
||||||
p["source_language"] = p["source_language"].lower()[:5]
|
p["source_language"] = p["source_language"].lower()[:5]
|
||||||
|
if p["kids_llm_review"] not in REVIEW_MODES:
|
||||||
|
p["kids_llm_review"] = "advisory"
|
||||||
root = validate_storage(g["storage_dir"])
|
root = validate_storage(g["storage_dir"])
|
||||||
except (TypeError, ValueError) as e:
|
except (TypeError, ValueError) as e:
|
||||||
return JSONResponse({"detail": str(e)}, status_code=400)
|
return JSONResponse({"detail": str(e)}, status_code=400)
|
||||||
|
|||||||
+8
-6
@@ -38,6 +38,7 @@ input,select{width:100%;padding:8px;background:#0f1115;border:1px solid var(--bo
|
|||||||
.grid h3{grid-column:1/-1;margin:10px 0 0;font-size:14px;color:var(--text);border-bottom:1px solid var(--border);padding-bottom:6px}
|
.grid h3{grid-column:1/-1;margin:10px 0 0;font-size:14px;color:var(--text);border-bottom:1px solid var(--border);padding-bottom:6px}
|
||||||
.grid .wide{grid-column:1/-1}
|
.grid .wide{grid-column:1/-1}
|
||||||
.hint{font-size:11px;color:var(--muted);margin-top:3px;word-break:break-all}
|
.hint{font-size:11px;color:var(--muted);margin-top:3px;word-break:break-all}
|
||||||
|
.hint.notes{color:#ffb74d;word-break:normal}
|
||||||
a{color:#64b5f6}
|
a{color:#64b5f6}
|
||||||
.pill{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;background:#394150}
|
.pill{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;background:#394150}
|
||||||
.pill.published{background:#1b5e20}.pill.failed{background:#b71c1c}.pill.running{background:#e65100}.pill.review{background:#6a1b9a}
|
.pill.published{background:#1b5e20}.pill.failed{background:#b71c1c}.pill.running{background:#e65100}.pill.review{background:#6a1b9a}
|
||||||
@@ -105,6 +106,7 @@ const FIELDS=[
|
|||||||
["channel_niche","Channel niche","wide","Steers every script, e.g. 60 second animated movies for kids ages 3 to 7","p"],
|
["channel_niche","Channel niche","wide","Steers every script, e.g. 60 second animated movies for kids ages 3 to 7","p"],
|
||||||
["made_for_kids","Made for kids","checkbox","Flags uploads as made for kids and enforces child-safe rules, word filters, LLM compliance review, and frame vision checks","p"],
|
["made_for_kids","Made for kids","checkbox","Flags uploads as made for kids and enforces child-safe rules, word filters, LLM compliance review, and frame vision checks","p"],
|
||||||
["kids_manual_review","Hold kids videos for manual review","checkbox","Forces made-for-kids uploads to private so you can watch them before publishing","p"],
|
["kids_manual_review","Hold kids videos for manual review","checkbox","Forces made-for-kids uploads to private so you can watch them before publishing","p"],
|
||||||
|
["kids_llm_review","LLM script review","select:advisory=Advisory (show notes, you review)|block=Block (reject flagged scripts)|off=Off","Advisory only applies while manual review is on, otherwise it blocks. Word filters and vision checks always block","p"],
|
||||||
["upload_category_id","Upload category","select:"+CATS,"","p"],
|
["upload_category_id","Upload category","select:"+CATS,"","p"],
|
||||||
["privacy_status","Upload privacy","select:private|unlisted|public","","p"],
|
["privacy_status","Upload privacy","select:private|unlisted|public","","p"],
|
||||||
["trending_query","Trending search query","text","Blank uses the general trending chart. e.g. kids animated story","p"],
|
["trending_query","Trending search query","text","Blank uses the general trending chart. e.g. kids animated story","p"],
|
||||||
@@ -125,10 +127,10 @@ const FIELDS=[
|
|||||||
["vision_model","Ollama vision model","text","Required for made-for-kids profiles. Run: ollama pull llama3.2-vision","g"],
|
["vision_model","Ollama vision model","text","Required for made-for-kids profiles. Run: ollama pull llama3.2-vision","g"],
|
||||||
["ollama_timeout","Ollama timeout (seconds)","number","Max wait per Ollama request before retrying","g"],
|
["ollama_timeout","Ollama timeout (seconds)","number","Max wait per Ollama request before retrying","g"],
|
||||||
["gemini_api_key","Gemini API key (Veo)","password","Billing must be enabled","g"],
|
["gemini_api_key","Gemini API key (Veo)","password","Billing must be enabled","g"],
|
||||||
["veo_model","Veo model","text","Update if Google renames or retires the model","g"],
|
["veo_model","Veo model","text","e.g. veo-3.1-fast-generate-preview. Update if Google renames or retires the model","g"],
|
||||||
["aspect_ratio","Aspect ratio","select:9:16|16:9","","g"],
|
["aspect_ratio","Aspect ratio","select:9:16|16:9","","g"],
|
||||||
["target_seconds","Target length (seconds)","number","Split into clips of the length below","g"],
|
["target_seconds","Target length (seconds)","number","Split into clips of the length below","g"],
|
||||||
["clip_seconds","Seconds per clip","number","Veo 3 clips are 8 seconds","g"],
|
["clip_seconds","Seconds per clip","number","Veo clips are 8 seconds","g"],
|
||||||
["error_cooldown_minutes","Wait after error (minutes)","number","","g"]
|
["error_cooldown_minutes","Wait after error (minutes)","number","","g"]
|
||||||
];
|
];
|
||||||
const $=s=>document.querySelector(s);
|
const $=s=>document.querySelector(s);
|
||||||
@@ -155,9 +157,9 @@ const d=document.createElement("div");if(type==="wide")d.className="wide";
|
|||||||
d.innerHTML=`<label>${label}</label>${input}${hint?`<div class="hint">${esc(hint)}</div>`:""}${extra}`;g.appendChild(d)}
|
d.innerHTML=`<label>${label}</label>${input}${hint?`<div class="hint">${esc(hint)}</div>`:""}${extra}`;g.appendChild(d)}
|
||||||
syncKidsFields()}
|
syncKidsFields()}
|
||||||
function syncKidsFields(){
|
function syncKidsFields(){
|
||||||
const kids=$("#f-made_for_kids");const review=$("#f-kids_manual_review");
|
const kids=$("#f-made_for_kids");
|
||||||
if(!kids||!review)return;
|
if(!kids)return;
|
||||||
review.disabled=!kids.checked;review.closest("div").style.opacity=kids.checked?1:.5;
|
for(const id of ["#f-kids_manual_review","#f-kids_llm_review"]){const el=$(id);if(!el)continue;el.disabled=!kids.checked;el.closest("div").style.opacity=kids.checked?1:.5}
|
||||||
kids.onchange=syncKidsFields}
|
kids.onchange=syncKidsFields}
|
||||||
async function saveSettings(){
|
async function saveSettings(){
|
||||||
const body={global:{},profile:{}};
|
const body={global:{},profile:{}};
|
||||||
@@ -176,7 +178,7 @@ $("#st-free").style.color=s.storage_free_gb!==null&&s.storage_free_gb<10?"#ffb74
|
|||||||
$("#st-after").textContent=s.delete_after_publish?"Delete files":"Move to published";
|
$("#st-after").textContent=s.delete_after_publish?"Delete files":"Move to published";
|
||||||
$("#kids-badge").style.display=s.made_for_kids?"inline-block":"none";
|
$("#kids-badge").style.display=s.made_for_kids?"inline-block":"none";
|
||||||
$("#go").disabled=s.running;$("#stop").disabled=!s.running;
|
$("#go").disabled=s.running;$("#stop").disabled=!s.running;
|
||||||
$("#jobs").innerHTML="<tr><th>Started</th><th>Profile</th><th>Source</th><th>Title</th><th>Status</th><th>Video</th></tr>"+s.history.map(h=>`<tr><td>${esc(h.started)}</td><td>${esc(h.profile)}</td><td><a target="_blank" href="https://youtube.com/watch?v=${esc(h.source_id)}">${esc(h.source_title)}</a></td><td>${esc(h.title)}</td><td><span class="pill ${esc(h.status)}">${h.status==="review"?"private, needs review":esc(h.status)}</span>${h.error?`<div class="hint">${esc(h.error)}</div>`:""}${h.location?`<div class="hint">${esc(h.location)}</div>`:""}</td><td>${h.youtube_id?`<a target="_blank" href="https://studio.youtube.com/video/${esc(h.youtube_id)}/edit">studio</a> <a target="_blank" href="https://youtube.com/shorts/${esc(h.youtube_id)}">open</a>`:""}</td></tr>`).join("")
|
$("#jobs").innerHTML="<tr><th>Started</th><th>Profile</th><th>Source</th><th>Title</th><th>Status</th><th>Video</th></tr>"+s.history.map(h=>`<tr><td>${esc(h.started)}</td><td>${esc(h.profile)}</td><td><a target="_blank" href="https://youtube.com/watch?v=${esc(h.source_id)}">${esc(h.source_title)}</a></td><td>${esc(h.title)}</td><td><span class="pill ${esc(h.status)}">${h.status==="review"?"private, needs review":esc(h.status)}</span>${h.notes?`<div class="hint notes">Reviewer notes: ${esc(h.notes)}</div>`:""}${h.error?`<div class="hint">${esc(h.error)}</div>`:""}${h.location?`<div class="hint">${esc(h.location)}</div>`:""}</td><td>${h.youtube_id?`<a target="_blank" href="https://studio.youtube.com/video/${esc(h.youtube_id)}/edit">studio</a> <a target="_blank" href="https://youtube.com/shorts/${esc(h.youtube_id)}">open</a>`:""}</td></tr>`).join("")
|
||||||
}catch(e){}}
|
}catch(e){}}
|
||||||
let since=0;
|
let since=0;
|
||||||
async function pollLogs(){
|
async function pollLogs(){
|
||||||
|
|||||||
Reference in New Issue
Block a user