Add working hours and scheduled publishing

This commit is contained in:
Justin Oros
2026-09-21 13:33:47 -07:00
parent af59e9b83d
commit 451e318860
2 changed files with 150 additions and 6 deletions
+124 -3
View File
@@ -12,6 +12,7 @@ from collections import deque
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from pathlib import Path from pathlib import Path
from zoneinfo import ZoneInfo
import requests import requests
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
@@ -79,6 +80,10 @@ GLOBAL_DEFAULTS = {
"target_seconds": 60, "target_seconds": 60,
"clip_seconds": 8, "clip_seconds": 8,
"error_cooldown_minutes": 5, "error_cooldown_minutes": 5,
"work_hours_enabled": False,
"work_days": "mon,tue,wed,thu,fri,sat,sun",
"work_start": "09:00",
"work_end": "17:00",
} }
PROFILE_DEFAULTS = { PROFILE_DEFAULTS = {
@@ -99,6 +104,12 @@ PROFILE_DEFAULTS = {
"trending_count": 25, "trending_count": 25,
"videos_per_day": 0, "videos_per_day": 0,
"minutes_between_videos": 0, "minutes_between_videos": 0,
"publish_mode": "immediate",
"publish_timezone": "America/New_York",
"publish_days": "mon,tue,wed,thu,fri,sat,sun",
"publish_time_1": "08:00",
"publish_time_2": "",
"publish_time_3": "",
} }
KIDS_RULES = """The audience is young children under 13. Follow every one of these rules: KIDS_RULES = """The audience is young children under 13. Follow every one of these rules:
@@ -587,6 +598,91 @@ def migrate():
logger.info("Created Default profile from existing settings") logger.info("Created Default profile from existing settings")
DAY_KEYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
def parse_hm(value):
m = re.fullmatch(r"(\d{1,2}):(\d{2})", str(value or "").strip())
if not m:
return None
h, mi = int(m.group(1)), int(m.group(2))
if h > 23 or mi > 59:
return None
return h, mi
def day_set(value):
return {d.strip() for d in str(value or "").lower().split(",") if d.strip() in DAY_KEYS}
def work_window(s, now=None):
if not s["work_hours_enabled"]:
return True, None
now = now or datetime.now()
days = day_set(s["work_days"])
if not days:
return False, None
start = parse_hm(s["work_start"]) or (0, 0)
end = parse_hm(s["work_end"]) or (23, 59)
t = (now.hour, now.minute)
today = DAY_KEYS[now.weekday()]
yesterday = DAY_KEYS[(now.weekday() - 1) % 7]
if start < end:
inside = today in days and start <= t < end
else:
inside = (t >= start and today in days) or (t < end and yesterday in days)
if inside:
return True, None
for offset in range(0, 8):
d = now.date() + timedelta(days=offset)
cand = datetime(d.year, d.month, d.day, start[0], start[1])
if cand > now and DAY_KEYS[cand.weekday()] in days:
return False, cand
return False, None
def publish_zone(s):
try:
return ZoneInfo(s["publish_timezone"] or "America/New_York")
except Exception:
return ZoneInfo("America/New_York")
def next_publish_slot(s):
if s["publish_mode"] != "scheduled":
return None
times = sorted({hm for hm in (parse_hm(s[k]) for k in ("publish_time_1", "publish_time_2", "publish_time_3")) if hm})
days = day_set(s["publish_days"])
if not times or not days:
return None
tz = publish_zone(s)
now = datetime.now(tz)
earliest = now + timedelta(minutes=30)
taken = set(load_state().get("scheduled", {}).get(s["profile_id"], []))
for offset in range(0, 90):
d = (now + timedelta(days=offset)).date()
if DAY_KEYS[d.weekday()] not in days:
continue
for h, m in times:
slot = datetime(d.year, d.month, d.day, h, m, tzinfo=tz)
if slot < earliest:
continue
iso = slot.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
if iso not in taken:
return iso
return None
def record_slot(pid, iso):
with state_lock:
st = load_state()
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
slots = [x for x in st.setdefault("scheduled", {}).get(pid, []) if x > now_iso]
slots.append(iso)
st["scheduled"][pid] = sorted(set(slots))
save_state(st)
def load_state(): def load_state():
with state_lock: with state_lock:
st = read_json(STATE_FILE, {}) st = read_json(STATE_FILE, {})
@@ -1444,9 +1540,20 @@ def upload_video(s, path, script, tags):
description = re.sub(r"[<>]", "", f"{script['description']}\n\n#Shorts {' '.join(hashtags)}").strip()[:4500] description = re.sub(r"[<>]", "", f"{script['description']}\n\n#Shorts {' '.join(hashtags)}").strip()[:4500]
privacy = s["privacy_status"] privacy = s["privacy_status"]
held = kids and s["kids_manual_review"] held = kids and s["kids_manual_review"]
publish_at = None
if held: if held:
privacy = "private" privacy = "private"
logger.info("Holding made-for-kids video as private for manual review") logger.info("Holding made-for-kids video as private for manual review")
if s["publish_mode"] == "scheduled":
logger.info("Publishing schedule skipped because manual review hold is on")
else:
publish_at = next_publish_slot(s)
if publish_at:
privacy = "private"
local = datetime.strptime(publish_at, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc).astimezone(publish_zone(s))
logger.info("Scheduled to go public %s", local.strftime("%a %b %d %I:%M %p %Z"))
elif s["publish_mode"] == "scheduled":
logger.warning("Publishing schedule has no valid days or times, uploading as %s", privacy)
body = { body = {
"snippet": { "snippet": {
"title": title, "title": title,
@@ -1460,6 +1567,8 @@ def upload_video(s, path, script, tags):
"containsSyntheticMedia": bool(s["ai_disclosure"]), "containsSyntheticMedia": bool(s["ai_disclosure"]),
}, },
} }
if publish_at:
body["status"]["publishAt"] = publish_at
logger.info( logger.info(
"Uploading to %s as category %s, made for kids: %s, AI disclosure: %s", "Uploading to %s as category %s, made for kids: %s, AI disclosure: %s",
profile_channel(s["profile_id"]), body["snippet"]["categoryId"], kids, bool(s["ai_disclosure"]), profile_channel(s["profile_id"]), body["snippet"]["categoryId"], kids, bool(s["ai_disclosure"]),
@@ -1473,7 +1582,9 @@ def upload_video(s, path, script, tags):
if prog: if prog:
logger.info("Upload %d%%", int(prog.progress() * 100)) logger.info("Upload %d%%", int(prog.progress() * 100))
logger.info("Uploaded: https://youtube.com/shorts/%s (%s)", resp["id"], privacy) logger.info("Uploaded: https://youtube.com/shorts/%s (%s)", resp["id"], privacy)
return resp["id"], held if publish_at:
record_slot(s["profile_id"], publish_at)
return resp["id"], held, publish_at
def run_job(s): def run_job(s):
@@ -1620,7 +1731,8 @@ def run_job(s):
final = job_dir / "final.mp4" final = job_dir / "final.mp4"
concat_clips(clips, final, s["aspect_ratio"]) concat_clips(clips, final, s["aspect_ratio"])
set_stage("uploading to YouTube", step=4 + n * 2) set_stage("uploading to YouTube", step=4 + n * 2)
vid, held = upload_video(s, final, script, tags) vid, held, publish_at = upload_video(s, final, script, tags)
compliance["publish_at"] = publish_at
compliance["made_for_kids_flag"] = kids compliance["made_for_kids_flag"] = kids
compliance["synthetic_media_flag"] = bool(s["ai_disclosure"]) compliance["synthetic_media_flag"] = bool(s["ai_disclosure"])
compliance["held_for_review"] = held compliance["held_for_review"] = held
@@ -1639,7 +1751,8 @@ def run_job(s):
c.unlink(missing_ok=True) c.unlink(missing_ok=True)
set_stage("moving files") set_stage("moving files")
location = finish_files(s, job_dir, job_id) location = finish_files(s, job_dir, job_id)
update_history(job_id, status="review" if held else "published", youtube_id=vid, location=location, finished_ts=int(datetime.now().timestamp())) final_status = "review" if held else ("scheduled" if publish_at else "published")
update_history(job_id, status=final_status, youtube_id=vid, location=location, publish_at=publish_at, finished_ts=int(datetime.now().timestamp()))
set_stage("job complete", step=status["steps"]) set_stage("job complete", step=status["steps"])
except Cancelled: except Cancelled:
update_history(job_id, status="cancelled", finished_ts=int(datetime.now().timestamp())) update_history(job_id, status="cancelled", finished_ts=int(datetime.now().timestamp()))
@@ -1659,6 +1772,14 @@ def run_loop():
try: try:
while not stop_event.is_set(): while not stop_event.is_set():
s = load_settings() s = load_settings()
ok, resume = work_window(s)
if not ok:
msg = f"outside working hours, resumes {resume.strftime('%a %I:%M %p')}" if resume else "outside working hours, no work days selected"
if status["stage"] != msg:
logger.info("Outside working hours, %s", msg.split(", ", 1)[1])
set_stage(msg, log=False)
stop_event.wait(60)
continue
limit = int(s["videos_per_day"]) limit = int(s["videos_per_day"])
if limit > 0 and published_today(s["profile_id"]) >= limit: if limit > 0 and published_today(s["profile_id"]) >= limit:
if status["stage"] != "daily limit reached": if status["stage"] != "daily limit reached":
+26 -3
View File
@@ -47,7 +47,7 @@ input,select{width:100%;padding:8px;background:#0f1115;border:1px solid var(--bo
.hint.notes{color:#ffb74d;word-break:normal} .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}.pill.scheduled{background:#1565c0}
</style> </style>
</head> </head>
<body> <body>
@@ -130,6 +130,18 @@ const FIELDS=[
["trending_count","Videos to scan","select:10|25|50","","p"], ["trending_count","Videos to scan","select:10|25|50","","p"],
["videos_per_day","Videos per day","select:0=Unlimited|1|2|3|4|5|6|8|10|12|15|20","The default YouTube API quota allows about 6 uploads a day. Higher values need a quota increase from Google","p"], ["videos_per_day","Videos per day","select:0=Unlimited|1|2|3|4|5|6|8|10|12|15|20","The default YouTube API quota allows about 6 uploads a day. Higher values need a quota increase from Google","p"],
["minutes_between_videos","Minutes between videos","select:0=No gap|30|60|120|180|240|360","","p"], ["minutes_between_videos","Minutes between videos","select:0=No gap|30|60|120|180|240|360","","p"],
["","Publishing schedule","header","",""],
["publish_mode","When to publish","select:immediate=As soon as it is uploaded|scheduled=At set days and times","Scheduled videos upload as private and YouTube makes them public at the next open time slot","p"],
["publish_timezone","Publishing time zone","select:America/New_York=Eastern (US and Canada)|America/Chicago=Central (US and Canada)|America/Denver=Mountain (US and Canada)|America/Phoenix=Arizona|America/Los_Angeles=Pacific (US and Canada)|America/Anchorage=Alaska|Pacific/Honolulu=Hawaii|America/Halifax=Atlantic (Canada)|America/Mexico_City=Mexico City|Europe/London=London|Europe/Berlin=Central Europe|Asia/Kolkata=India|Australia/Sydney=Sydney|UTC=UTC","Times below are in this zone, pick your audience's","p"],
["publish_days","Publishing days","days","","p"],
["publish_time_1","Publish time 1","time","","p"],
["publish_time_2","Publish time 2","time:opt","Optional","p"],
["publish_time_3","Publish time 3","time:opt","Optional","p"],
["","Working hours (shared by all profiles)","header","",""],
["work_hours_enabled","Only work during set hours","checkbox","Outside these hours no new video is started. A video already in progress finishes first. Uses this computer's clock","g"],
["work_days","Working days","days","","g"],
["work_start","Start work at","time","","g"],
["work_end","Stop starting new videos at","time","If this is earlier than the start time, the window runs overnight","g"],
["","Folders (shared by all profiles)","header","",""], ["","Folders (shared by all profiles)","header","",""],
["storage_dir","Storage folder","wide","Full path on a large disk, e.g. /Volumes/iDrive/shorts-autopilot. Jobs are built in output/ and moved to published/","g"], ["storage_dir","Storage folder","wide","Full path on a large disk, e.g. /Volumes/iDrive/shorts-autopilot. Jobs are built in output/ and moved to published/","g"],
["models_dir","Models folder","wide","Where AI models download to (several GB). Blank puts them in the storage folder","g"], ["models_dir","Models folder","wide","Where AI models download to (several GB). Blank puts them in the storage folder","g"],
@@ -160,6 +172,8 @@ const FIELDS=[
["clip_seconds","Seconds per scene","select:5|6|8|10|12","8 gives 7 scenes in a 60 second Short","g"], ["clip_seconds","Seconds per scene","select:5|6|8|10|12","8 gives 7 scenes in a 60 second Short","g"],
["error_cooldown_minutes","Wait after error (minutes)","select:1|5|10|15|30|60","","g"] ["error_cooldown_minutes","Wait after error (minutes)","select:1|5|10|15|30|60","","g"]
]; ];
const DAYS=[["mon","Mon"],["tue","Tue"],["wed","Wed"],["thu","Thu"],["fri","Fri"],["sat","Sat"],["sun","Sun"]];
const TIMES=[];for(let h=0;h<24;h++)for(const m of [0,30]){const v=String(h).padStart(2,"0")+":"+String(m).padStart(2,"0");const hr=h%12||12;TIMES.push([v,hr+":"+String(m).padStart(2,"0")+(h<12?" AM":" PM")])}
const $=s=>document.querySelector(s); const $=s=>document.querySelector(s);
const esc=s=>String(s??"").replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c])); const esc=s=>String(s??"").replace(/[&<>"]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"}[c]));
async function api(path,opts={}){const r=await fetch(path,{headers:{"Content-Type":"application/json"},...opts});const j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.detail||r.statusText);return j} async function api(path,opts={}){const r=await fetch(path,{headers:{"Content-Type":"application/json"},...opts});const j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.detail||r.statusText);return j}
@@ -177,6 +191,8 @@ const s=scope==="g"?data.global:data.profile;
let input; let input;
if(type.startsWith("select:")){const list=type.slice(7).split("|").map(opt);const cur=String(s[k]??"");if(!list.some(([v])=>v===cur))list.unshift([cur,cur+" (current)"]);input=`<select id="f-${k}">${list.map(([v,l])=>`<option value="${esc(v)}" ${cur===v?"selected":""}>${esc(l)}</option>`).join("")}</select>`} if(type.startsWith("select:")){const list=type.slice(7).split("|").map(opt);const cur=String(s[k]??"");if(!list.some(([v])=>v===cur))list.unshift([cur,cur+" (current)"]);input=`<select id="f-${k}">${list.map(([v,l])=>`<option value="${esc(v)}" ${cur===v?"selected":""}>${esc(l)}</option>`).join("")}</select>`}
else if(type==="checkbox"){input=`<input type="checkbox" id="f-${k}" ${s[k]?"checked":""} style="width:auto">`} else if(type==="checkbox"){input=`<input type="checkbox" id="f-${k}" ${s[k]?"checked":""} style="width:auto">`}
else if(type==="days"){const on=new Set(String(s[k]||"").split(","));input=`<div class="row" id="f-${k}" data-days="1" style="gap:10px">${DAYS.map(([v,l])=>`<label style="margin:0;display:flex;gap:4px;align-items:center;color:var(--text)"><input type="checkbox" value="${v}" ${on.has(v)?"checked":""} style="width:auto">${l}</label>`).join("")}</div>`}
else if(type.startsWith("time")){const cur=String(s[k]||"");const opts=(type==="time:opt"?[["","None"]]:[]).concat(TIMES);if(cur&&!opts.some(([v])=>v===cur))opts.push([cur,cur]);input=`<select id="f-${k}">${opts.map(([v,l])=>`<option value="${esc(v)}" ${cur===v?"selected":""}>${esc(l)}</option>`).join("")}</select>`}
else if(type.startsWith("model:")){ else if(type.startsWith("model:")){
const kind=type.slice(6); const kind=type.slice(6);
const installed=data.installed_models||[]; const installed=data.installed_models||[];
@@ -201,6 +217,11 @@ const kids=$("#f-made_for_kids");
if(kids){ if(kids){
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} 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=syncFields} kids.onchange=syncFields}
const dim=(ids,off)=>{for(const id of ids){const el=$(id);if(!el)continue;el.querySelectorAll?el.querySelectorAll("input").forEach(x=>x.disabled=off):0;if(el.tagName!=="DIV")el.disabled=off;el.closest(".grid > div").style.opacity=off?.5:1}};
const wh=$("#f-work_hours_enabled");
if(wh){dim(["#f-work_days","#f-work_start","#f-work_end"],!wh.checked);wh.onchange=syncFields}
const pm=$("#f-publish_mode");
if(pm){dim(["#f-publish_timezone","#f-publish_days","#f-publish_time_1","#f-publish_time_2","#f-publish_time_3"],pm.value!=="scheduled");pm.onchange=syncFields}
const sdm=$("#f-sd_model"); const sdm=$("#f-sd_model");
if(sdm&&!sdm.dataset.wired){sdm.dataset.wired="1";sdm.addEventListener("change",()=>{ if(sdm&&!sdm.dataset.wired){sdm.dataset.wired="1";sdm.addEventListener("change",()=>{
const best=sdm.value.includes("stable-diffusion-xl-base"); const best=sdm.value.includes("stable-diffusion-xl-base");
@@ -215,7 +236,9 @@ eng.onchange=syncFields}}
async function saveSettings(){ async function saveSettings(){
const body={global:{},profile:{}}; const body={global:{},profile:{}};
const NUMERIC=new Set(["sd_steps","sd_guidance","sd_width","sd_height","trending_days","trending_count","videos_per_day","minutes_between_videos","target_seconds","clip_seconds","ollama_timeout","error_cooldown_minutes","tts_rate"]); const NUMERIC=new Set(["sd_steps","sd_guidance","sd_width","sd_height","trending_days","trending_count","videos_per_day","minutes_between_videos","target_seconds","clip_seconds","ollama_timeout","error_cooldown_minutes","tts_rate"]);
for(const [k,,type,,scope] of FIELDS){if(type==="header")continue;const el=$("#f-"+k);body[scope==="g"?"global":"profile"][k]=type==="checkbox"?el.checked:(type==="number"||NUMERIC.has(k))?Number(el.value):el.value} for(const [k,,type,,scope] of FIELDS){if(type==="header")continue;const el=$("#f-"+k);const dest=body[scope==="g"?"global":"profile"];
if(type==="days"){dest[k]=[...el.querySelectorAll("input:checked")].map(x=>x.value).join(",");continue}
dest[k]=type==="checkbox"?el.checked:(type==="number"||NUMERIC.has(k))?Number(el.value):el.value}
try{await api("/api/settings",{method:"POST",body:JSON.stringify(body)});await loadSettings();$("#saved").textContent="Saved "+new Date().toLocaleTimeString();refresh()}catch(e){$("#saved").textContent="Error: "+e.message}} try{await api("/api/settings",{method:"POST",body:JSON.stringify(body)});await loadSettings();$("#saved").textContent="Saved "+new Date().toLocaleTimeString();refresh()}catch(e){$("#saved").textContent="Error: "+e.message}}
async function activate(id){try{await api("/api/profiles/activate",{method:"POST",body:JSON.stringify({id})})}catch(e){alert(e.message)}await loadSettings();refresh()} async function activate(id){try{await api("/api/profiles/activate",{method:"POST",body:JSON.stringify({id})})}catch(e){alert(e.message)}await loadSettings();refresh()}
async function refresh(){ async function refresh(){
@@ -242,7 +265,7 @@ $("#pg-meta").textContent=pct+"% | step "+s.step+" of "+s.steps+(s.elapsed?" | "
const lb=$("#link");lb.textContent=s.channel?"Unlink YouTube Channel":"Link YouTube Channel";lb.dataset.linked=s.channel?"1":""; const lb=$("#link");lb.textContent=s.channel?"Unlink YouTube Channel":"Link YouTube Channel";lb.dataset.linked=s.channel?"1":"";
setButton(s.running); setButton(s.running);
const dur=h=>{if(!h.started_ts)return"";const end=h.finished_ts||(h.status==="running"?s.now:0);if(!end)return"";const d=Math.max(0,end-h.started_ts);const m=Math.floor(d/60),sec=d%60;return(m?m+"m ":"")+sec+"s"}; const dur=h=>{if(!h.started_ts)return"";const end=h.finished_ts||(h.status==="running"?s.now:0);if(!end)return"";const d=Math.max(0,end-h.started_ts);const m=Math.floor(d/60),sec=d%60;return(m?m+"m ":"")+sec+"s"};
$("#jobs").innerHTML="<tr><th>Started</th><th>Time</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 style="white-space:nowrap">${esc(dur(h))}</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("") $("#jobs").innerHTML="<tr><th>Started</th><th>Time</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 style="white-space:nowrap">${esc(dur(h))}</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":h.status==="scheduled"&&h.publish_at?"scheduled "+new Date(h.publish_at).toLocaleString([], {weekday:"short",month:"short",day:"numeric",hour:"numeric",minute:"2-digit"}):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(){