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::{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<Instant>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
@ -73,6 +76,12 @@ struct WaitOutcome {
screenshot_path: Option<PathBuf>,
}
#[derive(Clone)]
struct BrowserTabState {
tab: Arc<Tab>,
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<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> {
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<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<()> {
@ -936,6 +1126,243 @@ fn advance_nbc_auth_flow(tab: &Arc<Tab>) -> Result<Option<NbcAuthAdvanceResult>>
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> {
let value = tab
.evaluate(
@ -1098,7 +1525,9 @@ fn probe_nbc_video(tab: &Arc<Tab>) -> Result<NbcVideoState> {
}
fn wait_for_nbc_playback(
browser: &Browser,
tab: &Arc<Tab>,
source_url: &str,
trace: &Arc<StdMutex<NbcTraceState>>,
auth_mode: AuthMode,
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 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::<Instant>;
let mut watch_surface_seen_at = None::<Instant>;
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));
}
}