From 340e2346bafe9017f8b9c021af5525d5bb69605e Mon Sep 17 00:00:00 2001 From: "every.channel" Date: Sun, 3 May 2026 21:20:26 -0700 Subject: [PATCH] Automate Forge NBC Verizon auth --- crates/ec-node/src/nbc.rs | 659 +++++++++++++++++- ...nbc-popup-aware-verizon-auth-automation.md | 46 ++ nix/nixos/ecp-forge.nix | 2 + 3 files changed, 671 insertions(+), 36 deletions(-) create mode 100644 evolution/proposals/ECP-0107-forge-nbc-popup-aware-verizon-auth-automation.md diff --git a/crates/ec-node/src/nbc.rs b/crates/ec-node/src/nbc.rs index c681c66..f62419d 100644 --- a/crates/ec-node/src/nbc.rs +++ b/crates/ec-node/src/nbc.rs @@ -2,6 +2,7 @@ use anyhow::{anyhow, Context, Result}; use headless_chrome::protocol::cdp::Page; use headless_chrome::{Browser, Tab}; use serde::{Deserialize, Serialize}; +use std::collections::HashSet; use std::env; use std::fs; use std::io::{BufRead, BufReader, Cursor, Read, Write}; @@ -21,6 +22,8 @@ struct NbcTraceState { token_verified: bool, mt_session_ready: bool, background_login_complete: bool, + media_activity_seen: bool, + last_media_activity_at: Option, } #[derive(Debug, Default, Clone, Serialize, Deserialize)] @@ -73,6 +76,12 @@ struct WaitOutcome { screenshot_path: Option, } +#[derive(Clone)] +struct BrowserTabState { + tab: Arc, + state: NbcVideoState, +} + enum AuthMode { Forbidden, AllowInteractive { timeout: Duration }, @@ -126,8 +135,10 @@ const NBC_CHROME_BASE_ARGS: &[&str] = &[ "--disable-popup-blocking", "--disable-prompt-on-repost", "--disable-renderer-backgrounding", + "--disable-session-crashed-bubble", "--disable-sync", "--force-color-profile=srgb", + "--hide-crash-restore-bubble", "--metrics-recording-only", "--enable-automation", "--password-store=basic", @@ -225,6 +236,34 @@ fn nbc_mvpd_provider_name() -> String { .unwrap_or_else(|| "Verizon Fios".to_string()) } +fn nbc_secret_value(name: &str) -> Option { + 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 { + nbc_secret_value("EVERY_CHANNEL_NBC_MVPD_USERNAME") +} + +fn nbc_mvpd_password() -> Option { + nbc_secret_value("EVERY_CHANNEL_NBC_MVPD_PASSWORD") +} + pub fn resolve_nbc_chrome_path(override_path: Option<&Path>) -> Result { if let Some(path) = override_path { if path.exists() { @@ -353,7 +392,9 @@ pub fn bootstrap_nbc_auth( tab.wait_until_navigated()?; let outcome = wait_for_nbc_playback( + chrome.browser(), &tab, + &url, &trace, AuthMode::AllowInteractive { timeout: nbc_bootstrap_timeout(), @@ -578,7 +619,14 @@ fn run_nbc_capture_loop( register_nbc_trace_handlers(&tab, trace.clone())?; tab.navigate_to(&url)?; 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 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; 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 } @@ -707,17 +765,43 @@ fn nbc_trace_is_authorized(trace_state: &NbcTraceState) -> bool { || 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 { 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.contains("auth.adobe.com") - || host.contains("verizon") - || path.contains("mvpd") - || path.contains("signin") - || path.contains("login") + host.contains("auth.adobe.com") || host.contains("verizon") || path.contains("mvpd") } fn nbc_page_is_watch_surface(url: &str) -> bool { @@ -725,7 +809,113 @@ fn nbc_page_is_watch_surface(url: &str) -> bool { return false; }; 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> { + browser.register_missing_tabs(); + browser.get_tabs().lock().unwrap().iter().cloned().collect() +} + +fn register_nbc_trace_handlers_for_browser( + browser: &Browser, + trace: &Arc>, + tracked_tabs: &mut HashSet, +) { + 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 { + 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, +) -> 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, +) -> 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) { + 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) -> Result<()> { @@ -936,6 +1126,243 @@ fn advance_nbc_auth_flow(tab: &Arc) -> Result> Ok(value.filter(|value| !value.actions.is_empty())) } +fn advance_mvpd_login_flow(tab: &Arc) -> Result> { + 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::(&value).ok()); + Ok(value.filter(|value| !value.actions.is_empty())) +} + +fn advance_nbc_post_auth_flow(tab: &Arc) -> Result> { + 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::(&value).ok()); + Ok(value.filter(|value| !value.actions.is_empty())) +} + fn probe_nbc_page_clues(tab: &Arc) -> Option { let value = tab .evaluate( @@ -1098,7 +1525,9 @@ fn probe_nbc_video(tab: &Arc) -> Result { } fn wait_for_nbc_playback( + browser: &Browser, tab: &Arc, + source_url: &str, trace: &Arc>, auth_mode: AuthMode, screenshot_out: Option, @@ -1113,21 +1542,84 @@ fn wait_for_nbc_playback( let mut last_clue_log = Instant::now() - Duration::from_secs(30); let mut resumed_after_background_login = false; let mut resumed_after_authenticated_surface = false; + let mut optional_profile_signin_recoveries = 0_u8; + let mut last_optional_profile_signin_retry = None::; let mut watch_surface_seen_at = None::; + let mut tracked_tabs = HashSet::new(); + let mut provider_linked_completed = false; loop { - kick_nbc_player(tab).ok(); - if let Some(progress) = advance_nbc_auth_flow(tab).ok().flatten() { + register_nbc_trace_handlers_for_browser(browser, trace, &mut tracked_tabs); + 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!( title = %progress.title, page_url = %progress.page_url, 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 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) { last_log = Instant::now(); @@ -1140,6 +1632,7 @@ fn wait_for_nbc_playback( ready_state = state.ready_state, paused = state.paused, authorized, + recent_media_activity, interactive_auth_required, "waiting for NBC playback" ); @@ -1149,7 +1642,7 @@ fn wait_for_nbc_playback( if !interactive_auth_required { interactive_auth_required = true; 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()); } 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; tracing::info!( 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); std::thread::sleep(Duration::from_secs(2)); continue; } - let fully_loaded_watch_surface = nbc_page_is_watch_surface(&state.page_url) - && state.document_ready_state.eq_ignore_ascii_case("complete"); + if authorized + && 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 - && !state.has_video + && !primary_state.has_video && last_clue_log.elapsed() >= Duration::from_secs(15) { 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); } else { watch_surface_seen_at = None; } if fully_loaded_watch_surface - && !state.has_video + && !primary_state.has_video && !resumed_after_authenticated_surface && watch_surface_seen_at .map(|seen_at| seen_at.elapsed() >= Duration::from_secs(3)) @@ -1212,8 +1749,8 @@ fn wait_for_nbc_playback( { resumed_after_authenticated_surface = true; tracing::info!( - title = %state.title, - page_url = %state.page_url, + title = %primary_state.title, + page_url = %primary_state.page_url, "NBC browser is on a fully loaded watch surface without video; reloading once" ); let _ = tab.evaluate("window.location.reload()", true); @@ -1221,20 +1758,6 @@ fn wait_for_nbc_playback( 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_trace_state = Some(trace_state); @@ -1293,6 +1816,9 @@ mod tests { assert!(nbc_url_requires_interactive_auth( "https://secure.verizon.com/signin" )); + assert!(!nbc_url_requires_interactive_auth( + "https://www.nbc.com/sign-in" + )); assert!(!nbc_url_requires_interactive_auth( "https://www.nbc.com/watch/some-show" )); @@ -1312,4 +1838,65 @@ mod tests { )); 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)); + } } diff --git a/evolution/proposals/ECP-0107-forge-nbc-popup-aware-verizon-auth-automation.md b/evolution/proposals/ECP-0107-forge-nbc-popup-aware-verizon-auth-automation.md new file mode 100644 index 0000000..b9c3f75 --- /dev/null +++ b/evolution/proposals/ECP-0107-forge-nbc-popup-aware-verizon-auth-automation.md @@ -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. diff --git a/nix/nixos/ecp-forge.nix b/nix/nixos/ecp-forge.nix index aa01fd8..6b515a2 100644 --- a/nix/nixos/ecp-forge.nix +++ b/nix/nixos/ecp-forge.nix @@ -266,6 +266,8 @@ in isolateWithUserNetns = true; requireMullvad = false; mullvadLocation = null; + mvpdUsernameFile = "/var/lib/every-channel/nbc-auth/verizon-user.txt"; + mvpdPasswordFile = "/var/lib/every-channel/nbc-auth/verizon-pass.txt"; noSandbox = true; vnc = { enable = true;