Add YouTube POT token provider (bgutil-pot, opt-in)

YouTube increasingly requires a per-video Proof-of-Origin token bound
to each video ID before handing back format URLs. Without one, yt-dlp
sees empty formats and downloads fail. The upstream solution is the
Brainicism bgutil-ytdlp-pot-provider Python plugin paired with a
long-running server that mints tokens via BotGuard.

We integrate the Rust port (jim60105/bgutil-ytdlp-pot-provider-rs) so
the server is a single static binary, no Node.js required.

## Architecture

- bgutil-pot binary lives alongside the bundled deno + yt-dlp at
  ~/.local/share/yt-offline/bin/bgutil-pot.
- The matching Python plugin is pip-installed into the bundled venv:
  python -m pip install bgutil-ytdlp-pot-provider.
- Downloader spawns the server lazily on first job (port 4416,
  loopback only) and kills it on Drop.
- Each yt-dlp invocation gets
  --extractor-args "youtubepot-bgutilhttp:base_url=http://127.0.0.1:4416"
  appended in spawn_job when use_pot_provider is on and the server
  child is alive.

## UX

- Settings → "POT token provider" row with toggle + Install/Update
  button (mirrors the bundled yt-dlp row).
- Disabled unless use_bundled_ytdlp is also on (plugin lives in that
  venv).
- Refuses install if the bundled yt-dlp venv isn't there yet, with a
  helpful 428 error.

## Lifecycle

- Lazy spawn: ensure_pot_server() runs at the top of start() so the
  server is up before yt-dlp queries the plugin.
- Drop: kill_server() on Downloader drop so we don't leave an orphaned
  process bound to 4416.
- start_pot_provider_update() kills the running child first so the
  install can overwrite the binary in place (no ETXTBSY).

3 new unit tests cover URL/extractor-args formatting; 77 total pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-05-31 06:08:25 -07:00
parent 4f15ab873f
commit 2696a51cc8
7 changed files with 439 additions and 4 deletions

View file

@ -1301,6 +1301,16 @@ async function openSettings(){
<span style="font-size:11px;color:var(--muted)">${cur.bundled_ytdlp_installed?'✓ installed':'not installed'}</span>
<span id="ytdlp-status" style="font-size:11px;color:var(--muted)"></span>
</div>
<div class="settings-row" style="margin-top:8px">
<label for="cf-pot-provider">Use POT token provider</label>
<input type="checkbox" id="cf-pot-provider" ${cur.use_pot_provider?'checked':''} ${cur.use_bundled_ytdlp?'':'disabled'}>
</div>
<div class="settings-hint" style="margin-bottom:6px">Spawns the bgutil-pot HTTP server (loopback only) and points yt-dlp at it. YouTube increasingly requires a Proof-of-Origin token bound to each video ID — without one, format URLs come back empty. Requires the bundled yt-dlp; the matching Python plugin is pip-installed into the bundled venv on Install.</div>
<div style="display:flex;gap:6px;align-items:center;margin-bottom:4px">
<button onclick="updatePotProvider(this)" ${cur.use_bundled_ytdlp?'':'disabled'}>${cur.pot_provider_installed?'⟳ Update POT':'⤓ Install POT'}</button>
<span style="font-size:11px;color:var(--muted)">${cur.pot_provider_installed?'✓ installed':'not installed'}</span>
<span id="pot-status" style="font-size:11px;color:var(--muted)"></span>
</div>
<hr style="border-color:var(--border);margin:12px 0">
<div style="font-weight:700;margin-bottom:8px">Plex</div>
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:6px">
@ -1429,6 +1439,18 @@ async function updateYtdlp(btn){
}catch(e){st.textContent='Error: '+e.message}
finally{btn.disabled=false}
}
async function updatePotProvider(btn){
btn.disabled=true;
const st=document.getElementById('pot-status');
st.textContent='Started — click ⬇ in the header to view progress';
try{
const r=await fetch('/api/pot/update',{method:'POST'});
const t=await r.text();
if(!r.ok)throw new Error(t);
setStatus('POT provider install job started');
}catch(e){st.textContent='Error: '+e.message}
finally{btn.disabled=false}
}
async function runScheduler(btn){
btn.disabled=true;
const st=document.getElementById('sched-status');
@ -1452,8 +1474,9 @@ async function saveSettings(btn){
const schedInterval=parseInt(document.getElementById('cf-sched-interval')?.value||'24',10);
const maxConcurrent=parseInt(document.getElementById('cf-max-concurrent')?.value||'3',10);
const useBundledYtdlp=document.getElementById('cf-ytdlp-bundled')?.checked||false;
const usePotProvider=document.getElementById('cf-pot-provider')?.checked||false;
const sourceUrl=document.getElementById('cf-source-url')?.value;
const payload={transcode,scheduler_enabled:schedEnabled,scheduler_interval_hours:schedInterval,max_concurrent:maxConcurrent,use_bundled_ytdlp:useBundledYtdlp};
const payload={transcode,scheduler_enabled:schedEnabled,scheduler_interval_hours:schedInterval,max_concurrent:maxConcurrent,use_bundled_ytdlp:useBundledYtdlp,use_pot_provider:usePotProvider};
if(bindMode)payload.bind_mode=bindMode;
if(plexPath!==undefined)payload.plex_library_path=plexPath;
if(sourceUrl!==undefined)payload.source_url=sourceUrl;