Record worker browser sessions
This commit is contained in:
@@ -18,6 +18,12 @@ HEADLESS=true
|
|||||||
# CHROME_BINARY=/usr/bin/google-chrome-stable # optional: explicit Chrome binary path
|
# CHROME_BINARY=/usr/bin/google-chrome-stable # optional: explicit Chrome binary path
|
||||||
# CHROMEDRIVER_PATH=drivers/chromedriver # pre-patched driver (run: make patch-driver)
|
# CHROMEDRIVER_PATH=drivers/chromedriver # pre-patched driver (run: make patch-driver)
|
||||||
|
|
||||||
|
# Worker browser-session recordings (Docker Compose enables these by default)
|
||||||
|
RECORD_SESSIONS=false
|
||||||
|
RECORDING_FPS=12
|
||||||
|
RECORDING_MAX_FILES=3
|
||||||
|
RECORDINGS_DIR=recordings
|
||||||
|
|
||||||
# Scenario 1 — comma-separated phone numbers
|
# Scenario 1 — comma-separated phone numbers
|
||||||
PHONE_NUMBERS=+989100000001,+989100000002,+989100000003
|
PHONE_NUMBERS=+989100000001,+989100000002,+989100000003
|
||||||
|
|
||||||
|
|||||||
+4
-7
@@ -1,8 +1,8 @@
|
|||||||
# ── Stage 1: dependency resolver ─────────────────────────────────────────────
|
# ── Stage 1: dependency resolver ─────────────────────────────────────────────
|
||||||
FROM python:3.11-slim AS builder
|
FROM python:3.11-slim AS builder
|
||||||
|
|
||||||
# Install uv
|
# Keep the build independent of GHCR; dependencies already use official PyPI.
|
||||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
RUN pip install --no-cache-dir uv==0.11.31
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -17,12 +17,9 @@ FROM python:3.11-slim AS runtime
|
|||||||
|
|
||||||
# ── Chromium + matching system ChromeDriver ───────────────────────────────────
|
# ── Chromium + matching system ChromeDriver ───────────────────────────────────
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ca-certificates curl chromium chromium-driver \
|
ca-certificates curl chromium chromium-driver ffmpeg xvfb \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# ── uv + venv from builder ────────────────────────────────────────────────────
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Make the venv's binaries the default Python
|
# Make the venv's binaries the default Python
|
||||||
@@ -43,7 +40,7 @@ COPY --from=builder /app/.venv /app/.venv
|
|||||||
|
|
||||||
# Patch Debian's version-matched driver at build time. Runtime jobs require no
|
# Patch Debian's version-matched driver at build time. Runtime jobs require no
|
||||||
# ChromeDriver network download.
|
# ChromeDriver network download.
|
||||||
RUN PYTHONPATH=. python scripts/patch_driver.py && mkdir -p data logs
|
RUN PYTHONPATH=. python scripts/patch_driver.py && mkdir -p data logs recordings
|
||||||
|
|
||||||
# Non-root user for safety
|
# Non-root user for safety
|
||||||
RUN useradd -m -u 1001 seed && chown -R seed:seed /app
|
RUN useradd -m -u 1001 seed && chown -R seed:seed /app
|
||||||
|
|||||||
@@ -169,6 +169,9 @@ make down
|
|||||||
```
|
```
|
||||||
|
|
||||||
The SQLite database is stored in the `seed-data` Docker volume and persists across rebuilds.
|
The SQLite database is stored in the `seed-data` Docker volume and persists across rebuilds.
|
||||||
|
Worker browser sessions are recorded at 12 FPS into the shared `seed-recordings`
|
||||||
|
volume. The admin panel's **Recordings** tab lists and downloads the newest three
|
||||||
|
completed sessions.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -186,6 +189,10 @@ Copy `.env.example` to `.env` and set values before running anything.
|
|||||||
| `DB_PATH` | `data/tracker.db` | Path to the SQLite file |
|
| `DB_PATH` | `data/tracker.db` | Path to the SQLite file |
|
||||||
| `HEADLESS` | `true` | Set to `false` to watch the browser (local only) |
|
| `HEADLESS` | `true` | Set to `false` to watch the browser (local only) |
|
||||||
| `CHROME_BINARY` | _(auto)_ | Explicit path to Chrome, e.g. `/usr/bin/google-chrome-stable` |
|
| `CHROME_BINARY` | _(auto)_ | Explicit path to Chrome, e.g. `/usr/bin/google-chrome-stable` |
|
||||||
|
| `RECORD_SESSIONS` | `false` | Record browser sessions; Docker workers override this to `true` |
|
||||||
|
| `RECORDING_FPS` | `12` | Recording frame rate |
|
||||||
|
| `RECORDING_MAX_FILES` | `3` | Number of completed recordings retained across all workers |
|
||||||
|
| `RECORDINGS_DIR` | `recordings` | Recording directory; Docker uses the shared `/app/recordings` volume |
|
||||||
| `PHONE_NUMBERS` | _(empty)_ | Comma-separated list of phone numbers for Scenario 1 |
|
| `PHONE_NUMBERS` | _(empty)_ | Comma-separated list of phone numbers for Scenario 1 |
|
||||||
| `FLOW_STEPS` | `step_home` | Comma-separated step names for the legacy step-registry runner |
|
| `FLOW_STEPS` | `step_home` | Comma-separated step names for the legacy step-registry runner |
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
import speedtest # type: ignore[import-untyped]
|
import speedtest # type: ignore[import-untyped]
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@@ -232,6 +235,63 @@ async def list_batch_runs(_: Auth) -> list[dict[str, object]]:
|
|||||||
return await batch_runs_repo.list() # type: ignore[return-value]
|
return await batch_runs_repo.list() # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Browser-session recordings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _recording_path(filename: str) -> Path:
|
||||||
|
if filename != Path(filename).name or Path(filename).suffix.lower() != ".mp4":
|
||||||
|
raise HTTPException(status_code=404, detail="Recording not found")
|
||||||
|
directory = Path(config.recordings_dir).resolve()
|
||||||
|
path = directory / filename
|
||||||
|
if path.is_symlink() or not path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="Recording not found")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/recordings")
|
||||||
|
def list_recordings(_: Auth) -> list[dict[str, object]]:
|
||||||
|
directory = Path(config.recordings_dir)
|
||||||
|
if not directory.is_dir():
|
||||||
|
return []
|
||||||
|
|
||||||
|
rows: list[dict[str, object]] = []
|
||||||
|
for path in directory.glob("*.mp4"):
|
||||||
|
if path.is_symlink():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
stat_result = path.stat()
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"name": path.name,
|
||||||
|
"size_bytes": stat_result.st_size,
|
||||||
|
"created_at": datetime.fromtimestamp(
|
||||||
|
stat_result.st_mtime, tz=UTC
|
||||||
|
).isoformat(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
rows.sort(key=lambda row: str(row["created_at"]), reverse=True)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/recordings/{filename}")
|
||||||
|
def download_recording(filename: str, _: Auth) -> FileResponse:
|
||||||
|
path = _recording_path(filename)
|
||||||
|
return FileResponse(path, media_type="video/mp4", filename=path.name)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/recordings/{filename}", status_code=204)
|
||||||
|
def delete_recording(filename: str, _: Auth) -> None:
|
||||||
|
path = _recording_path(filename)
|
||||||
|
try:
|
||||||
|
path.unlink()
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="Recording not found") from None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Log viewer
|
# Log viewer
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -348,6 +348,7 @@
|
|||||||
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
||||||
<div class="tab" data-tab="flows">Flow Configs</div>
|
<div class="tab" data-tab="flows">Flow Configs</div>
|
||||||
<div class="tab" data-tab="batch">Batch Run</div>
|
<div class="tab" data-tab="batch">Batch Run</div>
|
||||||
|
<div class="tab" data-tab="recordings">Recordings</div>
|
||||||
<div class="tab" data-tab="logs">Logs</div>
|
<div class="tab" data-tab="logs">Logs</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -589,6 +590,29 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Recordings Tab ──────────────────────────────────────────── -->
|
||||||
|
<div class="tab-panel" id="tab-recordings">
|
||||||
|
<main>
|
||||||
|
<div class="panel" style="max-width:1100px">
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;margin-bottom:14px">
|
||||||
|
<div>
|
||||||
|
<div class="section-title" style="margin:0">Browser Session Recordings</div>
|
||||||
|
<div style="color:var(--muted);font-size:12px;margin-top:4px">The newest three completed worker sessions are retained at 12 FPS.</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn" onclick="loadRecordings()" style="margin-left:auto;padding:5px 14px;font-size:12px">↻ Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div style="overflow-x:auto">
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>Recorded</th><th>Session</th><th>Size</th><th>Actions</th></tr></thead>
|
||||||
|
<tbody id="recordings-tbody">
|
||||||
|
<tr><td colspan="4" class="empty">No recordings yet.</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── Logs Tab ───────────────────────────────────────────────── -->
|
<!-- ── Logs Tab ───────────────────────────────────────────────── -->
|
||||||
<div class="tab-panel" id="tab-logs">
|
<div class="tab-panel" id="tab-logs">
|
||||||
<main>
|
<main>
|
||||||
@@ -712,6 +736,7 @@ document.querySelectorAll('.tab').forEach(tab => {
|
|||||||
document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active');
|
document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active');
|
||||||
if (tab.dataset.tab === 'flows') loadConfigs();
|
if (tab.dataset.tab === 'flows') loadConfigs();
|
||||||
if (tab.dataset.tab === 'batch') { loadConfigs(); pollBatchStatus(); loadBatchHistory(); }
|
if (tab.dataset.tab === 'batch') { loadConfigs(); pollBatchStatus(); loadBatchHistory(); }
|
||||||
|
if (tab.dataset.tab === 'recordings') loadRecordings();
|
||||||
if (tab.dataset.tab === 'logs') { loadLogs(); _startLogPoll(); }
|
if (tab.dataset.tab === 'logs') { loadLogs(); _startLogPoll(); }
|
||||||
else _stopLogPoll();
|
else _stopLogPoll();
|
||||||
});
|
});
|
||||||
@@ -1421,6 +1446,56 @@ async function loadBatchHistory() {
|
|||||||
renderBatchHistory(await res.json());
|
renderBatchHistory(await res.json());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────── Recordings ─────────────────────── */
|
||||||
|
function fmtBytes(bytes) {
|
||||||
|
if (!bytes) return '0 B';
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||||
|
return `${(bytes / Math.pow(1024, index)).toFixed(index ? 1 : 0)} ${units[index]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecordings() {
|
||||||
|
const res = await apiFetch('/api/recordings');
|
||||||
|
if (!res || !res.ok) return;
|
||||||
|
const rows = await res.json();
|
||||||
|
const tbody = document.getElementById('recordings-tbody');
|
||||||
|
if (!rows.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="4" class="empty">No recordings yet.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = rows.map(row => {
|
||||||
|
const encodedName = encodeURIComponent(row.name);
|
||||||
|
return `<tr>
|
||||||
|
<td style="white-space:nowrap;color:var(--muted)">${fmtTime(row.created_at)}</td>
|
||||||
|
<td style="font-family:monospace;font-size:12px">${esc(row.name)}</td>
|
||||||
|
<td style="white-space:nowrap;color:var(--muted)">${fmtBytes(row.size_bytes)}</td>
|
||||||
|
<td style="white-space:nowrap">
|
||||||
|
<button class="btn" onclick="downloadRecording('${encodedName}')" style="padding:4px 10px;font-size:12px">↓ Download</button>
|
||||||
|
<button class="btn btn-danger btn-sm" onclick="deleteRecording('${encodedName}')">Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadRecording(encodedName) {
|
||||||
|
const res = await apiFetch(`/api/recordings/${encodedName}`);
|
||||||
|
if (!res || !res.ok) return;
|
||||||
|
const blobUrl = URL.createObjectURL(await res.blob());
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.download = decodeURIComponent(encodedName);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteRecording(encodedName) {
|
||||||
|
if (!confirm(`Delete recording ${decodeURIComponent(encodedName)}?`)) return;
|
||||||
|
const res = await apiFetch(`/api/recordings/${encodedName}`, { method: 'DELETE' });
|
||||||
|
if (res && res.ok) await loadRecordings();
|
||||||
|
}
|
||||||
|
|
||||||
/* ─────────────────────────── Logs ──────────────────────────── */
|
/* ─────────────────────────── Logs ──────────────────────────── */
|
||||||
let _logPollTimer = null;
|
let _logPollTimer = null;
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ class Config:
|
|||||||
headless: bool = os.getenv("HEADLESS", "true").lower() == "true"
|
headless: bool = os.getenv("HEADLESS", "true").lower() == "true"
|
||||||
chrome_binary: str | None = os.getenv("CHROME_BINARY")
|
chrome_binary: str | None = os.getenv("CHROME_BINARY")
|
||||||
chromedriver_path: str = os.getenv("CHROMEDRIVER_PATH", "drivers/chromedriver")
|
chromedriver_path: str = os.getenv("CHROMEDRIVER_PATH", "drivers/chromedriver")
|
||||||
|
record_sessions: bool = os.getenv("RECORD_SESSIONS", "false").lower() == "true"
|
||||||
|
recording_fps: int = int(os.getenv("RECORDING_FPS", "12"))
|
||||||
|
recording_max_files: int = int(os.getenv("RECORDING_MAX_FILES", "3"))
|
||||||
|
recordings_dir: str = os.getenv("RECORDINGS_DIR", "recordings")
|
||||||
|
|
||||||
# Scenario 1 — SMS-OTP: list of phone numbers (one per line in env or comma-separated)
|
# Scenario 1 — SMS-OTP: list of phone numbers (one per line in env or comma-separated)
|
||||||
phone_numbers: list[str] = field(default_factory=list)
|
phone_numbers: list[str] = field(default_factory=list)
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ def _single_run(
|
|||||||
log.info("[batch:%s] Run %d starting", batch_id[:8], run_index)
|
log.info("[batch:%s] Run %d starting", batch_id[:8], run_index)
|
||||||
try:
|
try:
|
||||||
target_url: str = cfg.get("target_url") or ""
|
target_url: str = cfg.get("target_url") or ""
|
||||||
with driver_session(headless=headless) as driver:
|
with driver_session(headless=headless, recording_name=identifier) as driver:
|
||||||
FreshSessionScenario(driver, target_url).run()
|
FreshSessionScenario(driver, target_url).run()
|
||||||
result = DynamicFlow(driver, cfg).run() if cfg else None
|
result = DynamicFlow(driver, cfg).run() if cfg else None
|
||||||
|
|
||||||
|
|||||||
+29
-4
@@ -20,6 +20,7 @@ from selenium.webdriver.chrome.options import Options
|
|||||||
from selenium_stealth import stealth
|
from selenium_stealth import stealth
|
||||||
|
|
||||||
from config import config
|
from config import config
|
||||||
|
from crawler.recording import SessionRecorder
|
||||||
from logger import get_logger
|
from logger import get_logger
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
@@ -88,6 +89,7 @@ def _build_options(
|
|||||||
headless: bool,
|
headless: bool,
|
||||||
chrome_binary: str,
|
chrome_binary: str,
|
||||||
profile_dir: str | None = None,
|
profile_dir: str | None = None,
|
||||||
|
display: str | None = None,
|
||||||
) -> Options:
|
) -> Options:
|
||||||
opts = Options()
|
opts = Options()
|
||||||
opts.binary_location = chrome_binary
|
opts.binary_location = chrome_binary
|
||||||
@@ -96,6 +98,9 @@ def _build_options(
|
|||||||
opts.add_argument("--headless=new")
|
opts.add_argument("--headless=new")
|
||||||
if profile_dir:
|
if profile_dir:
|
||||||
opts.add_argument(f"--user-data-dir={profile_dir}")
|
opts.add_argument(f"--user-data-dir={profile_dir}")
|
||||||
|
if display:
|
||||||
|
opts.add_argument(f"--display={display}")
|
||||||
|
opts.add_argument("--ozone-platform=x11")
|
||||||
opts.add_argument(f"--user-agent={user_agent}")
|
opts.add_argument(f"--user-agent={user_agent}")
|
||||||
opts.add_argument("--no-sandbox")
|
opts.add_argument("--no-sandbox")
|
||||||
opts.add_argument("--disable-gpu")
|
opts.add_argument("--disable-gpu")
|
||||||
@@ -109,12 +114,13 @@ def _build_options(
|
|||||||
def make_driver(
|
def make_driver(
|
||||||
headless: bool | None = None,
|
headless: bool | None = None,
|
||||||
profile_dir: str | None = None,
|
profile_dir: str | None = None,
|
||||||
|
display: str | None = None,
|
||||||
) -> uc.Chrome:
|
) -> uc.Chrome:
|
||||||
"""Return a stealthed undetected Chrome instance."""
|
"""Return a stealthed undetected Chrome instance."""
|
||||||
use_headless = config.headless if headless is None else headless
|
use_headless = config.headless if headless is None else headless
|
||||||
chrome_binary, major = _detect_chrome()
|
chrome_binary, major = _detect_chrome()
|
||||||
ua = random.choice(_USER_AGENTS)
|
ua = random.choice(_USER_AGENTS)
|
||||||
opts = _build_options(ua, use_headless, chrome_binary, profile_dir)
|
opts = _build_options(ua, use_headless, chrome_binary, profile_dir, display)
|
||||||
|
|
||||||
log.debug("Starting ChromeDriver (headless=%s, Chrome %d)", use_headless, major)
|
log.debug("Starting ChromeDriver (headless=%s, Chrome %d)", use_headless, major)
|
||||||
|
|
||||||
@@ -125,7 +131,9 @@ def make_driver(
|
|||||||
use_subprocess=True,
|
use_subprocess=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not use_headless:
|
if display:
|
||||||
|
driver.set_window_size(1920, 1080)
|
||||||
|
elif not use_headless:
|
||||||
driver.maximize_window()
|
driver.maximize_window()
|
||||||
else:
|
else:
|
||||||
driver.set_window_size(1920, 1080)
|
driver.set_window_size(1920, 1080)
|
||||||
@@ -180,11 +188,26 @@ def _terminate_profile_processes(profile_dir: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def driver_session(headless: bool | None = None) -> Generator[uc.Chrome, None, None]:
|
def driver_session(
|
||||||
|
headless: bool | None = None,
|
||||||
|
recording_name: str | None = None,
|
||||||
|
) -> Generator[uc.Chrome, None, None]:
|
||||||
profile_dir = tempfile.mkdtemp(prefix="seed-chrome-")
|
profile_dir = tempfile.mkdtemp(prefix="seed-chrome-")
|
||||||
driver: uc.Chrome | None = None
|
driver: uc.Chrome | None = None
|
||||||
|
recorder: SessionRecorder | None = None
|
||||||
|
display: str | None = None
|
||||||
|
effective_headless = config.headless if headless is None else headless
|
||||||
try:
|
try:
|
||||||
driver = make_driver(headless, profile_dir=profile_dir)
|
if config.record_sessions:
|
||||||
|
recorder = SessionRecorder(recording_name)
|
||||||
|
if recorder.start():
|
||||||
|
display = recorder.display
|
||||||
|
effective_headless = False
|
||||||
|
driver = make_driver(
|
||||||
|
effective_headless,
|
||||||
|
profile_dir=profile_dir,
|
||||||
|
display=display,
|
||||||
|
)
|
||||||
yield driver
|
yield driver
|
||||||
finally:
|
finally:
|
||||||
_terminate_profile_processes(profile_dir)
|
_terminate_profile_processes(profile_dir)
|
||||||
@@ -194,6 +217,8 @@ def driver_session(headless: bool | None = None) -> Generator[uc.Chrome, None, N
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
log.warning("Driver shutdown error: %s", exc)
|
log.warning("Driver shutdown error: %s", exc)
|
||||||
_terminate_profile_processes(profile_dir)
|
_terminate_profile_processes(profile_dir)
|
||||||
|
if recorder is not None:
|
||||||
|
recorder.stop()
|
||||||
shutil.rmtree(profile_dir, ignore_errors=True)
|
shutil.rmtree(profile_dir, ignore_errors=True)
|
||||||
log.debug("Driver session closed")
|
log.debug("Driver session closed")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
"""Per-session Xvfb/FFmpeg recording with shared-volume retention."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from config import config
|
||||||
|
from logger import get_logger
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
|
_DISPLAY_LOCK = threading.Lock()
|
||||||
|
_SAFE_NAME = re.compile(r"[^A-Za-z0-9_.-]+")
|
||||||
|
_WIDTH = 1920
|
||||||
|
_HEIGHT = 1080
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_label(label: str | None) -> str:
|
||||||
|
cleaned = _SAFE_NAME.sub("-", label or "session").strip("-._")
|
||||||
|
return cleaned[:80] or "session"
|
||||||
|
|
||||||
|
|
||||||
|
def _stop_process(process: subprocess.Popen[bytes] | None, *, interrupt: bool = False) -> None:
|
||||||
|
if process is None or process.poll() is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
process.send_signal(signal.SIGINT if interrupt else signal.SIGTERM)
|
||||||
|
process.wait(timeout=10)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
process.wait(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_recordings(directory: Path) -> None:
|
||||||
|
keep = max(1, config.recording_max_files)
|
||||||
|
lock_path = directory / ".retention.lock"
|
||||||
|
with lock_path.open("a+b") as lock_file:
|
||||||
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
||||||
|
recordings = sorted(
|
||||||
|
directory.glob("*.mp4"),
|
||||||
|
key=lambda path: path.stat().st_mtime_ns,
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
for stale in recordings[keep:]:
|
||||||
|
try:
|
||||||
|
stale.unlink()
|
||||||
|
log.info("Removed expired recording %s", stale.name)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SessionRecorder:
|
||||||
|
"""Own one virtual display and one FFmpeg recorder."""
|
||||||
|
|
||||||
|
def __init__(self, label: str | None = None) -> None:
|
||||||
|
self.label = _safe_label(label)
|
||||||
|
self.display: str | None = None
|
||||||
|
self._xvfb: subprocess.Popen[bytes] | None = None
|
||||||
|
self._ffmpeg: subprocess.Popen[bytes] | None = None
|
||||||
|
self._temp_path: Path | None = None
|
||||||
|
self._final_path: Path | None = None
|
||||||
|
|
||||||
|
def start(self) -> bool:
|
||||||
|
directory = Path(config.recordings_dir)
|
||||||
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S.%fZ")
|
||||||
|
token = uuid.uuid4().hex[:8]
|
||||||
|
self._final_path = directory / f"{self.label}-{stamp}-{token}.mp4"
|
||||||
|
self._temp_path = directory / f".{self._final_path.name}.part.mp4"
|
||||||
|
|
||||||
|
try:
|
||||||
|
with _DISPLAY_LOCK:
|
||||||
|
self._start_xvfb()
|
||||||
|
self._start_ffmpeg()
|
||||||
|
except Exception as exc:
|
||||||
|
log.error("Could not start session recording: %s", exc)
|
||||||
|
self.stop(publish=False)
|
||||||
|
return False
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"Recording session to %s at %d FPS",
|
||||||
|
self._final_path.name,
|
||||||
|
config.recording_fps,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _start_xvfb(self) -> None:
|
||||||
|
for display_number in range(100, 1000):
|
||||||
|
socket_path = Path(f"/tmp/.X11-unix/X{display_number}")
|
||||||
|
if socket_path.exists():
|
||||||
|
continue
|
||||||
|
display = f":{display_number}"
|
||||||
|
process = subprocess.Popen(
|
||||||
|
[
|
||||||
|
"Xvfb",
|
||||||
|
display,
|
||||||
|
"-screen",
|
||||||
|
"0",
|
||||||
|
f"{_WIDTH}x{_HEIGHT}x24",
|
||||||
|
"-nolisten",
|
||||||
|
"tcp",
|
||||||
|
"-ac",
|
||||||
|
],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
deadline = time.monotonic() + 5
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if process.poll() is not None:
|
||||||
|
stderr = (process.stderr.read() if process.stderr else b"").decode(
|
||||||
|
"utf-8", errors="replace"
|
||||||
|
)
|
||||||
|
raise RuntimeError(f"Xvfb exited early: {stderr.strip()}")
|
||||||
|
if socket_path.exists():
|
||||||
|
self.display = display
|
||||||
|
self._xvfb = process
|
||||||
|
return
|
||||||
|
time.sleep(0.05)
|
||||||
|
_stop_process(process)
|
||||||
|
raise RuntimeError("no free X display was available")
|
||||||
|
|
||||||
|
def _start_ffmpeg(self) -> None:
|
||||||
|
if self.display is None or self._temp_path is None:
|
||||||
|
raise RuntimeError("virtual display is not ready")
|
||||||
|
self._ffmpeg = subprocess.Popen(
|
||||||
|
[
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-nostdin",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-f",
|
||||||
|
"x11grab",
|
||||||
|
"-framerate",
|
||||||
|
str(config.recording_fps),
|
||||||
|
"-video_size",
|
||||||
|
f"{_WIDTH}x{_HEIGHT}",
|
||||||
|
"-i",
|
||||||
|
f"{self.display}.0",
|
||||||
|
"-c:v",
|
||||||
|
"libx264",
|
||||||
|
"-preset",
|
||||||
|
"ultrafast",
|
||||||
|
"-threads",
|
||||||
|
"1",
|
||||||
|
"-crf",
|
||||||
|
"28",
|
||||||
|
"-pix_fmt",
|
||||||
|
"yuv420p",
|
||||||
|
str(self._temp_path),
|
||||||
|
],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
)
|
||||||
|
time.sleep(0.2)
|
||||||
|
if self._ffmpeg.poll() is not None:
|
||||||
|
stderr = (self._ffmpeg.stderr.read() if self._ffmpeg.stderr else b"").decode(
|
||||||
|
"utf-8", errors="replace"
|
||||||
|
)
|
||||||
|
raise RuntimeError(f"FFmpeg exited early: {stderr.strip()}")
|
||||||
|
|
||||||
|
def stop(self, *, publish: bool = True) -> Path | None:
|
||||||
|
_stop_process(self._ffmpeg, interrupt=True)
|
||||||
|
_stop_process(self._xvfb)
|
||||||
|
self._ffmpeg = None
|
||||||
|
self._xvfb = None
|
||||||
|
|
||||||
|
if self._temp_path is None or self._final_path is None:
|
||||||
|
return None
|
||||||
|
if not publish or not self._temp_path.exists() or self._temp_path.stat().st_size == 0:
|
||||||
|
self._temp_path.unlink(missing_ok=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
os.replace(self._temp_path, self._final_path)
|
||||||
|
_prune_recordings(self._final_path.parent)
|
||||||
|
log.info("Recording saved: %s", self._final_path.name)
|
||||||
|
return self._final_path
|
||||||
+1
-1
@@ -309,7 +309,7 @@ def run_batch_job(batch_id: str, item: dict[str, Any]) -> None:
|
|||||||
try:
|
try:
|
||||||
target_url: str = cfg.get("target_url") or ""
|
target_url: str = cfg.get("target_url") or ""
|
||||||
scenario: str = cfg.get("scenario") or "dynamic"
|
scenario: str = cfg.get("scenario") or "dynamic"
|
||||||
with driver_session(headless=headless) as driver:
|
with driver_session(headless=headless, recording_name=identifier) as driver:
|
||||||
FreshSessionScenario(driver, target_url).run()
|
FreshSessionScenario(driver, target_url).run()
|
||||||
if cfg:
|
if cfg:
|
||||||
if scenario == "digipay":
|
if scenario == "digipay":
|
||||||
|
|||||||
+11
-1
@@ -33,9 +33,11 @@ services:
|
|||||||
HEADLESS: "true"
|
HEADLESS: "true"
|
||||||
CHROME_BINARY: /usr/bin/chromium
|
CHROME_BINARY: /usr/bin/chromium
|
||||||
CHROMEDRIVER_PATH: /usr/bin/chromedriver
|
CHROMEDRIVER_PATH: /usr/bin/chromedriver
|
||||||
|
RECORDINGS_DIR: /app/recordings
|
||||||
volumes:
|
volumes:
|
||||||
- seed-data:/app/data
|
- seed-data:/app/data
|
||||||
- seed-logs:/app/logs
|
- seed-logs:/app/logs
|
||||||
|
- seed-recordings:/app/recordings
|
||||||
depends_on:
|
depends_on:
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -50,8 +52,9 @@ services:
|
|||||||
worker:
|
worker:
|
||||||
build: .
|
build: .
|
||||||
image: seed:latest
|
image: seed:latest
|
||||||
container_name: seed-worker
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
replicas: 3
|
||||||
command: ["rq", "worker", "--url", "redis://redis:6379/0", "batch"]
|
command: ["rq", "worker", "--url", "redis://redis:6379/0", "batch"]
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
@@ -62,9 +65,14 @@ services:
|
|||||||
HEADLESS: "true"
|
HEADLESS: "true"
|
||||||
CHROME_BINARY: /usr/bin/chromium
|
CHROME_BINARY: /usr/bin/chromium
|
||||||
CHROMEDRIVER_PATH: /usr/bin/chromedriver
|
CHROMEDRIVER_PATH: /usr/bin/chromedriver
|
||||||
|
RECORD_SESSIONS: "true"
|
||||||
|
RECORDING_FPS: "12"
|
||||||
|
RECORDING_MAX_FILES: "3"
|
||||||
|
RECORDINGS_DIR: /app/recordings
|
||||||
volumes:
|
volumes:
|
||||||
- seed-data:/app/data
|
- seed-data:/app/data
|
||||||
- seed-logs:/app/logs
|
- seed-logs:/app/logs
|
||||||
|
- seed-recordings:/app/recordings
|
||||||
depends_on:
|
depends_on:
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -80,5 +88,7 @@ volumes:
|
|||||||
driver: local
|
driver: local
|
||||||
seed-logs:
|
seed-logs:
|
||||||
driver: local
|
driver: local
|
||||||
|
seed-recordings:
|
||||||
|
driver: local
|
||||||
redis-data:
|
redis-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|||||||
Reference in New Issue
Block a user