Automate Forge NBC Verizon auth
This commit is contained in:
parent
7c9980667a
commit
340e2346ba
3 changed files with 671 additions and 36 deletions
|
|
@ -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,11 +1542,67 @@ 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 post-auth flow"
|
||||
);
|
||||
}
|
||||
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,
|
||||
|
|
@ -1125,9 +1610,16 @@ fn wait_for_nbc_playback(
|
|||
"advanced NBC interactive auth flow"
|
||||
);
|
||||
}
|
||||
let state = probe_nbc_video(tab).unwrap_or_default();
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue