2026-09-17 18:38:39 -07:00
<!doctype html>
< html lang = "en" >
< head >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width,initial-scale=1" >
< title > Shorts Autopilot</ title >
< style >
: root { --bg : #0f1115 ; --panel : #181b22 ; --border : #2a2f3a ; --text : #e6e8ee ; --muted : #8a93a6 ; --accent : #e53935 ; --ok : #43a047 }
* { box-sizing : border-box }
body { margin : 0 ; font-family : system-ui , "Segoe UI" , Roboto , sans-serif ; background : var ( -- bg ); color : var ( -- text )}
header { display : flex ; align-items : center ; gap : 16 px ; padding : 14 px 20 px ; border-bottom : 1 px solid var ( -- border )}
header h1 { font-size : 18 px ; margin : 0 ; flex : 1 }
nav button { background : none ; border : none ; color : var ( -- muted ); padding : 8 px 12 px ; cursor : pointer ; font-size : 14 px }
nav button . active { color : var ( -- text ); border-bottom : 2 px solid var ( -- accent )}
main { padding : 20 px ; max-width : 1100 px ; margin : 0 auto }
. panel { background : var ( -- panel ); border : 1 px solid var ( -- border ); border-radius : 10 px ; padding : 16 px ; margin-bottom : 16 px ; overflow-x : auto }
. row { display : flex ; gap : 12 px ; align-items : center ; flex-wrap : wrap }
. btn { border : none ; border-radius : 8 px ; padding : 10 px 18 px ; font-size : 15 px ; cursor : pointer ; color : #fff ; background : #3949ab ; text-decoration : none ; display : inline-block }
. btn : disabled { opacity : .4 ; cursor : default }
2026-09-20 16:03:48 -07:00
. btn . go { background : var ( -- ok ); font-size : 20 px ; padding : 14 px 40 px ; min-width : 150 px }
. btn . go . running { background : var ( -- accent )}
. btn . go . busy { background : #e65100 }
2026-09-17 18:38:39 -07:00
. btn . stop { background : var ( -- accent )}
. btn . secondary { background : #394150 }
. stat { min-width : 150 px }
. stat . k { color : var ( -- muted ); font-size : 12 px }
. stat . v { font-size : 17 px ; margin-top : 4 px }
. stat . v . small { font-size : 13 px ; word-break : break-all }
. stat select { min-width : 200 px ; margin-top : 4 px }
2026-09-20 16:49:03 -07:00
# progress-panel { display : none }
. bar { height : 10 px ; background : #0f1115 ; border : 1 px solid var ( -- border ); border-radius : 6 px ; overflow : hidden ; margin-top : 6 px }
. bar span { display : block ; height : 100 % ; width : 0 ; background : var ( -- ok ); transition : width .4 s ease }
. bar . done span { background : #1b5e20 }
2026-09-17 18:38:39 -07:00
. badge { display : none ; background : #6a1b9a ; color : #fff ; border-radius : 10 px ; padding : 3 px 10 px ; font-size : 12 px }
table { width : 100 % ; border-collapse : collapse ; font-size : 13 px }
th , td { text-align : left ; padding : 6 px 8 px ; border-bottom : 1 px solid var ( -- border ); vertical-align : top }
th { color : var ( -- muted ); font-weight : 500 }
. tab { display : none }. tab . active { display : block }
# log { background : #0a0c10 ; border : 1 px solid var ( -- border ); border-radius : 8 px ; height : 65 vh ; overflow : auto ; padding : 10 px ; font-family : Consolas , monospace ; font-size : 12 px ; white-space : pre-wrap }
. ERROR , . CRITICAL { color : #ef5350 }. WARNING { color : #ffb74d }. DEBUG { color : #78909c }
label { display : block ; font-size : 12 px ; color : var ( -- muted ); margin-bottom : 4 px }
input , select { width : 100 % ; padding : 8 px ; background : #0f1115 ; border : 1 px solid var ( -- border ); border-radius : 6 px ; color : var ( -- text )}
. grid { display : grid ; grid-template-columns : repeat ( auto - fill , minmax ( 260 px , 1 fr )); gap : 14 px }
. grid h3 { grid-column : 1 /- 1 ; margin : 10 px 0 0 ; font-size : 14 px ; color : var ( -- text ); border-bottom : 1 px solid var ( -- border ); padding-bottom : 6 px }
. grid . wide { grid-column : 1 /- 1 }
. hint { font-size : 11 px ; color : var ( -- muted ); margin-top : 3 px ; word-break : break-all }
2026-09-17 22:58:33 -07:00
. hint . notes { color : #ffb74d ; word-break : normal }
2026-09-17 18:38:39 -07:00
a { color : #64b5f6 }
. pill { display : inline-block ; padding : 2 px 8 px ; border-radius : 10 px ; font-size : 11 px ; background : #394150 }
. pill . published { background : #1b5e20 }. pill . failed { background : #b71c1c }. pill . running { background : #e65100 }. pill . review { background : #6a1b9a }
</ style >
</ head >
< body >
< header >
< h1 > Shorts Autopilot</ h1 >
< nav >
< button data-tab = "dash" class = "active" > Dashboard</ button >
< button data-tab = "settings" > Settings</ button >
< button data-tab = "logs" > Log</ button >
</ nav >
</ header >
< main >
< section id = "dash" class = "tab active" >
< div class = "panel row" >
< button id = "go" class = "btn go" > GO</ button >
< div class = "stat" >< div class = "k" > Active profile</ div >< select id = "prof-dash" class = "prof-select" ></ select ></ div >
< div class = "stat" >< div class = "k" > Status</ div >< div class = "v" id = "st-stage" > idle</ div ></ div >
2026-09-20 16:03:48 -07:00
< div class = "stat" >< div class = "k" > Engine</ div >< div class = "v" id = "st-engine" ></ div ></ div >
2026-09-17 18:38:39 -07:00
< div class = "stat" >< div class = "k" > Published today</ div >< div class = "v" id = "st-today" > 0</ div ></ div >
< div class = "stat" >< div class = "k" > Channel</ div >< div class = "v" id = "st-channel" > Not linked</ div ></ div >
< span class = "badge" id = "kids-badge" > Made for kids: compliance checks on</ span >
</ div >
2026-09-20 16:49:03 -07:00
< div class = "panel" id = "progress-panel" >
< div class = "row" style = "justify-content:space-between" >< div id = "pg-stage" style = "font-size:15px" ></ div >< div class = "hint" id = "pg-meta" ></ div ></ div >
< div class = "bar" >< span id = "pg-fill" ></ span ></ div >
</ div >
2026-09-17 18:38:39 -07:00
< div class = "panel row" >
< div class = "stat" >< div class = "k" > Storage folder</ div >< div class = "v small" id = "st-storage" ></ div ></ div >
< div class = "stat" >< div class = "k" > Free space</ div >< div class = "v" id = "st-free" > ?</ div ></ div >
< div class = "stat" >< div class = "k" > After publishing</ div >< div class = "v" id = "st-after" ></ div ></ div >
</ div >
< div class = "panel row" >
2026-09-20 16:03:48 -07:00
< button class = "btn secondary" id = "link" > Link YouTube Channel</ button >
< button class = "btn secondary" id = "preview" > Preview Trending Videos</ button >
2026-09-17 18:38:39 -07:00
< span class = "hint" id = "preview-msg" ></ span >
</ div >
< div class = "panel" id = "trending-panel" style = "display:none" >< h3 > Trending</ h3 >< table id = "trending" ></ table ></ div >
2026-09-20 16:11:33 -07:00
< div class = "panel" >< div class = "row" style = "justify-content:space-between" >< h3 style = "margin:0" > Jobs</ h3 >< button class = "btn secondary" id = "clear-jobs" style = "padding:6px 12px;font-size:13px" > Clear View</ button ></ div >< table id = "jobs" style = "margin-top:10px" ></ table ></ div >
2026-09-17 18:38:39 -07:00
</ section >
< section id = "settings" class = "tab" >
< div class = "panel row" >
< div class = "stat" >< div class = "k" > Editing profile</ div >< select id = "prof-set" class = "prof-select" ></ select ></ div >
< button class = "btn secondary" id = "new-profile" > New profile</ button >
< button class = "btn stop" id = "del-profile" > Delete profile</ button >
</ div >
< div class = "panel" >
< div class = "grid" id = "settings-grid" ></ div >
< div class = "row" style = "margin-top:16px" >< button class = "btn" id = "save" > Save settings</ button >< span id = "saved" class = "hint" ></ span ></ div >
</ div >
</ section >
< section id = "logs" class = "tab" >
< div class = "panel row" >
< label style = "margin:0;display:flex;gap:6px;align-items:center" >< input type = "checkbox" id = "autoscroll" checked style = "width:auto" > Auto scroll</ label >
2026-09-20 16:11:33 -07:00
< button class = "btn secondary" id = "copy-log" > Copy Log</ button >
< button class = "btn secondary" id = "clear" > Clear View</ button >
2026-09-17 18:38:39 -07:00
< a href = "/api/logs/download" > Download full log</ a >
2026-09-20 16:11:33 -07:00
< span class = "hint" id = "copy-msg" ></ span >
2026-09-17 18:38:39 -07:00
</ div >
< div id = "log" ></ div >
</ section >
</ main >
< script >
const CATS = "1=Film and Animation|2=Autos and Vehicles|10=Music|15=Pets and Animals|17=Sports|19=Travel and Events|20=Gaming|22=People and Blogs|23=Comedy|24=Entertainment|25=News and Politics|26=Howto and Style|27=Education|28=Science and Technology" ;
const FIELDS = [
[ "" , "Profile: audience and channel" , "header" , "" , "" ],
[ "name" , "Profile name" , "text" , "" , "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" ],
[ "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" ],
2026-09-17 22:58:33 -07:00
[ "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" ],
2026-09-17 18:38:39 -07:00
[ "upload_category_id" , "Upload category" , "select:" + CATS , "" , "p" ],
[ "privacy_status" , "Upload privacy" , "select:private|unlisted|public" , "" , "p" ],
2026-09-20 17:16:41 -07:00
[ "ai_disclosure" , "Disclose AI generated content" , "checkbox" , "YouTube only requires this for realistic synthetic content, not cartoons. Leave on to be transparent with parents" , "p" ],
2026-09-17 18:38:39 -07:00
[ "trending_query" , "Trending search query" , "text" , "Blank uses the general trending chart. e.g. kids animated story" , "p" ],
[ "trending_days" , "Search window (days)" , "number" , "Only used with a search query" , "p" ],
[ "category_id" , "Source category" , "select:=All|" + CATS , "Filters source videos" , "p" ],
[ "region_code" , "Region" , "text" , "Two-letter country code, e.g. US" , "p" ],
2026-09-17 22:37:25 -07:00
[ "source_language" , "Source language" , "text" , "Two-letter code, e.g. en. Skips source videos in other languages. Blank for any" , "p" ],
2026-09-17 18:38:39 -07:00
[ "trending_count" , "Videos to scan" , "number" , "Max 50" , "p" ],
[ "videos_per_day" , "Videos per day" , "number" , "0 runs continuously" , "p" ],
[ "minutes_between_videos" , "Minutes between videos" , "number" , "0 for no gap" , "p" ],
2026-09-20 16:03:48 -07:00
[ "" , "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" ],
[ "models_dir" , "Models folder" , "wide" , "Where AI models download to (several GB). Blank puts them in the storage folder" , "g" ],
[ "temp_dir" , "Temp folder" , "wide" , "Scratch space used while rendering. Blank puts it in the storage folder" , "g" ],
2026-09-17 18:38:39 -07:00
[ "delete_after_publish" , "Delete videos after publishing" , "checkbox" , "Removes the job folder once the upload succeeds instead of moving it to published" , "g" ],
2026-09-20 16:03:48 -07:00
[ "keep_clips" , "Keep individual clips" , "checkbox" , "Keeps the scene clips, images, and narration alongside final.mp4" , "g" ],
2026-09-20 16:44:32 -07:00
[ "auto_install" , "Install missing pieces automatically" , "checkbox" , "Downloads models and installs packages when a run needs them" , "g" ],
2026-09-20 16:03:48 -07:00
[ "" , "Video engine (shared by all profiles)" , "header" , "" , "" ],
[ "video_engine" , "Video engine" , "select:local=Local (free: AI images, narration, motion)|veo=Veo (paid Google API)" , "Local runs entirely on this machine at no cost. Veo makes true AI video but needs billing" , "g" ],
[ "sd_model" , "Image model" , "text" , "Hugging Face model id, e.g. stabilityai/sdxl-turbo or stabilityai/stable-diffusion-xl-base-1.0" , "g" ],
[ "sd_steps" , "Image steps" , "number" , "4 for turbo models, 20 to 30 for standard models" , "g" ],
2026-09-20 16:45:15 -07:00
[ "sd_guidance" , "Image guidance" , "number" , "2 for turbo models, 7 for standard models. Below 1.1 the anatomy filter stops working" , "g" ],
2026-09-20 16:11:33 -07:00
[ "sd_width" , "Image width" , "number" , "768 avoids duplicated subjects. Larger is slower" , "g" ],
[ "sd_height" , "Image height" , "number" , "1344 is the correct vertical ratio for SDXL" , "g" ],
2026-09-20 16:44:32 -07:00
[ "tts_engine" , "Narration engine" , "select:auto=Auto (best available)|kokoro=Kokoro (most natural)|piper=Piper|say=macOS say|espeak=espeak-ng" , "Missing engines are installed automatically on the next run" , "g" ],
2026-09-20 16:56:07 -07:00
[ "tts_voice" , "Narration voice" , "text" , "Kokoro: af_heart, af_bella, af_nicole, am_michael, bf_emma. Ignored by the system voice" , "g" ],
2026-09-20 16:44:32 -07:00
[ "tts_rate" , "Narration speed" , "number" , "Only used by the system voice. Words per minute" , "g" ],
2026-09-20 16:11:33 -07:00
[ "piper_model" , "Piper voice file" , "wide" , "Blank uses the first voice found in the voices folder under Models" , "g" ],
2026-09-20 16:03:48 -07:00
[ "gemini_api_key" , "Gemini API key (Veo)" , "password" , "Only needed for the Veo engine. Billing must be enabled" , "g" ],
[ "veo_model" , "Veo model" , "text" , "e.g. veo-3.1-fast-generate-preview" , "g" ],
[ "" , "Text and shared options" , "header" , "" , "" ],
2026-09-17 18:38:39 -07:00
[ "ollama_url" , "Ollama URL" , "text" , "" , "g" ],
2026-09-20 16:03:48 -07:00
[ "ollama_model" , "Ollama model" , "model:text" , "Writes scripts and tags. Models not installed yet are downloaded automatically" , "g" ],
[ "vision_model" , "Ollama vision model" , "model:vision" , "Checks generated scenes on made-for-kids profiles. moondream suits 16 GB machines" , "g" ],
2026-09-17 22:37:25 -07:00
[ "ollama_timeout" , "Ollama timeout (seconds)" , "number" , "Max wait per Ollama request before retrying" , "g" ],
2026-09-17 18:38:39 -07:00
[ "aspect_ratio" , "Aspect ratio" , "select:9:16|16:9" , "" , "g" ],
2026-09-20 16:03:48 -07:00
[ "target_seconds" , "Target length (seconds)" , "number" , "Split into scenes of the length below" , "g" ],
[ "clip_seconds" , "Seconds per scene" , "number" , "8 gives 7 scenes in a 60 second Short" , "g" ],
2026-09-17 18:38:39 -07:00
[ "error_cooldown_minutes" , "Wait after error (minutes)" , "number" , "" , "g" ]
];
const $ = s => document . querySelector ( s );
const esc = s => String ( s ?? "" ). replace ( /[&<>"]/g , c =>({ "&" : "&" , "<" : "<" , ">" : ">" , '"' : """ }[ 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 }
document . querySelectorAll ( "nav button" ). forEach ( b => b . onclick = ()=>{ document . querySelectorAll ( "nav button" ). forEach ( x => x . classList . remove ( "active" )); document . querySelectorAll ( ".tab" ). forEach ( x => x . classList . remove ( "active" )); b . classList . add ( "active" ); $ ( "#" + b . dataset . tab ). classList . add ( "active" )});
function opt ( o ){ const i = o . indexOf ( "=" ); return i < 0 ? [ o , o ] : [ o . slice ( 0 , i ), o . slice ( i + 1 )]}
function renderProfiles ( list , active ){
document . querySelectorAll ( ".prof-select" ). forEach ( sel =>{ sel . innerHTML = list . map ( p => `<option value=" ${ esc ( p . id ) } " ${ p . id === active ? "selected" : "" } > ${ esc ( p . name ) }${ p . channel ? " (" + esc ( p . channel ) + ")" : "" } </option>` ). join ( "" )})}
async function loadSettings (){
const data = await api ( "/api/settings" );
renderProfiles ( data . profiles , data . active );
const g = $ ( "#settings-grid" ); g . innerHTML = "" ;
for ( const [ k , label , type , hint , scope ] of FIELDS ){
if ( type === "header" ){ const h = document . createElement ( "h3" ); h . textContent = label ; g . appendChild ( h ); continue }
const s = scope === "g" ? data . global : data . profile ;
let input ;
if ( type . startsWith ( "select:" )){ input = `<select id="f- ${ k } "> ${ type . slice ( 7 ). split ( "|" ). map ( o =>{ const [ v , l ] = opt ( o ); return `<option value=" ${ esc ( v ) } " ${ String ( s [ k ]) === v ? "selected" : "" } > ${ esc ( l ) } </option>` } ).join("")}</select>` }
else if ( type === "checkbox" ){ input = `<input type="checkbox" id="f- ${ k } " ${ s [ k ] ? "checked" : "" } style="width:auto">` }
2026-09-20 16:03:48 -07:00
else if ( type . startsWith ( "model:" )){
const kind = type . slice ( 6 );
const installed = data . installed_models || [];
const suggested = ( kind === "vision" ? data . suggested_vision_models : data . suggested_text_models ) || [];
const cur = String ( s [ k ] || "" );
const seen = new Set ();
const opts = [];
for ( const m of installed ){ if ( seen . has ( m )) continue ; seen . add ( m ); opts . push ( `<option value=" ${ esc ( m ) } " ${ cur === m ? "selected" : "" } > ${ esc ( m ) } </option>` )}
for ( const m of suggested ){ if ( seen . has ( m ) || seen . has ( m + ":latest" )) continue ; seen . add ( m ); opts . push ( `<option value=" ${ esc ( m ) } " ${ cur === m ? "selected" : "" } > ${ esc ( m ) } (not installed)</option>` )}
if ( cur &&! seen . has ( cur )) opts . unshift ( `<option value=" ${ esc ( cur ) } " selected> ${ esc ( cur ) } (not installed)</option>` );
input = `<div class="row" style="gap:6px"><select id="f- ${ k } " style="flex:1"> ${ opts . join ( "" ) } </select><button type="button" class="btn secondary pull" data-for=" ${ k } " style="padding:8px 12px;font-size:13px">Pull now</button></div>` }
2026-09-17 18:38:39 -07:00
else { input = `<input type=" ${ type === "wide" ? "text" : type } " id="f- ${ k } " value=" ${ esc ( s [ k ]) } ">` }
let extra = "" ;
if ( k === "storage_dir" ) extra = `<div class="hint">Resolves to: ${ esc ( data . storage_resolved ) } </div>` ;
2026-09-20 16:03:48 -07:00
if ( k === "models_dir" ) extra = `<div class="hint">Resolves to: ${ esc ( data . models_resolved ) } </div>` ;
if ( k === "temp_dir" ) extra = `<div class="hint">Resolves to: ${ esc ( data . temp_resolved ) } </div>` ;
2026-09-17 18:38:39 -07:00
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 )}
2026-09-20 16:03:48 -07:00
document . querySelectorAll ( "button.pull" ). forEach ( b => b . onclick = async ()=>{
const sel = $ ( "#f-" + b . dataset . for ); const model = sel . value ;
b . disabled = true ; const was = b . textContent ; b . textContent = "Pulling..." ;
try { await api ( "/api/models/pull" ,{ method : "POST" , body : JSON . stringify ({ model })}); await loadSettings ()}
catch ( e ){ alert ( e . message ); b . disabled = false ; b . textContent = was }});
syncFields ()}
function syncFields (){
2026-09-17 22:58:33 -07:00
const kids = $ ( "#f-made_for_kids" );
2026-09-20 16:03:48 -07:00
if ( kids ){
2026-09-17 22:58:33 -07:00
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 }
2026-09-20 16:03:48 -07:00
kids . onchange = syncFields }
const eng = $ ( "#f-video_engine" );
if ( eng ){
const local = eng . value === "local" ;
for ( const id of [ "#f-sd_model" , "#f-sd_steps" , "#f-sd_guidance" , "#f-sd_width" , "#f-sd_height" , "#f-tts_engine" , "#f-tts_voice" , "#f-tts_rate" , "#f-piper_model" ]){ const el = $ ( id ); if ( ! el ) continue ; el . disabled =! local ; el . closest ( "div" ). style . opacity = local ? 1 : . 5 }
for ( const id of [ "#f-gemini_api_key" , "#f-veo_model" ]){ const el = $ ( id ); if ( ! el ) continue ; el . disabled = local ; el . closest ( "div" ). style . opacity = local ? . 5 : 1 }
eng . onchange = syncFields }}
2026-09-17 18:38:39 -07:00
async function saveSettings (){
const body = { global : {}, profile : {}};
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" ? 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 }}
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 (){
try {
const s = await api ( "/api/status" );
$ ( "#st-stage" ). textContent = s . stage ;
2026-09-20 16:03:48 -07:00
$ ( "#st-engine" ). textContent = s . engine === "local" ? "Local (free)" : "Veo (paid)" ;
2026-09-17 18:38:39 -07:00
$ ( "#st-today" ). textContent = s . published_today + ( s . videos_per_day > 0 ? " / " + s . videos_per_day : "" );
$ ( "#st-channel" ). textContent = s . channel || "Not linked" ;
$ ( "#st-storage" ). textContent = s . storage ;
$ ( "#st-free" ). textContent = s . storage_free_gb === null ? "not created yet" : s . storage_free_gb + " GB" ;
$ ( "#st-free" ). style . color = s . storage_free_gb !== null && s . storage_free_gb < 10 ? "#ffb74d" : "" ;
$ ( "#st-after" ). textContent = s . delete_after_publish ? "Delete files" : "Move to published" ;
$ ( "#kids-badge" ). style . display = s . made_for_kids ? "inline-block" : "none" ;
2026-09-20 16:49:03 -07:00
const panel = $ ( "#progress-panel" );
if ( s . steps > 0 && ( s . running || s . step < s . steps )){
panel . style . display = "block" ;
const pct = Math . min ( 100 , Math . round ( s . step * 100 / s . steps ));
$ ( "#pg-fill" ). style . width = pct + "%" ;
$ ( "#pg-stage" ). textContent = s . stage ;
const mins = Math . floor ( s . elapsed / 60 ), secs = s . elapsed % 60 ;
$ ( "#pg-meta" ). textContent = pct + "% | step " + s . step + " of " + s . steps + ( s . elapsed ? " | " + ( mins ? mins + "m " : "" ) + secs + "s" : "" );
} else { panel . style . display = "none" }
2026-09-20 16:03:48 -07:00
const lb = $ ( "#link" ); lb . textContent = s . channel ? "Unlink YouTube Channel" : "Link YouTube Channel" ; lb . dataset . linked = s . channel ? "1" : "" ;
setButton ( s . running );
2026-09-17 22:58:33 -07:00
$ ( "#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 ( "" )
2026-09-17 18:38:39 -07:00
} catch ( e ){}}
let since = 0 ;
async function pollLogs (){
try {
const r = await api ( "/api/logs?since=" + since ); const box = $ ( "#log" );
for ( const [ seq , level , line ] of r . lines ){ const d = document . createElement ( "div" ); d . className = level ; d . textContent = line ; box . appendChild ( d ); since = seq }
while ( box . childElementCount > 3000 ) box . firstChild . remove ();
if ( r . lines . length && $ ( "#autoscroll" ). checked ) box . scrollTop = box . scrollHeight
} catch ( e ){}}
document . querySelectorAll ( ".prof-select" ). forEach ( sel => sel . onchange = e => activate ( e . target . value ));
$ ( "#new-profile" ). onclick = async ()=>{
const name = prompt ( "New profile name (starts as a copy of the current profile's settings)" );
if ( ! name ) return ;
try { await api ( "/api/profiles" ,{ method : "POST" , body : JSON . stringify ({ name })}); await loadSettings (); refresh ()} catch ( e ){ alert ( e . message )}};
$ ( "#del-profile" ). onclick = async ()=>{
const sel = $ ( "#prof-set" ); const label = sel . options [ sel . selectedIndex ] ? . text || "" ;
if ( ! confirm ( `Delete profile " ${ label } " and its linked channel token? Videos on disk are kept.` )) return ;
try { await api ( "/api/profiles/delete" ,{ method : "POST" , body : JSON . stringify ({ id : sel . value })}); await loadSettings (); refresh ()} catch ( e ){ alert ( e . message )}};
2026-09-20 16:03:48 -07:00
function setButton ( running ){
const b = $ ( "#go" );
if ( b . dataset . busy === "1" ) return ;
b . classList . toggle ( "running" , running );
b . textContent = running ? "STOP" : "GO" ;
b . disabled = false }
$ ( "#go" ). onclick = async ()=>{
const b = $ ( "#go" ); const running = b . classList . contains ( "running" );
b . dataset . busy = "1" ; b . disabled = true ; b . classList . remove ( "running" ); b . classList . add ( "busy" );
b . textContent = running ? "Stopping..." : "Starting..." ;
try { await api ( running ? "/api/stop" : "/api/start" ,{ method : "POST" })} catch ( e ){ alert ( e . message )}
b . dataset . busy = "" ; b . classList . remove ( "busy" );
setButton ( ! running );
refresh ()};
$ ( "#link" ). onclick = async ()=>{
if ( $ ( "#link" ). dataset . linked === "1" ){
if ( ! confirm ( "Unlink the YouTube channel from this profile?" )) return ;
await api ( "/auth/unlink" ,{ method : "POST" }); await loadSettings (); refresh ()}
else { window . location . href = "/auth/start" }};
2026-09-17 18:38:39 -07:00
$ ( "#save" ). onclick = saveSettings ;
$ ( "#clear" ). onclick = ()=>{ $ ( "#log" ). innerHTML = "" };
2026-09-20 16:11:33 -07:00
$ ( "#copy-log" ). onclick = async ()=>{
const text = $ ( "#log" ). innerText . trim ();
if ( ! text ){ $ ( "#copy-msg" ). textContent = "Nothing to copy" ; return }
try { await navigator . clipboard . writeText ( text ); $ ( "#copy-msg" ). textContent = "Copied " + text . split ( "\n" ). length + " lines" }
catch ( e ){
const ta = document . createElement ( "textarea" ); ta . value = text ; document . body . appendChild ( ta ); ta . select ();
try { document . execCommand ( "copy" ); $ ( "#copy-msg" ). textContent = "Copied" } catch ( err ){ $ ( "#copy-msg" ). textContent = "Could not copy" }
ta . remove ()}
setTimeout (()=>{ $ ( "#copy-msg" ). textContent = "" }, 3000 )};
$ ( "#clear-jobs" ). onclick = async ()=>{
if ( ! confirm ( "Clear the job list? Videos on disk and on YouTube are not touched." )) return ;
try { await api ( "/api/history/clear" ,{ method : "POST" }); refresh ()} catch ( e ){ alert ( e . message )}};
2026-09-17 18:38:39 -07:00
$ ( "#preview" ). onclick = async ()=>{
$ ( "#preview-msg" ). textContent = "Loading..." ;
try {
const r = await api ( "/api/trending" );
$ ( "#trending" ). innerHTML = "<tr><th>Views</th><th>Title</th><th>Channel</th></tr>" + r . items . map ( v => `<tr><td> ${ v . views . toLocaleString () } </td><td><a target="_blank" href="https://youtube.com/watch?v= ${ esc ( v . id ) } "> ${ esc ( v . title ) } </a></td><td> ${ esc ( v . channel ) } </td></tr>` ). join ( "" );
$ ( "#trending-panel" ). style . display = "block" ; $ ( "#preview-msg" ). textContent = ""
} catch ( e ){ $ ( "#preview-msg" ). textContent = "Error: " + e . message }};
loadSettings (); refresh (); pollLogs ();
setInterval ( refresh , 3000 ); setInterval ( pollLogs , 2000 );
</ script >
</ body >
</ html >