Automate Forge NBC Verizon auth
Some checks are pending
ci-gates / checks (push) Waiting to run
deploy-cloudflare / checks (push) Waiting to run
deploy-cloudflare / deploy (push) Blocked by required conditions

This commit is contained in:
every.channel 2026-05-03 21:20:26 -07:00
parent 7c9980667a
commit 340e2346ba
No known key found for this signature in database
3 changed files with 671 additions and 36 deletions

View file

@ -2,6 +2,7 @@ use anyhow::{anyhow, Context, Result};
use headless_chrome::protocol::cdp::Page; use headless_chrome::protocol::cdp::Page;
use headless_chrome::{Browser, Tab}; use headless_chrome::{Browser, Tab};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::env; use std::env;
use std::fs; use std::fs;
use std::io::{BufRead, BufReader, Cursor, Read, Write}; use std::io::{BufRead, BufReader, Cursor, Read, Write};
@ -21,6 +22,8 @@ struct NbcTraceState {
token_verified: bool, token_verified: bool,
mt_session_ready: bool, mt_session_ready: bool,
background_login_complete: bool, background_login_complete: bool,
media_activity_seen: bool,
last_media_activity_at: Option<Instant>,
} }
#[derive(Debug, Default, Clone, Serialize, Deserialize)] #[derive(Debug, Default, Clone, Serialize, Deserialize)]
@ -73,6 +76,12 @@ struct WaitOutcome {
screenshot_path: Option<PathBuf>, screenshot_path: Option<PathBuf>,
} }
#[derive(Clone)]
struct BrowserTabState {
tab: Arc<Tab>,
state: NbcVideoState,
}
enum AuthMode { enum AuthMode {
Forbidden, Forbidden,
AllowInteractive { timeout: Duration }, AllowInteractive { timeout: Duration },
@ -126,8 +135,10 @@ const NBC_CHROME_BASE_ARGS: &[&str] = &[
"--disable-popup-blocking", "--disable-popup-blocking",
"--disable-prompt-on-repost", "--disable-prompt-on-repost",
"--disable-renderer-backgrounding", "--disable-renderer-backgrounding",
"--disable-session-crashed-bubble",
"--disable-sync", "--disable-sync",
"--force-color-profile=srgb", "--force-color-profile=srgb",
"--hide-crash-restore-bubble",
"--metrics-recording-only", "--metrics-recording-only",
"--enable-automation", "--enable-automation",
"--password-store=basic", "--password-store=basic",
@ -225,6 +236,34 @@ fn nbc_mvpd_provider_name() -> String {
.unwrap_or_else(|| "Verizon Fios".to_string()) .unwrap_or_else(|| "Verizon Fios".to_string())
} }
fn nbc_secret_value(name: &str) -> Option<String> {
let file_name = format!("{name}_FILE");
if let Ok(path) = env::var(&file_name) {
let path = PathBuf::from(path);
match fs::read_to_string(&path) {
Ok(value) => {
let value = value.trim_end_matches(['\r', '\n']).to_string();
if !value.trim().is_empty() {
return Some(value);
}
}
Err(err) => {
tracing::debug!(path = %path.display(), "failed to read NBC secret file: {err:#}")
}
}
}
env::var(name).ok().filter(|value| !value.trim().is_empty())
}
fn nbc_mvpd_username() -> Option<String> {
nbc_secret_value("EVERY_CHANNEL_NBC_MVPD_USERNAME")
}
fn nbc_mvpd_password() -> Option<String> {
nbc_secret_value("EVERY_CHANNEL_NBC_MVPD_PASSWORD")
}
pub fn resolve_nbc_chrome_path(override_path: Option<&Path>) -> Result<PathBuf> { pub fn resolve_nbc_chrome_path(override_path: Option<&Path>) -> Result<PathBuf> {
if let Some(path) = override_path { if let Some(path) = override_path {
if path.exists() { if path.exists() {
@ -353,7 +392,9 @@ pub fn bootstrap_nbc_auth(
tab.wait_until_navigated()?; tab.wait_until_navigated()?;
let outcome = wait_for_nbc_playback( let outcome = wait_for_nbc_playback(
chrome.browser(),
&tab, &tab,
&url,
&trace, &trace,
AuthMode::AllowInteractive { AuthMode::AllowInteractive {
timeout: nbc_bootstrap_timeout(), timeout: nbc_bootstrap_timeout(),
@ -578,7 +619,14 @@ fn run_nbc_capture_loop(
register_nbc_trace_handlers(&tab, trace.clone())?; register_nbc_trace_handlers(&tab, trace.clone())?;
tab.navigate_to(&url)?; tab.navigate_to(&url)?;
tab.wait_until_navigated()?; tab.wait_until_navigated()?;
wait_for_nbc_playback(&tab, &trace, AuthMode::Forbidden, None)?; wait_for_nbc_playback(
chrome.browser(),
&tab,
&url,
&trace,
AuthMode::Forbidden,
None,
)?;
let frame_interval = Duration::from_millis(1000 / nbc_capture_fps().max(1)); let frame_interval = Duration::from_millis(1000 / nbc_capture_fps().max(1));
let quality = nbc_capture_quality(); let quality = nbc_capture_quality();
@ -696,6 +744,16 @@ fn update_nbc_trace_from_url(trace_state: &mut NbcTraceState, url: &str) -> bool
trace_state.background_login_complete = true; trace_state.background_login_complete = true;
matched = true; matched = true;
} }
if url.contains("cssott.com/")
|| url.contains("/Content/CMAF_")
|| url.contains(".m4s")
|| url.contains(".mpd")
|| url.contains("item_Segment")
{
trace_state.media_activity_seen = true;
trace_state.last_media_activity_at = Some(Instant::now());
matched = true;
}
matched matched
} }
@ -707,17 +765,43 @@ fn nbc_trace_is_authorized(trace_state: &NbcTraceState) -> bool {
|| trace_state.background_login_complete || trace_state.background_login_complete
} }
fn nbc_trace_has_recent_media_activity(trace_state: &NbcTraceState) -> bool {
trace_state
.last_media_activity_at
.map(|instant| instant.elapsed() <= Duration::from_secs(15))
.unwrap_or(false)
}
fn nbc_url_is_background_login_complete(url: &str) -> bool {
url.contains("completeBackgroundLogin.html")
}
fn nbc_url_is_provider_linked(url: &str) -> bool {
let Ok(url) = Url::parse(url) else {
return false;
};
let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
let path = url.path().to_ascii_lowercase();
(host.ends_with("nbc.com") || host.ends_with(".nbc.com")) && path.contains("provider-linked")
}
fn nbc_url_is_optional_profile_signin(url: &str) -> bool {
let Ok(url) = Url::parse(url) else {
return false;
};
let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
let path = url.path().to_ascii_lowercase();
(host.ends_with("nbc.com") || host.ends_with(".nbc.com"))
&& (path.contains("sign-in") || path.contains("login"))
}
fn nbc_url_requires_interactive_auth(url: &str) -> bool { fn nbc_url_requires_interactive_auth(url: &str) -> bool {
let Ok(url) = Url::parse(url) else { let Ok(url) = Url::parse(url) else {
return false; return false;
}; };
let host = url.host_str().unwrap_or_default().to_ascii_lowercase(); let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
let path = url.path().to_ascii_lowercase(); let path = url.path().to_ascii_lowercase();
host.contains("auth.adobe.com") host.contains("auth.adobe.com") || host.contains("verizon") || path.contains("mvpd")
|| host.contains("verizon")
|| path.contains("mvpd")
|| path.contains("signin")
|| path.contains("login")
} }
fn nbc_page_is_watch_surface(url: &str) -> bool { fn nbc_page_is_watch_surface(url: &str) -> bool {
@ -725,7 +809,113 @@ fn nbc_page_is_watch_surface(url: &str) -> bool {
return false; return false;
}; };
let host = url.host_str().unwrap_or_default().to_ascii_lowercase(); let host = url.host_str().unwrap_or_default().to_ascii_lowercase();
host.ends_with("nbc.com") || host.ends_with(".nbc.com") (host.ends_with("nbc.com") || host.ends_with(".nbc.com"))
&& !nbc_url_is_optional_profile_signin(url.as_str())
&& !nbc_url_is_provider_linked(url.as_str())
}
fn nbc_title_looks_like_verizon_popup(title: &str) -> bool {
let title = title.to_ascii_lowercase();
(title.contains("verizon") || title.contains("fios"))
&& (title.contains("sign in") || title.contains("signin") || title.contains("log in"))
}
fn nbc_title_looks_like_provider_linked(title: &str) -> bool {
title.to_ascii_lowercase().contains("tv provider linked")
}
fn nbc_title_looks_like_optional_profile_signin(title: &str) -> bool {
let title = title.to_ascii_lowercase();
title.contains("nbc account sign in")
|| title.contains("nbcuniversal profile")
|| title.contains("nbc profile")
}
fn nbc_state_is_optional_profile_signin(state: &NbcVideoState) -> bool {
nbc_url_is_optional_profile_signin(&state.page_url)
|| nbc_title_looks_like_optional_profile_signin(&state.title)
}
fn browser_tabs(browser: &Browser) -> Vec<Arc<Tab>> {
browser.register_missing_tabs();
browser.get_tabs().lock().unwrap().iter().cloned().collect()
}
fn register_nbc_trace_handlers_for_browser(
browser: &Browser,
trace: &Arc<StdMutex<NbcTraceState>>,
tracked_tabs: &mut HashSet<String>,
) {
for tab in browser_tabs(browser) {
let target_id = tab.get_target_id().clone();
if !tracked_tabs.insert(target_id.to_string()) {
continue;
}
let _ = tab.enable_stealth_mode();
if let Err(err) = register_nbc_trace_handlers(&tab, trace.clone()) {
tracing::debug!(target_id = %target_id, "failed to register NBC trace handlers on tab: {err:#}");
}
}
}
fn collect_browser_tab_states(browser: &Browser) -> Vec<BrowserTabState> {
browser_tabs(browser)
.into_iter()
.map(|tab| {
let state = probe_nbc_video(&tab).unwrap_or_default();
BrowserTabState { tab, state }
})
.collect()
}
fn find_primary_tab_state<'a>(
tabs: &'a [BrowserTabState],
primary_tab: &Arc<Tab>,
) -> Option<&'a BrowserTabState> {
let target_id = primary_tab.get_target_id();
tabs.iter()
.find(|candidate| candidate.tab.get_target_id() == target_id)
}
fn find_playing_tab_state(tabs: &[BrowserTabState]) -> Option<&BrowserTabState> {
tabs.iter().find(|candidate| {
candidate.state.has_video
&& candidate.state.width > 0
&& candidate.state.height > 0
&& !candidate.state.paused
&& (candidate.state.current_time > 0.0 || candidate.state.ready_state >= 2)
})
}
fn find_provider_linked_tab_state(tabs: &[BrowserTabState]) -> Option<&BrowserTabState> {
tabs.iter().find(|candidate| {
nbc_title_looks_like_provider_linked(&candidate.state.title)
|| nbc_url_is_provider_linked(&candidate.state.page_url)
})
}
fn find_interaction_tab_state<'a>(
tabs: &'a [BrowserTabState],
primary_tab: &'a Arc<Tab>,
) -> Option<&'a BrowserTabState> {
find_provider_linked_tab_state(tabs)
.or_else(|| {
tabs.iter().find(|candidate| {
candidate.tab.get_target_id() != primary_tab.get_target_id()
&& (nbc_title_looks_like_verizon_popup(&candidate.state.title)
|| nbc_url_requires_interactive_auth(&candidate.state.page_url))
})
})
.or_else(|| find_primary_tab_state(tabs, primary_tab))
}
fn close_auxiliary_browser_tabs(browser: &Browser, primary_tab: &Arc<Tab>) {
for tab in browser_tabs(browser) {
if tab.get_target_id() == primary_tab.get_target_id() {
continue;
}
let _ = tab.close(false);
}
} }
fn kick_nbc_player(tab: &Arc<Tab>) -> Result<()> { fn kick_nbc_player(tab: &Arc<Tab>) -> Result<()> {
@ -936,6 +1126,243 @@ fn advance_nbc_auth_flow(tab: &Arc<Tab>) -> Result<Option<NbcAuthAdvanceResult>>
Ok(value.filter(|value| !value.actions.is_empty())) Ok(value.filter(|value| !value.actions.is_empty()))
} }
fn advance_mvpd_login_flow(tab: &Arc<Tab>) -> Result<Option<NbcAuthAdvanceResult>> {
let username = match nbc_mvpd_username() {
Some(value) => value,
None => return Ok(None),
};
let password = match nbc_mvpd_password() {
Some(value) => value,
None => return Ok(None),
};
let username_json = serde_json::to_string(&username)?;
let password_json = serde_json::to_string(&password)?;
let script = format!(
r#"
JSON.stringify((() => {{
const username = {username_json};
const password = {password_json};
const normalize = (value) => (value || "").replace(/\s+/g, " ").trim().toLowerCase();
const visible = (node) => {{
if (!node || typeof node.getBoundingClientRect !== "function") return false;
const rect = node.getBoundingClientRect();
const style = window.getComputedStyle(node);
return rect.width > 0 &&
rect.height > 0 &&
style.display !== "none" &&
style.visibility !== "hidden" &&
style.opacity !== "0";
}};
const textOf = (node) => normalize([
node.innerText || node.textContent || "",
node.getAttribute?.("aria-label") || "",
node.getAttribute?.("title") || "",
node.getAttribute?.("name") || "",
node.id || "",
node.value || "",
].join(" "));
const setValue = (node, value, label) => {{
if (!node || !visible(node)) return false;
const prototype = Object.getPrototypeOf(node);
const descriptor = prototype && Object.getOwnPropertyDescriptor(prototype, "value");
if (descriptor && descriptor.set) {{
descriptor.set.call(node, value);
}} else {{
node.value = value;
}}
node.focus?.();
node.dispatchEvent(new Event("input", {{ bubbles: true }}));
node.dispatchEvent(new Event("change", {{ bubbles: true }}));
actions.push(`typed:${{label}}`);
return true;
}};
const pressEnter = (node) => {{
if (!node) return false;
for (const type of ["keydown", "keypress", "keyup"]) {{
node.dispatchEvent?.(new KeyboardEvent(type, {{
key: "Enter",
code: "Enter",
which: 13,
keyCode: 13,
bubbles: true,
cancelable: true,
}}));
}}
actions.push("press:enter");
return true;
}};
const clickNode = (node, action) => {{
if (!node || !visible(node)) return false;
node.scrollIntoView?.({{ block: "center", inline: "center" }});
node.click?.();
actions.push(action);
return true;
}};
const actions = [];
const url = window.location.href || "";
const title = document.title || "";
const titleText = `${{title}} ${{url}}`.toLowerCase();
let host = "";
try {{
host = new URL(url).hostname.toLowerCase();
}} catch (_err) {{}}
const looksLikeVerizon = titleText.includes("verizon") || titleText.includes("fios");
const onNbcMvpdPicker =
(host.endsWith("nbc.com") || host.endsWith(".nbc.com")) &&
url.includes("/mvpd");
const looksLikeProviderLogin =
!onNbcMvpdPicker &&
(
looksLikeVerizon ||
host.includes("auth.adobe.com") ||
host.includes("verizon")
);
const looksLikeOptionalNbcProfile =
(host.endsWith("nbc.com") || host.endsWith(".nbc.com")) &&
(url.includes("/sign-in") ||
url.includes("/login") ||
titleText.includes("nbc account sign in") ||
titleText.includes("nbcuniversal profile") ||
titleText.includes("nbc profile"));
if (looksLikeOptionalNbcProfile) {{
const profileButtons = Array.from(document.querySelectorAll("button,a,[role='button'],input[type='submit'],input[type='button']"));
const providerLink = profileButtons.find((node) => {{
const text = textOf(node);
return visible(node) && (
text === "link tv provider" ||
text === "link provider" ||
text.startsWith("link tv provider ") ||
text.startsWith("link provider ")
);
}});
clickNode(providerLink, "click:profile-link-provider");
return {{ pageUrl: url, title, actions }};
}}
if (!looksLikeProviderLogin) {{
return {{ pageUrl: url, title, actions }};
}}
const fields = Array.from(document.querySelectorAll("input"));
const usernameField = fields.find((node) => {{
if (!visible(node) || node.disabled) return false;
const type = normalize(node.type || "");
if (type === "hidden" || type === "password" || type === "submit" || type === "checkbox") return false;
const hint = textOf(node);
return hint.includes("user")
|| hint.includes("email")
|| hint.includes("mobile")
|| hint.includes("phone")
|| hint.includes("name")
|| type === "email"
|| type === "tel"
|| type === "text";
}});
const passwordField = fields.find((node) => {{
if (!visible(node) || node.disabled) return false;
return normalize(node.type || "") === "password" || textOf(node).includes("password");
}});
if (usernameField && normalize(usernameField.value || "") !== normalize(username)) {{
setValue(usernameField, username, "username");
}}
if (passwordField && normalize(passwordField.value || "") !== normalize(password)) {{
setValue(passwordField, password, "password");
}}
const buttons = Array.from(document.querySelectorAll("button,input[type='submit'],input[type='button'],a,[role='button']"));
const button = buttons.find((node) => {{
const text = textOf(node);
return visible(node) && (
text === "sign in" ||
text === "log in" ||
text === "continue" ||
text === "next" ||
text === "submit" ||
text.startsWith("sign in ") ||
text.startsWith("continue ")
);
}});
if (button) {{
clickNode(button, `click:${{textOf(button).slice(0, 120)}}`);
}} else if (passwordField || usernameField) {{
pressEnter(passwordField || usernameField);
}}
return {{ pageUrl: url, title, actions }};
}})())
"#
);
let value = tab
.evaluate(&script, false)?
.value
.and_then(|value| value.as_str().map(|value| value.to_string()))
.and_then(|value| serde_json::from_str::<NbcAuthAdvanceResult>(&value).ok());
Ok(value.filter(|value| !value.actions.is_empty()))
}
fn advance_nbc_post_auth_flow(tab: &Arc<Tab>) -> Result<Option<NbcAuthAdvanceResult>> {
let script = r#"
JSON.stringify((() => {
const normalize = (value) => (value || "").replace(/\s+/g, " ").trim().toLowerCase();
const visible = (node) => {
if (!node || typeof node.getBoundingClientRect !== "function") return false;
const rect = node.getBoundingClientRect();
const style = window.getComputedStyle(node);
return rect.width > 0 &&
rect.height > 0 &&
style.display !== "none" &&
style.visibility !== "hidden" &&
style.opacity !== "0";
};
const textOf = (node) => normalize([
node.innerText || node.textContent || "",
node.getAttribute?.("aria-label") || "",
node.getAttribute?.("title") || "",
node.getAttribute?.("name") || "",
node.id || "",
].join(" "));
const clickNode = (node, action) => {
if (!node || !visible(node)) return false;
node.scrollIntoView?.({ block: "center", inline: "center" });
node.click?.();
actions.push(action);
return true;
};
const actions = [];
const url = window.location.href || "";
const title = document.title || "";
const bodyText = normalize(document.body?.innerText || "");
const looksLinked = title.toLowerCase().includes("tv provider linked")
|| url.includes("provider-linked")
|| bodyText.includes("tv provider linked");
if (!looksLinked) {
return { pageUrl: url, title, actions };
}
const buttons = Array.from(document.querySelectorAll("button,a,[role='button'],input[type='submit'],input[type='button']"));
const skipButton = buttons.find((node) => {
const text = textOf(node);
return visible(node) && (
text === "skip" ||
text.startsWith("skip ") ||
text === "continue" ||
text.startsWith("continue watching")
);
});
if (skipButton) {
clickNode(skipButton, `click:${textOf(skipButton).slice(0, 120)}`);
}
return { pageUrl: url, title, actions };
})())
"#;
let value = tab
.evaluate(script, false)?
.value
.and_then(|value| value.as_str().map(|value| value.to_string()))
.and_then(|value| serde_json::from_str::<NbcAuthAdvanceResult>(&value).ok());
Ok(value.filter(|value| !value.actions.is_empty()))
}
fn probe_nbc_page_clues(tab: &Arc<Tab>) -> Option<NbcPageClues> { fn probe_nbc_page_clues(tab: &Arc<Tab>) -> Option<NbcPageClues> {
let value = tab let value = tab
.evaluate( .evaluate(
@ -1098,7 +1525,9 @@ fn probe_nbc_video(tab: &Arc<Tab>) -> Result<NbcVideoState> {
} }
fn wait_for_nbc_playback( fn wait_for_nbc_playback(
browser: &Browser,
tab: &Arc<Tab>, tab: &Arc<Tab>,
source_url: &str,
trace: &Arc<StdMutex<NbcTraceState>>, trace: &Arc<StdMutex<NbcTraceState>>,
auth_mode: AuthMode, auth_mode: AuthMode,
screenshot_out: Option<PathBuf>, screenshot_out: Option<PathBuf>,
@ -1113,21 +1542,84 @@ fn wait_for_nbc_playback(
let mut last_clue_log = Instant::now() - Duration::from_secs(30); let mut last_clue_log = Instant::now() - Duration::from_secs(30);
let mut resumed_after_background_login = false; let mut resumed_after_background_login = false;
let mut resumed_after_authenticated_surface = false; let mut resumed_after_authenticated_surface = false;
let mut optional_profile_signin_recoveries = 0_u8;
let mut last_optional_profile_signin_retry = None::<Instant>;
let mut watch_surface_seen_at = None::<Instant>; let mut watch_surface_seen_at = None::<Instant>;
let mut tracked_tabs = HashSet::new();
let mut provider_linked_completed = false;
loop { loop {
kick_nbc_player(tab).ok(); register_nbc_trace_handlers_for_browser(browser, trace, &mut tracked_tabs);
if let Some(progress) = advance_nbc_auth_flow(tab).ok().flatten() { let tab_states = collect_browser_tab_states(browser);
let primary_state = find_primary_tab_state(&tab_states, tab)
.map(|value| value.state.clone())
.unwrap_or_else(|| probe_nbc_video(tab).unwrap_or_default());
if let Some(playing_tab) = find_playing_tab_state(&tab_states) {
return Ok(WaitOutcome {
state: playing_tab.state.clone(),
trace: trace.lock().map(|state| state.clone()).unwrap_or_default(),
interactive_auth_required,
screenshot_path,
});
}
let interaction_tab = find_interaction_tab_state(&tab_states, tab)
.map(|value| value.tab.clone())
.unwrap_or_else(|| tab.clone());
let _ = interaction_tab.activate();
kick_nbc_player(&interaction_tab).ok();
let pre_state = probe_nbc_video(&interaction_tab).unwrap_or_default();
if nbc_title_looks_like_provider_linked(&pre_state.title)
|| nbc_url_is_provider_linked(&pre_state.page_url)
{
provider_linked_completed = true;
}
let pre_trace_state = trace.lock().map(|state| state.clone()).unwrap_or_default();
let pre_authorized = nbc_trace_is_authorized(&pre_trace_state) || provider_linked_completed;
if let Some(progress) = advance_nbc_post_auth_flow(&interaction_tab).ok().flatten() {
if nbc_title_looks_like_provider_linked(&progress.title)
|| nbc_url_is_provider_linked(&progress.page_url)
|| progress
.actions
.iter()
.any(|action| action.starts_with("click:skip"))
{
provider_linked_completed = true;
}
tracing::info!( tracing::info!(
title = %progress.title, title = %progress.title,
page_url = %progress.page_url, page_url = %progress.page_url,
actions = ?progress.actions, actions = ?progress.actions,
"advanced NBC interactive auth flow" "advanced NBC post-auth flow"
); );
} }
let state = probe_nbc_video(tab).unwrap_or_default(); if !pre_authorized {
if let Some(progress) = advance_mvpd_login_flow(&interaction_tab).ok().flatten() {
tracing::info!(
title = %progress.title,
page_url = %progress.page_url,
actions = ?progress.actions,
"advanced NBC MVPD login flow"
);
}
if let Some(progress) = advance_nbc_auth_flow(&interaction_tab).ok().flatten() {
tracing::info!(
title = %progress.title,
page_url = %progress.page_url,
actions = ?progress.actions,
"advanced NBC interactive auth flow"
);
}
}
let state = probe_nbc_video(&interaction_tab).unwrap_or_default();
if nbc_title_looks_like_provider_linked(&state.title)
|| nbc_url_is_provider_linked(&state.page_url)
{
provider_linked_completed = true;
}
let trace_state = trace.lock().map(|state| state.clone()).unwrap_or_default(); let trace_state = trace.lock().map(|state| state.clone()).unwrap_or_default();
let authorized = nbc_trace_is_authorized(&trace_state); let authorized = nbc_trace_is_authorized(&trace_state) || provider_linked_completed;
let recent_media_activity = nbc_trace_has_recent_media_activity(&trace_state);
if last_log.elapsed() >= Duration::from_secs(5) { if last_log.elapsed() >= Duration::from_secs(5) {
last_log = Instant::now(); last_log = Instant::now();
@ -1140,6 +1632,7 @@ fn wait_for_nbc_playback(
ready_state = state.ready_state, ready_state = state.ready_state,
paused = state.paused, paused = state.paused,
authorized, authorized,
recent_media_activity,
interactive_auth_required, interactive_auth_required,
"waiting for NBC playback" "waiting for NBC playback"
); );
@ -1149,7 +1642,7 @@ fn wait_for_nbc_playback(
if !interactive_auth_required { if !interactive_auth_required {
interactive_auth_required = true; interactive_auth_required = true;
if let Some(path) = screenshot_out.as_ref() { if let Some(path) = screenshot_out.as_ref() {
save_tab_screenshot(tab, path).ok(); save_tab_screenshot(&interaction_tab, path).ok();
screenshot_path = Some(path.clone()); screenshot_path = Some(path.clone());
} }
tracing::info!( tracing::info!(
@ -1168,21 +1661,65 @@ fn wait_for_nbc_playback(
} }
} }
if trace_state.background_login_complete && !resumed_after_background_login { if (trace_state.background_login_complete
|| nbc_url_is_background_login_complete(&state.page_url))
&& !resumed_after_background_login
{
resumed_after_background_login = true; resumed_after_background_login = true;
tracing::info!( tracing::info!(
title = %state.title, title = %state.title,
"NBC background login completed; reloading watch page" page_url = %state.page_url,
"NBC background login completed; closing auth tabs and reloading watch page"
); );
close_auxiliary_browser_tabs(browser, tab);
let _ = tab.activate();
let _ = tab.evaluate("window.location.reload()", true); let _ = tab.evaluate("window.location.reload()", true);
std::thread::sleep(Duration::from_secs(2)); std::thread::sleep(Duration::from_secs(2));
continue; continue;
} }
let fully_loaded_watch_surface = nbc_page_is_watch_surface(&state.page_url) if authorized
&& state.document_ready_state.eq_ignore_ascii_case("complete"); && nbc_state_is_optional_profile_signin(&state)
&& !recent_media_activity
&& optional_profile_signin_recoveries < 3
&& last_optional_profile_signin_retry
.map(|instant| instant.elapsed() >= Duration::from_secs(3))
.unwrap_or(true)
{
optional_profile_signin_recoveries += 1;
last_optional_profile_signin_retry = Some(Instant::now());
tracing::info!(
title = %state.title,
page_url = %state.page_url,
authorized,
source_url,
optional_profile_signin_recoveries,
"NBC profile sign-in surface detected after authorization; returning to the live source URL"
);
close_auxiliary_browser_tabs(browser, tab);
let _ = tab.activate();
tab.navigate_to(source_url)?;
tab.wait_until_navigated()?;
std::thread::sleep(Duration::from_secs(2));
continue;
}
if authorized && nbc_state_is_optional_profile_signin(&state) && recent_media_activity {
tracing::debug!(
title = %state.title,
page_url = %state.page_url,
"NBC optional profile sign-in is visible but media activity is already in flight; staying on the page"
);
}
let interaction_is_primary = interaction_tab.get_target_id() == tab.get_target_id();
let fully_loaded_watch_surface = (!interactive_auth_required || authorized)
&& interaction_is_primary
&& nbc_page_is_watch_surface(&primary_state.page_url)
&& primary_state
.document_ready_state
.eq_ignore_ascii_case("complete");
if fully_loaded_watch_surface if fully_loaded_watch_surface
&& !state.has_video && !primary_state.has_video
&& last_clue_log.elapsed() >= Duration::from_secs(15) && last_clue_log.elapsed() >= Duration::from_secs(15)
{ {
last_clue_log = Instant::now(); last_clue_log = Instant::now();
@ -1198,13 +1735,13 @@ fn wait_for_nbc_playback(
); );
} }
} }
if fully_loaded_watch_surface && !state.has_video { if fully_loaded_watch_surface && !primary_state.has_video {
watch_surface_seen_at.get_or_insert_with(Instant::now); watch_surface_seen_at.get_or_insert_with(Instant::now);
} else { } else {
watch_surface_seen_at = None; watch_surface_seen_at = None;
} }
if fully_loaded_watch_surface if fully_loaded_watch_surface
&& !state.has_video && !primary_state.has_video
&& !resumed_after_authenticated_surface && !resumed_after_authenticated_surface
&& watch_surface_seen_at && watch_surface_seen_at
.map(|seen_at| seen_at.elapsed() >= Duration::from_secs(3)) .map(|seen_at| seen_at.elapsed() >= Duration::from_secs(3))
@ -1212,8 +1749,8 @@ fn wait_for_nbc_playback(
{ {
resumed_after_authenticated_surface = true; resumed_after_authenticated_surface = true;
tracing::info!( tracing::info!(
title = %state.title, title = %primary_state.title,
page_url = %state.page_url, page_url = %primary_state.page_url,
"NBC browser is on a fully loaded watch surface without video; reloading once" "NBC browser is on a fully loaded watch surface without video; reloading once"
); );
let _ = tab.evaluate("window.location.reload()", true); let _ = tab.evaluate("window.location.reload()", true);
@ -1221,20 +1758,6 @@ fn wait_for_nbc_playback(
continue; continue;
} }
if state.has_video
&& state.width > 0
&& state.height > 0
&& !state.paused
&& (state.current_time > 0.0 || state.ready_state >= 2)
{
return Ok(WaitOutcome {
state,
trace: trace_state,
interactive_auth_required,
screenshot_path,
});
}
last_state = Some(state); last_state = Some(state);
last_trace_state = Some(trace_state); last_trace_state = Some(trace_state);
@ -1293,6 +1816,9 @@ mod tests {
assert!(nbc_url_requires_interactive_auth( assert!(nbc_url_requires_interactive_auth(
"https://secure.verizon.com/signin" "https://secure.verizon.com/signin"
)); ));
assert!(!nbc_url_requires_interactive_auth(
"https://www.nbc.com/sign-in"
));
assert!(!nbc_url_requires_interactive_auth( assert!(!nbc_url_requires_interactive_auth(
"https://www.nbc.com/watch/some-show" "https://www.nbc.com/watch/some-show"
)); ));
@ -1312,4 +1838,65 @@ mod tests {
)); ));
assert!(trace.background_login_complete); assert!(trace.background_login_complete);
} }
#[test]
fn verizon_popup_title_detection_matches_expected_surface() {
assert!(nbc_title_looks_like_verizon_popup(
"Verizon FiOS - sign in - Google Chrome"
));
assert!(!nbc_title_looks_like_verizon_popup("MVPD Picker"));
}
#[test]
fn background_login_url_detection_matches_adobe_completion() {
assert!(nbc_url_is_background_login_complete(
"https://entitlement.auth.adobe.com/entitlement/v4/completeBackgroundLogin.html"
));
assert!(!nbc_url_is_background_login_complete(
"https://www.nbc.com/mvpd-picker"
));
}
#[test]
fn provider_linked_surface_detection_matches_expected_url() {
assert!(nbc_url_is_provider_linked(
"https://www.nbc.com/provider-linked"
));
assert!(nbc_title_looks_like_provider_linked("TV Provider Linked"));
assert!(!nbc_url_is_provider_linked(
"https://www.nbc.com/live?brand=nbc-sports-philadelphia"
));
}
#[test]
fn optional_profile_signin_detection_matches_expected_surface() {
assert!(nbc_url_is_optional_profile_signin(
"https://www.nbc.com/sign-in"
));
assert!(nbc_title_looks_like_optional_profile_signin(
"NBC Account Sign In - NBC.com"
));
assert!(!nbc_url_is_optional_profile_signin(
"https://secure.verizon.com/signin"
));
}
#[test]
fn optional_profile_signin_is_not_treated_as_watch_surface() {
assert!(!nbc_page_is_watch_surface("https://www.nbc.com/sign-in"));
assert!(nbc_page_is_watch_surface(
"https://www.nbc.com/live?brand=nbc-sports-philadelphia"
));
}
#[test]
fn cssott_media_requests_mark_recent_media_activity() {
let mut trace = NbcTraceState::default();
assert!(update_nbc_trace_from_url(
&mut trace,
"https://g001-live-us-cmaf-prd-ak.pcdn03.cssott.com/Content/CMAF_OL2-CTR-4s/Live/channel(123)/1752757734249item-04item_Segment-5754562.mp4"
));
assert!(trace.media_activity_seen);
assert!(nbc_trace_has_recent_media_activity(&trace));
}
} }

View file

@ -0,0 +1,46 @@
# ECP-0107: Forge NBC Popup-Aware Verizon Auth Automation
## Why
The forge NBC worker now reaches the MVPD picker and can select `Verizon Fios`, but the next step
opens a separate `Verizon FiOS - sign in` popup window.
The Linux browser worker was still treating auth as a single-tab flow, so it kept retrying the MVPD
picker instead of entering credentials in the popup and returning to the main NBC watch surface.
## Decision
1. Treat forge NBC auth as a browser-wide flow, not a single-tab flow.
2. Detect and interact with popup tabs whose title or URL indicate MVPD sign-in.
3. Support operational-only Verizon credentials via environment variables or `*_FILE` paths:
- `EVERY_CHANNEL_NBC_MVPD_USERNAME`
- `EVERY_CHANNEL_NBC_MVPD_PASSWORD`
- `EVERY_CHANNEL_NBC_MVPD_USERNAME_FILE`
- `EVERY_CHANNEL_NBC_MVPD_PASSWORD_FILE`
4. After Adobe background-login completion, close auxiliary auth tabs and resume the primary NBC tab.
5. Suppress Chrome crash-restore UI in the forge browser worker so popup automation reaches the
actual MVPD login form instead of browser chrome.
6. Allow the Linux NixOS module to point the NBC worker at root-managed MVPD credential files
without committing secret values.
7. Treat post-auth NBC profile sign-in as optional when live media requests are already in flight;
do not force-navigate away from that surface while CSSOTT/CMAF playback activity is active.
## Consequences
- Forge NBC bootstrap can complete the Verizon popup without manual browser typing when credentials
are provided operationally.
- The credential path stays outside committed repo configuration and can be supplied differently per
host or per session.
- Hosts that want unattended recovery can reference root-managed credential files declaratively
without placing the credentials themselves in git.
- The worker avoids aborting its own live-session startup by bouncing away from an optional NBC
profile screen after Verizon auth has already unlocked media delivery.
- Future MVPD integrations can extend the same popup-aware browser model instead of adding more
picker-only retries.
## Rejected Alternatives
- Keep forcing the MVPD picker tab until the popup resolves itself: rejected because the Verizon
popup is the actual login surface.
- Store MVPD credentials in committed Nix or repo files: rejected because the secret is operator
material and does not belong in versioned configuration.

View file

@ -266,6 +266,8 @@ in
isolateWithUserNetns = true; isolateWithUserNetns = true;
requireMullvad = false; requireMullvad = false;
mullvadLocation = null; mullvadLocation = null;
mvpdUsernameFile = "/var/lib/every-channel/nbc-auth/verizon-user.txt";
mvpdPasswordFile = "/var/lib/every-channel/nbc-auth/verizon-pass.txt";
noSandbox = true; noSandbox = true;
vnc = { vnc = {
enable = true; enable = true;