Browser Automation¶
S4C does not provide an official upload API. Browser automation with Playwright fills that gap by scripting the Episode Wizard UI directly.
When to Use Browser Automation vs API¶
Use the REST / GraphQL API whenever possible — it is faster, more reliable, and requires no headless browser. Fall back to Playwright only for operations that have no API equivalent.
| Operation | Preferred approach |
|---|---|
| Read episode metadata | REST API (/overview) |
| Update title, description, publish date | REST API (/update) |
| File upload | Playwright (no upload API exists) |
| Scheduled publish (initial wizard) | Playwright |
| Change publish date of a published episode | Playwright (REST /update is blocked by the server for already-published episodes) |
| Comment moderation | GraphQL API |
Authentication¶
Playwright uses the same sp_dc / sp_key cookies described in the
Authentication page — no login interaction is needed at runtime.
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context()
context.add_cookies([
{
"name": "sp_dc",
"value": sp_dc,
"domain": ".spotify.com",
"path": "/",
"secure": True,
"httpOnly": True,
"sameSite": "None",
},
{
"name": "sp_key",
"value": sp_key,
"domain": ".spotify.com",
"path": "/",
"secure": True,
"httpOnly": True,
"sameSite": "None",
},
])
page = context.new_page()
Episode Wizard — Step by Step¶
The Episode Wizard is a multi-step React SPA at:
Always embed your Show ID in the URL
The generic URL (/episode/wizard without a show ID) automatically
selects the last show you used. If you manage multiple shows this
silently uploads to the wrong one. Always use the show-specific URL.
Step 1: File Upload¶
The file input is hidden from view but can still receive files via
set_input_files().
# Primary selector
file_input = page.query_selector("#uploadAreaInput")
# Fallback
if not file_input:
file_input = page.wait_for_selector(
"input[type=file]", state="attached", timeout=15000
)
file_input.set_input_files("/path/to/episode.mp3")
Warning
Do not use input[type=file][accept*=audio]. The accept attribute
value is '.mp3, .m4a, ...' — it does not contain the string audio,
so that selector never matches.
Step 2: Detecting Upload Completion¶
After the file is set, the UI automatically advances to the Details step. Wait for the completion signal before proceeding.
# Wait for "Preview ready!" text — up to 5 minutes for large files
page.wait_for_selector("text=Preview ready!", timeout=300000)
# Fallback: wait for the title input to become visible
page.wait_for_selector("#title-input", state="visible", timeout=30000)
Step 3: Title Input¶
title_input = page.wait_for_selector("#title-input", state="visible", timeout=10000)
title_input.click()
title_input.fill("Your Episode Title")
Step 3: Description Input (Slate.js)¶
S4C uses a Slate.js rich-text editor for the description field. Standard
Playwright text-entry methods (fill(), keyboard.type()) do not update
React's internal state — only the DOM changes, so the value is silently
discarded on submit.
Why React Fiber?
React keeps its own virtual DOM tree separate from the browser DOM.
fill() writes to the browser DOM but bypasses the virtual DOM, so
React never registers the change. Traversing the Fiber tree lets you
call the editor's own insertText method, which updates both trees
simultaneously.
desc_js = """(text) => {
const ed = document.querySelector(
"div[contenteditable='true'][data-slate-editor='true']"
);
if (!ed) return 'editor not found';
const fk = Object.keys(ed).find(
k => k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance')
);
if (!fk) return 'fiber not found';
let cur = ed[fk];
let sl = null;
for (let i = 0; i < 100 && cur; i++) {
if (cur.memoizedProps?.editor?.insertText) {
sl = cur.memoizedProps.editor;
break;
}
cur = cur.return;
}
if (!sl) return 'slate not found';
const start = sl.start([]);
const end = sl.end([]);
sl.select({ anchor: start, focus: end });
sl.insertText(text);
return 'ok';
}"""
result = page.evaluate(desc_js, "Your episode description here.")
assert result == "ok", f"Description insert failed: {result}"
Note
If you need to preserve HTML formatting (links, line breaks) in the
description, use the REST API /update endpoint to write
htmlDescription directly after the wizard completes. The Slate.js
approach inserts plain text only.
Step 3: Removing the OneTrust Banner¶
A cookie-consent overlay sometimes blocks click targets. Remove it from the DOM before any click operations in this step.
page.evaluate("""() => {
const ids = [
'onetrust-consent-sdk',
'onetrust-banner-sdk',
'onetrust-pc-dark-filter',
];
ids.forEach(id => {
const el = document.getElementById(id);
if (el) el.remove();
});
document.body.style.overflow = '';
}""")
Step 4: Next Button → Review¶
next_btn = page.wait_for_selector("button:has-text('Next')", timeout=10000)
next_btn.click()
# Do NOT use wait_for_load_state("networkidle") — it times out on SPAs.
# Wait for a DOM element in the next step instead.
page.wait_for_selector("#publish-date-now", state="attached", timeout=20000)
Step 5: Scheduled Publish¶
The "Schedule for later" radio button is visually hidden — click its
<label> instead.
schedule_label = page.query_selector("label[for='publish-date-schedule']")
if schedule_label:
schedule_label.click()
else:
# Fallback: trigger click via JavaScript
page.evaluate("() => { document.getElementById('publish-date-schedule').click(); }")
Step 5: Date Picker¶
# Open the calendar
date_btn = page.wait_for_selector(
"button[class*='calendarDatePicker'], "
"div[class*='calendarDatePicker__wrapper'] button",
timeout=8000,
)
date_btn.click()
# Read the currently displayed month
caption_sel = (
"div[class*='CalendarMonth'][data-visible='true'] "
"div[class*='CalendarMonth_caption'] > strong"
)
current_month_text = page.text_content(caption_sel) # e.g. "June 2026"
# Navigate months if needed
page.click("div[class*='calendarDatePicker__navIcon--left']") # previous month
page.click("div[class*='calendarDatePicker__navIcon--right']") # next month
# Click the target day (no zero-padding — "1", "15", not "01")
day_sel = (
f"xpath=//div[contains(@class, 'CalendarMonth') and @data-visible='true']"
f"//td[text()='{day}']"
)
page.click(day_sel)
Step 5: Time Picker¶
fill() and keyboard.type() do not update React-controlled inputs.
Use React Fiber to invoke onChange directly, then use the native
select_option() for AM/PM (which correctly fires React's synthetic event).
# Hour (12-hour format — zero-padded string, e.g. "06" for 6 AM)
page.wait_for_selector("input[data-testid='hour-picker']", timeout=5000)
page.evaluate("""(hourVal) => {
const input = document.querySelector('input[data-testid="hour-picker"]');
const fk = Object.keys(input).find(
k => k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance')
);
let cur = input[fk];
for (let i = 0; i < 20 && cur; i++) {
if (cur.memoizedProps?.onChange) {
cur.memoizedProps.onChange({ target: { value: hourVal } });
return 'ok';
}
cur = cur.return;
}
return 'onChange not found';
}""", "06")
# Minute
page.evaluate("""(minVal) => {
const input = document.querySelector('input[data-testid="minute-picker"]');
const fk = Object.keys(input).find(
k => k.startsWith('__reactFiber') || k.startsWith('__reactInternalInstance')
);
let cur = input[fk];
for (let i = 0; i < 20 && cur; i++) {
if (cur.memoizedProps?.onChange) {
cur.memoizedProps.onChange({ target: { value: minVal } });
return 'ok';
}
cur = cur.return;
}
}""", "00")
# AM/PM — select_option() correctly fires React's synthetic onChange
page.select_option("select[aria-label='Meridiem picker']", "AM")
Step 6: Schedule Button¶
After selecting "Schedule for later", the Publish button becomes a Schedule button.
schedule_btn = page.wait_for_selector(
"button:has-text('Schedule')", timeout=10000
)
schedule_btn.click()
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
input[type=file][accept*=audio] not found |
The accept attribute value is '.mp3, .m4a, ...' — no audio substring |
Use #uploadAreaInput instead |
| File uploads to the wrong show | The generic wizard URL selects the last-used show | Embed the Show ID in the URL |
| Slate.js description is empty after submit | fill() / keyboard.type() bypass React's virtual DOM |
Use the React Fiber insertText approach above |
| Time picker value not saved | React-controlled inputs ignore direct DOM writes | Use React Fiber onChange for hour/minute |
wait_for_load_state("networkidle") times out |
SPA internal navigation never reaches network-idle | Wait for a specific DOM element in the next step |
| Description HTML formatting lost | Slate.js stores plain text; HTML tags are stripped | Use the REST API /update endpoint to set htmlDescription after the wizard |
publishOn is 1970-01-01 |
Unix timestamp was passed in seconds instead of milliseconds | Overwrite the schedule from the S4C UI |