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::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,11 +1542,67 @@ 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!(
|
||||||
|
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!(
|
tracing::info!(
|
||||||
title = %progress.title,
|
title = %progress.title,
|
||||||
page_url = %progress.page_url,
|
page_url = %progress.page_url,
|
||||||
|
|
@ -1125,9 +1610,16 @@ fn wait_for_nbc_playback(
|
||||||
"advanced NBC interactive auth flow"
|
"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 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue