Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
86 lines
7.3 KiB
JavaScript
86 lines
7.3 KiB
JavaScript
// Authorized private-video E2E. No credential is accepted in argv or printed.
|
|
import fs from 'node:fs';
|
|
import crypto from 'node:crypto';
|
|
import {execFileSync} from 'node:child_process';
|
|
const emit=x=>process.stdout.write(JSON.stringify(x)+'\n');
|
|
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
|
|
let stage='model-registration', session, db, auth, video, result={states:[]};
|
|
try {
|
|
if(JSON.parse(fs.readFileSync('/app/package.json','utf8')).version !== '7.3.0') throw Error('unreviewed server version');
|
|
const dbURL=new URL('file:///app/dist/core/initializers/database.js');
|
|
db=await import(dbURL.href);
|
|
const {logger}=await import('file:///app/dist/core/helpers/logger.js');logger.silent=true;
|
|
// Register the shipped models without invoking initDatabaseModels: that helper
|
|
// recreates a SQL function and is outside this task's authorized server scope.
|
|
const source=fs.readFileSync(dbURL,'utf8');
|
|
const names=source.match(/sequelizeTypescript\.addModels\(\[([\s\S]*?)\]\)/)[1].split(',').map(x=>x.trim()).filter(Boolean);
|
|
const imports=[...source.matchAll(/import \{ (\w+) \} from '(\.\.\/models\/[^']+)';/g)];
|
|
const models=[];
|
|
for(const name of names){const item=imports.find(m=>m[1]===name);if(!item)throw Error('model not found');models.push((await import(new URL(item[2],dbURL).href))[name]);}
|
|
db.sequelizeTypescript.addModels(models);
|
|
await db.sequelizeTypescript.authenticate();
|
|
const {OAuthTokenModel}=await import('file:///app/dist/core/models/oauth/oauth-token.js');
|
|
const {OAuthClientModel}=await import('file:///app/dist/core/models/oauth/oauth-client.js');
|
|
const {UserModel}=await import('file:///app/dist/core/models/user/user.js');
|
|
const {RunnerJobModel}=await import('file:///app/dist/core/models/runner/runner-job.js');
|
|
const {RUNNER_JOB_STATES}=await import('file:///app/dist/core/initializers/constants.js');
|
|
const {Op}=await import('sequelize');
|
|
const user=await UserModel.findOne({where:{username:'root',role:0}}), client=await OAuthClientModel.loadFirstClient();
|
|
if(!user||!client)throw Error('admin/client absent');
|
|
stage='temporary-admin-session';
|
|
const expires=new Date(Date.now()+10*60*1000);
|
|
session=await OAuthTokenModel.create({accessToken:crypto.randomBytes(32).toString('hex'),refreshToken:crypto.randomBytes(32).toString('hex'),accessTokenExpiresAt:expires,refreshTokenExpiresAt:expires,userId:user.id,oAuthClientId:client.id,loginDevice:'authorized-pt-worker-e2e',loginIP:'127.0.0.1',loginDate:new Date()});
|
|
auth={Authorization:'Bearer '+session.accessToken};
|
|
const base='http://127.0.0.1:9000/api/v1';
|
|
async function api(path,options={}) {const r=await fetch(base+path,{...options,headers:{...auth,...options.headers},signal:AbortSignal.timeout(30000)});if(!r.ok){result.http_status=r.status;throw Error('API request failed');}return r.status===204?null:r.json();}
|
|
stage='admin-auth-check';
|
|
const me=await api('/users/me');
|
|
const channel=me.videoChannels?.[0];if(!channel)throw Error('root channel absent');
|
|
const baseline=await RunnerJobModel.max('id')||0;
|
|
stage='private-upload';
|
|
const form=new FormData();
|
|
for(const [k,v] of Object.entries({channelId:String(channel.id),name:'Windows NVENC worker E2E 2026-10-09',privacy:'3',nsfw:'false',commentsEnabled:'false',downloadEnabled:'true',waitTranscoding:'true'}))form.set(k,v);
|
|
form.set('videofile',new Blob([fs.readFileSync('/tmp/pt-worker-e2e-source.mp4')],{type:'video/mp4'}),'nvenc-e2e.mp4');
|
|
const upload=await api('/videos/upload',{method:'POST',body:form});video=upload.video;
|
|
result.video={id:video.id,uuid:video.uuid,shortUUID:video.shortUUID,privacy:'private',watch_url:'https://tube.occupymedia.cc/w/'+video.shortUUID};
|
|
emit({phase:'uploaded',video:result.video});
|
|
stage='remote-job-states';
|
|
const end=Date.now()+5*60*1000, seen=new Map();let completed;
|
|
while(Date.now()<end){
|
|
const jobs=await RunnerJobModel.findAll({attributes:['id','uuid','type','state','runnerId','createdAt','startedAt','updatedAt'],where:{id:{[Op.gt]:baseline}},raw:true});
|
|
for(const j of jobs){const label=RUNNER_JOB_STATES[j.state];if(seen.get(j.uuid)!==label){seen.set(j.uuid,label);const item={...j,state:label,observed_at:new Date().toISOString()};result.states.push(item);emit({phase:'job-state',...item});}if(label==='Completed')completed=j;if(label==='Errored')throw Error('remote job errored');}
|
|
if(completed && jobs.some(j=>j.type==='vod-hls-transcoding' && RUNNER_JOB_STATES[j.state]==='Completed'))break;
|
|
await sleep(100);
|
|
}
|
|
if(!completed)throw Error('remote job deadline');
|
|
result.job={id:completed.id,uuid:completed.uuid,type:completed.type,runner_id:completed.runnerId};
|
|
stage='result-media-api';
|
|
let details;
|
|
for(let n=0;n<60;n++){details=await api('/videos/'+video.uuid);if(details.streamingPlaylists?.[0]?.files?.length)break;await sleep(500);}
|
|
const playlist=details.streamingPlaylists?.[0];if(!playlist?.files?.length)throw Error('HLS result absent');
|
|
let fileToken;
|
|
async function media(url){let u=new URL(url);u.protocol='http:';u.hostname='127.0.0.1';u.port='9000';if(fileToken)u.searchParams.set('videoFileToken',fileToken);let r=await fetch(u,{headers:auth,signal:AbortSignal.timeout(15000)});if(!r.ok&&!fileToken){try{const t=await api('/videos/'+video.uuid+'/token',{method:'POST'});fileToken=t.files?.token||t.token||t.videoFileToken;}catch{}if(fileToken){u.searchParams.set('videoFileToken',fileToken);r=await fetch(u,{headers:auth,signal:AbortSignal.timeout(15000)});}}if(!r.ok){result.media_http_status=r.status;throw Error('private media fetch failed');}return r;}
|
|
stage='playlist-playback';
|
|
const master=await (await media(playlist.playlistUrl)).text();
|
|
const renditionLine=master.split(/\r?\n/).find(l=>l.trim()&&!l.startsWith('#'));
|
|
const renditionURL=new URL(renditionLine,playlist.playlistUrl).href;
|
|
const rendition=await (await media(renditionURL)).text();
|
|
const assetLine=rendition.split(/\r?\n/).find(l=>l.trim()&&!l.startsWith('#'));
|
|
const assetURL=new URL(assetLine,renditionURL).href;
|
|
const blob=Buffer.from(await (await media(assetURL)).arrayBuffer());
|
|
const path='/tmp/pt-worker-e2e-result.mp4';fs.writeFileSync(path,blob,{mode:0o600});
|
|
const probe=JSON.parse(execFileSync('ffprobe',['-v','error','-show_streams','-show_format','-of','json',path],{encoding:'utf8',timeout:30000}));
|
|
stage='playback-decode';execFileSync('ffmpeg',['-v','error','-nostdin','-i',path,'-f','null','-'],{stdio:'pipe',timeout:30000});
|
|
const stream=probe.streams.find(s=>s.codec_type==='video');
|
|
result.playback={master_http:200,rendition_http:200,asset_http:200,decode_exit:0,bytes:blob.length,codec:stream.codec_name,width:stream.width,height:stream.height,duration:probe.format.duration};
|
|
result.success=true;emit({phase:'e2e-completed',job:result.job,playback:result.playback});
|
|
}catch(e){result.success=false;result.failed_stage=stage;result.error_type=e.name;emit({phase:'e2e-failed',stage,error_type:e.name,http_status:result.http_status,media_http_status:result.media_http_status});}
|
|
finally{
|
|
if(session){
|
|
try{await fetch('http://127.0.0.1:9000/api/v1/users/revoke-token',{method:'POST',headers:auth,signal:AbortSignal.timeout(10000)});await sleep(300);}catch{}
|
|
try{await session.destroy();result.temporary_admin_session_removed=true;}catch{result.temporary_admin_session_removed=false;}
|
|
}
|
|
fs.writeFileSync('/tmp/pt-worker-e2e-result.json',JSON.stringify(result,null,2),{mode:0o600});
|
|
emit({phase:'final-e2e-record',...result});
|
|
if(db)await db.sequelizeTypescript.close();
|
|
}
|