diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5dd099c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,120 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +# Cancel an in-flight run on the same branch when a new commit arrives. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # -------------------------------------------------------------------- + # Lint: ruff format + check. Fast (~5 s). + # -------------------------------------------------------------------- + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - run: pip install ruff + - name: ruff check + # Same rule set as .github/workflows/style.yml so the two + # jobs don't disagree. Real-bug rules only (E/F/W/B/C4/UP); + # cosmetic rules (E501 line length, E701/E702 single-line + # try/except, etc.) are intentionally off. + working-directory: SerienJunkie + run: | + ruff check . \ + --select=E,F,W,B,C4,UP \ + --ignore=E501,E701,E731,B008,B904,UP015 \ + --exclude='user.BingeWatcher,tor,extensions,_fresh_profiles,__pycache__' \ + --no-fix \ + --output-format=github + - name: ruff format --check + # Informational only — format drift across the legacy tree + # would be a churn-bomb to enforce strictly. + working-directory: SerienJunkie + continue-on-error: true + run: | + ruff format --check \ + --exclude='user.BingeWatcher,tor,extensions,_fresh_profiles,__pycache__' \ + . || echo "::warning::format drift — run 'ruff format' locally to fix." + + # -------------------------------------------------------------------- + # Type check: mypy on the newly-annotated modules. Currently scoped + # to the new modules; expand as the type-hint sweep progresses. + # -------------------------------------------------------------------- + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - run: pip install mypy + - name: mypy + # --follow-imports=silent so strict checks only apply to the + # listed modules. Without it, mypy walks into legacy + # bingewatcher.py / player_loop.py and surfaces hundreds of + # pre-existing errors that aren't this PR's scope. + run: | + mypy --strict --ignore-missing-imports --follow-imports=silent \ + SerienJunkie/bw/constants.py \ + SerienJunkie/bw/settings_types.py \ + SerienJunkie/bw/shutdown.py \ + SerienJunkie/bw/session.py \ + SerienJunkie/bw/episode_logic.py \ + SerienJunkie/bw/i18n.py + + # -------------------------------------------------------------------- + # Unit tests on Python 3.11 + 3.12. Skips slow (network/browser) tests. + # -------------------------------------------------------------------- + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python: ["3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - run: pip install -r SerienJunkie/requirements.txt pytest pytest-xdist + - name: pytest (fast tier) + working-directory: SerienJunkie + run: pytest tests/ -m "not slow" -n auto --tb=short + + # -------------------------------------------------------------------- + # Dedicated i18n parity check. Runs as a separate job so a missing + # DE translation shows up immediately in the PR status bar with a + # specific failure name (instead of being buried in the test job). + # -------------------------------------------------------------------- + i18n-parity: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: assert EN/DE parity + working-directory: SerienJunkie + run: | + python -c " + import sys + sys.path.insert(0, '.') + from bw import i18n + diff = i18n.diff_keys() + missing = {k: v for k, v in diff.items() if v} + if missing: + for lang, keys in missing.items(): + print(f'::error::{lang} missing {len(keys)} key(s): {keys[:5]}') + sys.exit(1) + print(f'i18n OK — EN={len(i18n.STRINGS[\"en\"])}, DE={len(i18n.STRINGS[\"de\"])}') + " diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 0d17cac..789c709 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -161,6 +161,7 @@ jobs: hits=$(grep -rnE '"useTorProxy"|'"'"'useTorProxy'"'" \ --include='*.py' --include='*.json' \ --exclude='selftest_phase0.py' \ + --exclude-dir='tests' \ . | grep -vE '^[^:]+:[0-9]+:\s*#' || true) if [ -n "$hits" ]; then echo "ERROR: 'useTorProxy' key resurfaced -- Tor must stay mandatory (see Phase 0)." diff --git a/SerienJunkie/assets/toolbar.js b/SerienJunkie/assets/toolbar.js index 964d6db..63e1fee 100644 --- a/SerienJunkie/assets/toolbar.js +++ b/SerienJunkie/assets/toolbar.js @@ -431,7 +431,7 @@ const bar = document.createElement('div'); bar.className = 'bw-tb'; bar.innerHTML = ` - @@ -610,7 +610,7 @@ document.exitPictureInPicture().catch(()=>{}); } else { v.requestPictureInPicture().catch((err) => { - try { window.top.postMessage({type:'bw-toast', msg:'PiP nicht möglich: ' + (err.message || err.name), level:'warn'}, '*'); } catch(_) {} + try { window.top.postMessage({type:'bw-toast', msg:'PiP not possible: ' + (err.message || err.name), level:'warn'}, '*'); } catch(_) {} }); } } catch(_) {} @@ -1308,8 +1308,8 @@ ecIcon.style.cursor = 'pointer'; ecIcon.setAttribute('role', 'button'); ecIcon.setAttribute('tabindex', '0'); - ecIcon.setAttribute('aria-label', 'Jetzt zur nächsten Folge'); - ecIcon.setAttribute('title', 'Jetzt zur nächsten Folge'); + ecIcon.setAttribute('aria-label', 'Go to next episode now'); + ecIcon.setAttribute('title', 'Go to next episode now'); const skipNow = (e) => { try { e.preventDefault(); e.stopPropagation(); } catch(_){} try { @@ -1757,7 +1757,7 @@ if (!inWindow) { pill.removeAttribute('data-open'); return; } pillState = 'predict'; pill.setAttribute('data-mode', 'predict'); - if (pillText) pillText.textContent = 'Intro überspringen'; + if (pillText) pillText.textContent = 'Skip intro'; if (pillShortcut) pillShortcut.textContent = 'I'; pill.setAttribute('data-open', '1'); } else { diff --git a/SerienJunkie/bingewatcher.py b/SerienJunkie/bingewatcher.py index 489cb17..3aad8eb 100644 --- a/SerienJunkie/bingewatcher.py +++ b/SerienJunkie/bingewatcher.py @@ -46,8 +46,22 @@ from selenium.webdriver.common.actions.pointer_input import PointerInput # === CONFIGURATION === -HEADLESS: bool = os.getenv("BW_HEADLESS", "false").lower() in {"1", "true", "yes"} -START_URL: str = os.getenv("BW_START_URL", "https://aniworld.to/") +# Tuning knobs live in ``bw.constants`` so a change shows up in one +# obvious place. Re-exports here are for any out-of-tree imports that +# still grab them via ``from bingewatcher import HEADLESS`` etc. +from bw.constants import ( # noqa: E402 + HEADLESS, + DEFAULT_START_URL as START_URL, + INTRO_SKIP_SECONDS, + MAX_RETRIES, + WAIT_TIMEOUT, + PROGRESS_SAVE_INTERVAL, + PKG_ROOT as SCRIPT_DIR, + GECKO_DRIVER_PATH, + TOR_DATA_DIR, + TOR_SOCKS_PORT, +) + _START_URL_ENV_OVERRIDDEN: bool = "BW_START_URL" in os.environ @@ -66,29 +80,6 @@ def get_start_url() -> str: return "https://aniworld.to/" except Exception: return "https://aniworld.to/" -INTRO_SKIP_SECONDS: int = int(os.getenv("BW_INTRO_SKIP", "80")) -MAX_RETRIES: int = int(os.getenv("BW_MAX_RETRIES", "3")) -WAIT_TIMEOUT: int = int(os.getenv("BW_WAIT_TIMEOUT", "25")) -PROGRESS_SAVE_INTERVAL: int = int(os.getenv("BW_PROGRESS_INTERVAL", "5")) - -SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) -GECKO_DRIVER_PATH = os.path.join(SCRIPT_DIR, "geckodriver.exe") - -# Tor's DataDirectory -- this is where Tor writes ``control_auth_cookie`` -# (consumed by bw.tor_persistence for ControlPort auth). Centralised -# here as a single constant after a long-running bug where three of -# four call sites omitted the ``Data`` segment and looked in the wrong -# folder; cookie lookup failed silently, the helper fell back to -# "refuse NULL-auth" (correct security posture), and the sidebar's -# Tor status pill rendered "Tor offline" even when SOCKS traffic was -# happily flowing through a healthy daemon. Use this constant for -# every cookie_dir= argument in this module. -# 2026-05-19: was ``Browser/TorBrowser/Data/Tor`` until the Browser-tree -# cleanup moved tor.exe + data into ``SerienJunkie/tor/``. The stale path -# caused get_circuit_summary() to fail-silent on the ControlPort cookie -# read, which made the sidebar's Tor pill render permanently as OFFLINE -# even though tor.exe was healthy and SOCKS:9050 was answering. -TOR_DATA_DIR = os.path.join(SCRIPT_DIR, "tor", "data") # Privacy bootstrap — read user choices from privacy.json (canonical # disk path, never forensik-routed) and translate them into the env @@ -127,12 +118,12 @@ def get_start_url() -> str: logging.debug("T5/S1 MAC-randomize import failed: %s", _e) # T5/S6 + T5/S7 -- per-session forensic hygiene. -# * Suppress NEW crash dumps for our process tree (MOZ_CRASHREPORTER_* -# env, WER HKCU registry key, SetErrorMode for this process). -# * Shred pre-existing artefacts from previous sessions: -# - Firefox/geckodriver crash dumps under %LOCALAPPDATA%\CrashDumps\ -# - Mozilla crash-report pending/submitted events -# - Geckodriver log files in the SerienJunkie root +# * Suppress NEW crash dumps for our process tree (MOZ_CRASHREPORTER_* +# env, WER HKCU registry key, SetErrorMode for this process). +# * Shred pre-existing artefacts from previous sessions: +# - Firefox/geckodriver crash dumps under %LOCALAPPDATA%\CrashDumps\ +# - Mozilla crash-report pending/submitted events +# - Geckodriver log files in the SerienJunkie root # Best-effort: silent failures are normal (file-in-use, perms) and never # block bot startup. Runs unconditionally on every bot start regardless # of privacy.json toggles -- these defenses have no downside. @@ -187,14 +178,14 @@ def _state(name: str) -> str: # to abort. # # The wrapper distinguishes two failure classes: -# * RuntimeError -- raised by verify_or_record() in strict mode after -# a real hash mismatch. We MUST NOT swallow this, otherwise the -# strict-mode promise is broken. Re-raise it so the bot refuses -# to start. -# * Any other exception -- the integrity check itself crashed -# (file unreadable, manifest corrupted, etc.). Log + continue; -# this is best-effort defence and we don't want the bot to brick -# on a tooling bug. +# * RuntimeError -- raised by verify_or_record() in strict mode after +# a real hash mismatch. We MUST NOT swallow this, otherwise the +# strict-mode promise is broken. Re-raise it so the bot refuses +# to start. +# * Any other exception -- the integrity check itself crashed +# (file unreadable, manifest corrupted, etc.). Log + continue; +# this is best-effort defence and we don't want the bot to brick +# on a tooling bug. try: from bw import integrity as _bw_integrity _bw_integrity.verify_known_binaries() @@ -207,38 +198,12 @@ def _state(name: str) -> str: logging.debug("integrity check skipped: %s", _e) # === STREAMING PROVIDERS === -STREAMING_PROVIDERS = { - "s.to": { - "name": "SerienJunkie", - "base_url": "https://s.to/", - # s.to renamed ``/serie/stream/`` → ``/serie/`` at some - # point. The optional ``stream/`` segment in the regex keeps old - # links in progress.json parseable while the new template - # produces the canonical short form for fresh navigation. - "url_pattern": r"https://s\.to/serie/(?:stream/)?([^/]+)/staffel-(\d+)(?:/episode-(\d+))?", - "episode_url_template": "https://s.to/serie/{series}/staffel-{season}/episode-{episode}", - "color": "#3b82f6" - }, - "aniworld.to": { - "name": "AniWorld", - "base_url": "https://aniworld.to/", - "url_pattern": r"https://aniworld\.to/anime/stream/([^/]+)/staffel-(\d+)/episode-(\d+)", - "episode_url_template": "https://aniworld.to/anime/stream/{series}/staffel-{season}/episode-{episode}", - "color": "#8b5cf6" - }, - # filmpalast.to is a films-only provider with NO progress tracking, - # NO autoplay, and NO series/season/episode concept. The only state - # the bot maintains for it is a bookmark watchlist (see - # bw/filmpalast.py + _drain_filmpalast_ipc). url_pattern / - # episode_url_template are deliberately absent so the legacy series - # code paths never try to parse a filmpalast URL. - "filmpalast.to": { - "name": "FilmPalast", - "base_url": "https://filmpalast.to/", - "films_only": True, - "color": "#ef4444" - } -} +# Provider registry + URL-parsing helpers live in bw.episode_logic. +from bw.episode_logic import ( # noqa: E402 + STREAMING_PROVIDERS, + norm_series_key, + parse_episode_info, +) # === GLOBAL STATE === current_series: str | None = None @@ -254,20 +219,11 @@ def _state(name: str) -> str: exited_via_dashboard: bool = False -class _ErrorTracker(logging.Filter): - """Logging filter that records whether any ERROR-or-worse record passed - through the root logger. We use this at exit time so the batch wrapper - can auto-close the console only on a fully clean run. - """ - - saw_error: bool = False - - def filter(self, record: logging.LogRecord) -> bool: - if record.levelno >= logging.ERROR: - _ErrorTracker.saw_error = True - return True - +from bw import shutdown as _bw_shutdown # noqa: E402 +# Legacy alias for in-tree callers / out-of-tree code that imports +# ``_ErrorTracker`` from ``bingewatcher``. +_ErrorTracker = _bw_shutdown.ErrorTracker _error_tracker = _ErrorTracker() @@ -306,7 +262,7 @@ def filter(self, record: logging.LogRecord) -> bool: return True # Structured event records (log_event(...)) -- always hidden # from console (they live in bw.events.jsonl + bw.log instead). - # 2026-05-19: was previously only filtered at INFO; the user's + # was previously only filtered at INFO; the user's # console showed event=hardening.applied etc. as plain INFO # because the WARNING-always-pass branch above came first. if getattr(record, "bw_event", None): @@ -389,7 +345,7 @@ def filter(self, record: logging.LogRecord) -> bool: return True logging.getLogger("urllib3.connectionpool").addFilter(_PoolFullFilter()) - # 2026-05-19 fix: PURGE any pre-existing StreamHandlers on the root + # PURGE any pre-existing StreamHandlers on the root # logger before installing our own. Previously we conditionally added # our handler only when none existed, but if some import-time code # (or Python's lastResort handler) had already attached one, our @@ -496,61 +452,30 @@ def webdriver_retry(fn, *args, attempts: int = 3, default=None, _exc=_RETRYABLE_ # === GRACEFUL SHUTDOWN === -_shutdown_callbacks: list[Any] = [] -_shutdown_running: bool = False +# Plumbing lives in ``bw.shutdown``. Mutable state (``should_quit`` and +# friends) stays here, because the play loop reads it from a thousand +# call sites; we just bind the signal handlers to a local setter. +register_shutdown = _bw_shutdown.register +_run_shutdown = _bw_shutdown.run -def register_shutdown(cb) -> None: - if cb not in _shutdown_callbacks: - _shutdown_callbacks.append(cb) - - -def _run_shutdown(reason: str = "exit") -> None: - global _shutdown_running - if _shutdown_running: - return - _shutdown_running = True - logging.info(f"Shutting down ({reason})...") - # Suppress urllib3 / selenium retry warnings during shutdown — geckodriver - # is often already gone (especially after driver.quit() or a user-initiated - # browser close), so any best-effort cleanup HTTP call will spam "Retrying" - # warnings before failing. We don't care: we're exiting. - for noisy in ( - "urllib3", - "urllib3.connectionpool", - "selenium", - "selenium.webdriver.remote.remote_connection", - ): - try: - logging.getLogger(noisy).setLevel(logging.ERROR) - except Exception: - pass - for cb in reversed(_shutdown_callbacks): - try: - cb() - except Exception as e: - logging.debug(f"Shutdown callback {getattr(cb, '__name__', cb)} failed: {e}") +def _on_signal(_signum: int) -> None: + global should_quit + should_quit = True -def _install_signal_handlers() -> None: - def _handler(signum, frame): - global should_quit - logging.info(f"Received signal {signum}, finishing gracefully...") - should_quit = True +_bw_shutdown.install_signal_handlers(_on_signal) - for sig_name in ("SIGINT", "SIGTERM", "SIGBREAK"): - sig = getattr(signal, sig_name, None) - if sig is None: - continue - try: - signal.signal(sig, _handler) - except (ValueError, OSError): - # Not in main thread / not supported on this platform. - pass +# Legacy alias. +_is_pid_alive = _bw_shutdown.is_pid_alive -_install_signal_handlers() -atexit.register(_run_shutdown, "atexit") +# Mirror of exited_via_dashboard for the user-X-close path: True when +# the user closed the Firefox window directly. Counted as a clean exit +# for the launcher batch's auto-close decision. Set in the main-loop +# session-error handler (bingewatcher.py:~10650) when we observe that +# firefox.exe is gone. +exited_via_user_x: bool = False # === KERNEL-LEVEL CHILD-PROCESS GUARANTEE === @@ -594,30 +519,21 @@ def _handler(signum, frame): # is a timeline snapshot of what got watched on which day). # # New rule: -# * progress.json, watch_stats.json -> 1 backup (.bak), no daily. -# These are the files whose corruption would actually lose data, -# so we keep one safety copy. -# * notes.json, bookmarks.json, intro_marks.json, watchlist.json -# -> 1 backup (.bak), no daily. User data, treat the same. -# * settings.json, descriptions.json -> 0 backups. settings is -# trivially regenerated from defaults; descriptions is re-fetched -# on demand from the streaming site. -# * bw.log, bw.events.jsonl, privacy.json, .integrity.json -# -> not written through atomic_write_json (handled elsewhere or -# non-critical), so this map doesn't apply to them. - -# Filename -> backup count. Missing key defaults to BACKUP_DEFAULT. -BACKUP_POLICY: dict[str, int] = { - "progress.json": 1, - "watch_stats.json": 1, - "notes.json": 1, - "bookmarks.json": 1, - "intro_marks.json": 1, - "watchlist.json": 1, - "settings.json": 0, - "descriptions.json": 0, -} -BACKUP_DEFAULT: int = 1 +# * progress.json, watch_stats.json -> 1 backup (.bak), no daily. +# These are the files whose corruption would actually lose data, +# so we keep one safety copy. +# * notes.json, bookmarks.json, intro_marks.json, watchlist.json +# -> 1 backup (.bak), no daily. User data, treat the same. +# * settings.json, descriptions.json -> 0 backups. settings is +# trivially regenerated from defaults; descriptions is re-fetched +# on demand from the streaming site. +# * bw.log, bw.events.jsonl, privacy.json, .integrity.json +# -> not written through atomic_write_json (handled elsewhere or +# non-critical), so this map doesn't apply to them. + +# BACKUP_POLICY + BACKUP_DEFAULT live in ``bw.constants``. Re-imported +# below so existing callers see the same names. +from bw.constants import BACKUP_POLICY, BACKUP_DEFAULT # noqa: E402 def _backup_count_for(path: str) -> int: @@ -661,7 +577,7 @@ def _rotate_backups(path: str, keep: int) -> None: shutil.copy2(path, f"{path}.bak") except OSError: pass - # NOTE: daily backups removed entirely. If a single corrupt + # daily backups removed entirely. If a single corrupt # write fries progress.json AND its .bak, the user manually # restores from a OneDrive/backup tool. The privacy cost of # weekly history snapshots on disk was not worth the recovery @@ -906,7 +822,7 @@ def get_tor_setting() -> bool: return True USE_TOR_PROXY: bool = True -TOR_SOCKS_PORT: int = int(os.getenv("BW_TOR_PORT", "9050")) +# TOR_SOCKS_PORT is re-exported from bw.constants at module top. def assert_tor_reachable(host: str = "127.0.0.1", @@ -1306,11 +1222,7 @@ def set_end_skip_seconds(series: str, seconds: int) -> bool: return False -def norm_series_key(s: str) -> str: - try: - return _html.unescape(str(s or "")).strip() - except Exception: - return str(s or "").strip() +# norm_series_key is imported from bw.episode_logic at module top. # === BROWSER HANDLING --------------------------- === @@ -1388,7 +1300,7 @@ def _wipe_tls_state_on_exit() -> None: options.set_preference("layers.acceleration.disabled", True) options.set_preference("gfx.webrender.force-disabled", True) options.set_preference("media.wmf.dxva.enabled", False) - # NOTE: media.eme.enabled + media.gmp-widevinecdm.enabled are + # media.eme.enabled + media.gmp-widevinecdm.enabled are # forced to False in bw/hardened_profile.py §A.4. They used to # be set True here, which was dead code (apply_hardened_profile # runs LATER and the last set wins). Removed to keep one source @@ -1457,9 +1369,8 @@ def _wipe_tls_state_on_exit() -> None: logging.error(line) logging.error("=" * 70) raise BingeWatcherError( - "Firefox nicht gefunden. Siehe Konsolen-Output für Setup-Anleitung " - "(BW_FIREFOX_BIN env-var ODER Sidebar -> Einstellungen -> Werkzeuge " - "-> Firefox-Pfad)." + i18n.t("console_firefox_not_found", _lang) + " " + + i18n.t("console_firefox_hint", _lang) ) except BingeWatcherError: raise @@ -1472,7 +1383,7 @@ def _wipe_tls_state_on_exit() -> None: if not os.path.exists(GECKO_DRIVER_PATH): raise BingeWatcherError(f"Geckodriver missing under {GECKO_DRIVER_PATH}") - # T5/S7 (2026-05-19 audit) -- silence geckodriver.log to + # T5/S7 -- silence geckodriver.log to # ``os.devnull``. Default behaviour writes URLs, Marionette # calls and JS-execute strings to ``geckodriver.log`` in the # CWD; that file is a plaintext history of every bot action. @@ -1617,7 +1528,7 @@ def arm_window_close_guard(driver): def move_to_primary_and_maximize(driver): - """Platziert das Fenster auf dem Primärmonitor (Monitor 1) und maximiert es.""" + """Place the window on the primary monitor (monitor 1) and maximise it.""" if HEADLESS: return try: @@ -1634,7 +1545,7 @@ def move_to_primary_and_maximize(driver): x, y = int(rect.left), int(rect.top) w, h = int(rect.right - rect.left), int(rect.bottom - rect.top) except Exception: - # 2) Sonst: Primärbildschirm-Größe per tkinter + # 2) Else: primary-display size via tkinter try: import tkinter as tk @@ -1648,7 +1559,7 @@ def move_to_primary_and_maximize(driver): x, y, w, h = 0, 0, 1920, 1080 driver.set_window_position(x, y) - # Entweder explizit auf Arbeitsbereich… + # Either explicitly to the work area… driver.set_window_size(w, h) # …oder OS-Maximize als Alternative: try: @@ -1656,7 +1567,7 @@ def move_to_primary_and_maximize(driver): except Exception: pass except Exception: - # Letzte Rettung + # Last-ditch fallback try: driver.maximize_window() except Exception: @@ -1721,8 +1632,8 @@ def is_browser_responsive(driver: webdriver.Firefox) -> bool: def _candidate_fs_points(driver): """ - Liefert bis zu ~20 Viewport-Koordinaten (x,y), an denen sehr wahrscheinlich ein Fullscreen-Button sitzt. - Muss im *Frame mit dem Video* aufgerufen werden! + Return up to ~20 viewport (x, y) coordinates where a fullscreen button is most likely located. + Must be invoked from within the *frame containing the video*. """ try: pts = driver.execute_script(""" @@ -1738,7 +1649,7 @@ def _candidate_fs_points(driver): '.plyr__controls', '.plyr__controls [data-plyr="fullscreen"]', '.plyr', // Shaka Player '.shaka-controls-container', '.shaka-fullscreen-button', - // Weitere Player + // Nexte Player '.mejs-controls', '.mejs-fullscreen-button', '.flowplayer', '.flowplayer-fullscreen', '.dplayer', '.dplayer-fullscreen', @@ -1749,7 +1660,7 @@ def _candidate_fs_points(driver): const pushBR = (r) => { if (!r || r.width < 20 || r.height < 15) return; - // Bottom-Right Varianten (häufigste Position) + // Bottom-right variants (most common position) out.push({x: Math.floor(r.right - 8), y: Math.floor(r.bottom - 8)}); out.push({x: Math.floor(r.right - 20), y: Math.floor(r.bottom - 12)}); out.push({x: Math.floor(r.right - 36), y: Math.floor(r.bottom - 16)}); @@ -1775,7 +1686,7 @@ def _candidate_fs_points(driver): }); } - // Fallback nur auf video, falls nichts anderes da ist + // Fallback to the video element only when nothing else is available if (out.length === 0) { const v = document.querySelector('video'); if (v) { @@ -1811,8 +1722,8 @@ def _click_viewport_xy(driver, x, y, double=False): def _hard_fullscreen_click(driver) -> bool: """ - Sucht Kandidatenpunkte und klickt dort „wie ein Mensch" - prüft nach jedem Klick auf Fullscreen. - Muss im *Frame mit dem Video* aufgerufen werden! + Probe candidate points and click them "human-like" — verify fullscreen after each click. + Must be invoked from within the *frame containing the video*. """ try: _reveal_controls(driver) @@ -1831,7 +1742,7 @@ def _hard_fullscreen_click(driver) -> bool: _click_viewport_xy(driver, x, y, double=True) time.sleep(0.35) elif strategy == 'long': - # Längerer Klick (für manche Player) + # Longer click (some players need it) try: builder = ActionBuilder(driver) mouse = PointerInput(PointerInput.MOUSE, "mouse") @@ -2240,10 +2151,10 @@ def run(self) -> str: # === SETTINGS HANDLING --------------------------- === -SIDEBAR_WIDTH_MIN = 280 -SIDEBAR_WIDTH_MAX = 560 -SIDEBAR_WIDTH_DEFAULT = 340 -FULLSCREEN_HINTS_MAX = 50 +from bw.constants import ( # noqa: E402 + SIDEBAR_WIDTH_MIN, SIDEBAR_WIDTH_MAX, SIDEBAR_WIDTH_DEFAULT, + FULLSCREEN_HINTS_MAX, +) def _clamp(value: float, lo: float, hi: float) -> float: @@ -2256,112 +2167,22 @@ def _clamp(value: float, lo: float, hi: float) -> float: return max(lo, min(hi, v)) -def _coerce_bool(value: Any, default: bool) -> bool: - if isinstance(value, bool): - return value - if isinstance(value, (int, float)): - return value != 0 - if isinstance(value, str): - return value.strip().lower() in {"1", "true", "yes", "on"} - return default - +# Settings validation now lives in bw.settings_types. These shims keep +# the legacy names alive for any out-of-tree caller still doing +# ``from bingewatcher import _validate_settings``. +from bw import settings_types as _settings_types # noqa: E402 -def _validate_settings(raw: Any) -> dict[str, Any]: - """Clamp + coerce settings against the schema. Always returns a usable dict.""" - d = _default_settings() - if not isinstance(raw, dict): - return d - - d["autoFullscreen"] = _coerce_bool(raw.get("autoFullscreen"), d["autoFullscreen"]) - d["autoSkipIntro"] = _coerce_bool(raw.get("autoSkipIntro"), d["autoSkipIntro"]) - d["autoSkipEndScreen"] = _coerce_bool(raw.get("autoSkipEndScreen"), d["autoSkipEndScreen"]) - d["autoNext"] = _coerce_bool(raw.get("autoNext"), d["autoNext"]) - # ``useTorProxy`` legacy key is intentionally dropped here — Tor is - # always on; see docs/privacy.md "Operating Principle". - - rate = _clamp(raw.get("playbackRate", 1.0), 0.5, 2.0) - # Snap to nearest 0.25 step for stable UI. - d["playbackRate"] = round(rate * 4) / 4 - d["volume"] = _clamp(raw.get("volume", 1.0), 0.0, 1.0) - - width = int(_clamp(raw.get("sidebarWidth", SIDEBAR_WIDTH_DEFAULT), - SIDEBAR_WIDTH_MIN, SIDEBAR_WIDTH_MAX)) - d["sidebarWidth"] = width - - hints = raw.get("fullscreenHints", {}) - if isinstance(hints, dict): - cleaned: dict[str, str] = {} - for k, v in hints.items(): - if isinstance(k, str) and isinstance(v, str) and k and v: - cleaned[k[:128]] = v[:64] - if len(cleaned) >= FULLSCREEN_HINTS_MAX: - break - d["fullscreenHints"] = cleaned - else: - d["fullscreenHints"] = {} +_coerce_bool = _settings_types.coerce_bool - start_page = raw.get("defaultStartPage", "aniworld.to") - if isinstance(start_page, str) and start_page in { - "s.to", "aniworld.to", "filmpalast.to", - }: - d["defaultStartPage"] = start_page - else: - d["defaultStartPage"] = "aniworld.to" - - # Sidebar series sort mode. See _sort_series_entries for the valid - # values. Anything outside the whitelist falls back to "recent". - sort_mode = raw.get("seriesSortMode", "recent") - valid_sort_modes = {"recent", "alpha", "progress", "rating", "added"} - d["seriesSortMode"] = sort_mode if (isinstance(sort_mode, str) and sort_mode in valid_sort_modes) else "recent" - - d["reducedMotion"] = _coerce_bool(raw.get("reducedMotion"), d["reducedMotion"]) - d["showEndCountdown"] = _coerce_bool(raw.get("showEndCountdown"), d["showEndCountdown"]) - d["watchTimeTracking"] = _coerce_bool(raw.get("watchTimeTracking"), d["watchTimeTracking"]) - d["pauseOnTabBlur"] = _coerce_bool(raw.get("pauseOnTabBlur"), d["pauseOnTabBlur"]) - d["pipOnTabBlur"] = _coerce_bool(raw.get("pipOnTabBlur"), d["pipOnTabBlur"]) - d["consoleStartMinimized"] = _coerce_bool( - raw.get("consoleStartMinimized"), d["consoleStartMinimized"] - ) - # One-shot flag: True once the user has dismissed the first-visit - # modal that explains why Tor is bypassed for s.to specifically. - # Set by bw/sto_first_visit_modal.py after the user clicks - # "Verstanden". Persisted so the warning never reappears in - # future sessions. - d["acknowledgedStoTorWarning"] = _coerce_bool( - raw.get("acknowledgedStoTorWarning"), d["acknowledgedStoTorWarning"] - ) - # F6 -- onboarding done flag. One-shot like the s.to warning. - d["onboardingComplete"] = _coerce_bool( - raw.get("onboardingComplete"), d["onboardingComplete"] - ) - # F1 -- notifications toggle (Komfort section). - d["notifications"] = _coerce_bool( - raw.get("notifications"), d["notifications"] - ) - # Firefox-binary override path. Accept strings only; reject empty - # strings AFTER trim (so a blank input means "use auto-detect"). - _ff = raw.get("firefox_path") - if isinstance(_ff, str): - d["firefox_path"] = _ff.strip().strip('"').strip("'") - # else: keep default (empty string) - - # F3 -- persistent cast device. UUID string + display name. Both - # set together when user clicks "Verbinden" in the cast modal. - _cdev = raw.get("castDevice") - if isinstance(_cdev, str): - d["castDevice"] = _cdev.strip() - _cdname = raw.get("castDeviceName") - if isinstance(_cdname, str): - d["castDeviceName"] = _cdname.strip() - - pinned = raw.get("pinnedSeries", []) - if isinstance(pinned, list): - d["pinnedSeries"] = [str(s) for s in pinned if isinstance(s, (str, int))][:200] - else: - d["pinnedSeries"] = [] +def _validate_settings(raw: Any) -> dict[str, Any]: + """Clamp + coerce settings against the schema. Always returns a usable dict. - return d + Delegates to ``bw.settings_types.validate``; kept as a thin shim so + in-tree call sites continue working without touching the schema + every time we add a key. + """ + return _settings_types.validate(raw if isinstance(raw, dict) else None) def get_settings(driver: webdriver.Firefox) -> dict[str, Any]: @@ -2371,78 +2192,21 @@ def get_settings(driver: webdriver.Firefox) -> dict[str, Any]: return _validate_settings(merged) -def load_settings_file() -> dict[str, Any]: - raw = _cached_load_json(SETTINGS_DB_FILE) - if raw is None: - # First-run or just-rescued-corrupt: persist a clean defaults file. - validated = _default_settings() - if not os.path.exists(SETTINGS_DB_FILE): - atomic_write_json(SETTINGS_DB_FILE, validated) - # Mirror the default `consoleStartMinimized=True` to the - # launcher marker file. This is the "second-launch onwards - # minimizes by default" mechanism: the batch checks the - # marker BEFORE Python runs, so on the very first launch - # the marker doesn't exist yet (this line just created it) - # and the console stays normal. Every subsequent launch - # finds the marker and relaunches itself with /MIN. - _sync_console_minimize_marker( - bool(validated.get("consoleStartMinimized", True)) - ) - return validated - return _validate_settings(raw) - - -#: Marker file for the launcher's "start console minimized" check. -#: Lives at a stable absolute path (NOT routed through _state(), so it -#: stays on disk even in forensik RAM mode — the batch needs to read -#: it BEFORE Python boots, so RAM-only state would defeat the purpose). -#: Existence = batch relaunches itself with `start /MIN`. Content is -#: ignored; we write "1\n" purely so the file isn't zero-byte. -_CONSOLE_MIN_MARKER = os.path.join( - os.path.dirname(os.path.abspath(__file__)), # SerienJunkie/ - ".console_minimize_on_start", -) - - -def _sync_console_minimize_marker(value: bool) -> None: - """Mirror the ``consoleStartMinimized`` setting to the launcher's - marker file. Called after every settings save and once on first - boot. No-op on non-Windows (the relaunch trick is cmd-specific). - Silently swallows file-I/O errors — the marker is best-effort UX - polish, not a correctness requirement. - """ - if os.name != "nt": - return - try: - if value: - with open(_CONSOLE_MIN_MARKER, "w", encoding="utf-8") as f: - f.write("1\n") - else: - try: - os.remove(_CONSOLE_MIN_MARKER) - except FileNotFoundError: - pass - except Exception as e: - logging.debug("console-minimize marker sync failed: %s", e) +# Settings file I/O + marker syncs now live in bw.session. Legacy +# aliases below keep callers that import these symbols from +# ``bingewatcher`` working unchanged. +from bw import session as _bw_session # noqa: E402 +load_settings_file = _bw_session.load_settings_file +save_settings_file = _bw_session.save_settings_file +_sync_console_minimize_marker = _bw_session.sync_console_minimize_marker +_sync_console_autoclose_marker = _bw_session.sync_console_autoclose_marker -def save_settings_file(settings: dict[str, Any]) -> bool: - try: - existing = load_settings_file() - merged = {**existing, **(settings or {})} - validated = _validate_settings(merged) - ok = atomic_write_json(SETTINGS_DB_FILE, validated) - if ok: - # Keep the launcher's marker file in sync. If the user - # just flipped the Komfort toggle, the next bot start - # picks up the new behaviour without a manual file edit. - _sync_console_minimize_marker( - bool(validated.get("consoleStartMinimized", True)) - ) - return ok - except Exception as e: - logging.error(f"Saving settings failed: {e}") - return False +# Marker-file path aliases for any direct readers (out of tree). +from bw.constants import ( # noqa: E402 + CONSOLE_MIN_MARKER as _CONSOLE_MIN_MARKER, + CONSOLE_AUTOCLOSE_MARKER as _CONSOLE_AUTOCLOSE_MARKER, +) def _handle_privacy_update_ipc(driver) -> None: @@ -2575,7 +2339,7 @@ def load_episode_titles_map() -> dict[str, dict[str, dict[str, str]]]: the season-overview page. That's the SAME source the sidebar uses for the gray episode-title line under each series row -- so the countdown now shows real titles instead of the placeholder - "Staffel N Episode M" the player-page anchor carries. + "Season N Episode M" the player-page anchor carries. """ out: dict[str, dict[str, dict[str, str]]] = {} try: @@ -2611,7 +2375,7 @@ def sync_episode_titles_to_localstorage(driver) -> None: """Push the full episode-title map to ``window.bwEpisodeTitles`` so the top-frame sidebar IIFE can resolve the next-episode title via cache lookup instead of DOM scrape. The player-page anchor - text on s.to / aniworld is just a placeholder ("Staffel 1 Episode + text on s.to / aniworld is just a placeholder ("Season 1 Episode 18"); the real title only lives on the season-overview page, which we've already fetched and cached in progress.json.""" try: @@ -2990,8 +2754,8 @@ def probe_series_episode_counts(series: str, provider: str = "s.to") -> dict[str Returns a dict:: { - "counts": {1: 24, 2: 12, ...}, # season -> max episode - "titles": {1: {1: "...", 2: "..."}} # season -> {episode -> title} + "counts": {1: 24, 2: 12, ...}, # season -> max episode + "titles": {1: {1: "...", 2: "..."}} # season -> {episode -> title} } Used by the sidebar row renderer for the "S1 · 7/24" badge AND the @@ -3015,12 +2779,12 @@ def probe_series_episode_counts(series: str, provider: str = "s.to") -> dict[str # Aniworld + s.to both render the canonical title inside the # series-title container, in an

element: # - #
- #

- # The strongest job is apparently not a hero ... - #

- # ... - #
+ #
+ #

+ # The strongest job is apparently not a hero ... + #

+ # ... + #
# # We prefer the inner text because the

itself can # carry trailing whitespace and alternate-title strings spliced @@ -3057,17 +2821,17 @@ def probe_series_episode_counts(series: str, provider: str = "s.to") -> dict[str # Aniworld + s.to use this exact season-page row shape (verified # against live HTML at /anime/stream/one-piece/staffel-1): # - # - # - # Der rote Shanks und sein Hut - - # Luffy's Past! ... [Episode 004] - # - # + # + # + # Der rote Shanks und sein Hut - + # Luffy's Past! ... [Episode 004] + # + # # # The German title is the first child of the anchor # inside the seasonEpisodeTitle . We deliberately avoid the # anchor's ``title=`` attribute -- aniworld populates it with - # a generic placeholder ("Staffel 1 Episode 4") rather than + # a generic placeholder ("Season 1 Episode 4") rather than # the real episode title. title_re = re.compile( r']*>\s*' @@ -3354,7 +3118,7 @@ def load_watch_stats() -> dict[str, Any]: { "daily": {"YYYY-MM-DD": {"": seconds_int, "_total": seconds_int}}, "series": {"": seconds_int}, - "total": seconds_int, + "total": seconds_int, } Missing keys are filled with safe defaults.""" raw = _cached_load_json(WATCH_STATS_FILE) @@ -3796,12 +3560,12 @@ def sync_critical_state_to_localstorage(driver: webdriver.Firefox) -> None: Critical means: data the playback path or the end-of-episode countdown reads immediately. - * intro marks -> Skip-Intro pill decides learn/predict mode on + * intro marks -> Skip-Intro pill decides learn/predict mode on toolbar inject, ~600 ms in. If missing, the pill renders the wrong state and corrects itself on the next sync tick, but the user briefly sees the wrong label. - * bookmarks -> Resume sheet checks this on toolbar init for + * bookmarks -> Resume sheet checks this on toolbar init for the per-series last-position. Missing it doesn't break playback but the sheet might offer ``Von vorn`` when the user really wanted @@ -3919,81 +3683,10 @@ def sync_settings_to_localstorage(driver: webdriver.Firefox) -> None: def _default_settings() -> dict[str, Any]: - # Defaults audited 2026-05-19 for maximum-privacy posture per user - # request. Privacy-relevant defaults: - # * watchTimeTracking: False -- no on-disk record of which series - # / episode the user has watched. The Stats heatmap is then - # empty until the user explicitly opts in via the Komfort - # toggle. Trade-off accepted: privacy > stats convenience. - # Other defaults are UX-only (autoplay, fullscreen, etc.) with no - # privacy footprint and keep their user-friendly values. - return { - "autoFullscreen": True, - "autoSkipIntro": True, - "autoSkipEndScreen": False, - "autoNext": True, - "playbackRate": 1.0, - "volume": 1.0, - # Tor is always on — see docs/privacy.md. We keep no - # user-facing toggle for it; legacy ``useTorProxy`` keys in - # settings.json are silently dropped by _validate_settings. - "sidebarWidth": SIDEBAR_WIDTH_DEFAULT, - "fullscreenHints": {}, - "defaultStartPage": "aniworld.to", - "reducedMotion": False, - "showEndCountdown": True, - "watchTimeTracking": False, # max-security default (2026-05-19) - "pauseOnTabBlur": True, - # When the tab loses focus, request Picture-in-Picture so the - # episode keeps playing in a floating window. Opt-in because - # not every user wants a PiP popout; off by default. If both - # this and pauseOnTabBlur are on, PiP wins (the video stays - # playing in its own window). - "pipOnTabBlur": False, - "pinnedSeries": [], - # Sidebar series sort mode. See _sort_series_entries for the - # valid values. "recent" preserves the historical default - # (most-recently-played first). - "seriesSortMode": "recent", - # One-shot: True after the user has read + dismissed the - # "Tor bypassed for s.to" first-visit modal. Default False so - # the warning appears for new users; flipped to True after - # they click "Verstanden". Never auto-flipped back. - "acknowledgedStoTorWarning": False, - # F6 -- one-shot onboarding walkthrough. False = show the - # first-run modal stack. Flipped to True when the user clicks - # "Fertig" or "Skip" in the last slide. Re-openable via - # Settings -> Werkzeuge -> "Onboarding wiederholen". - "onboardingComplete": False, - # F1 -- per-user notifications toggle for new episodes. Default - # True; user can disable in Komfort. - "notifications": True, - # Optional override path to a non-standard Firefox install. Empty - # string = auto-detect via bw.firefox_detect (env > registry > - # standard install paths > PATH). Set via Sidebar -> Werkzeuge - # -> "Firefox-Pfad" file-picker, or directly in settings.json. - # If both BW_FIREFOX_BIN env-var AND this are set, env wins. - "firefox_path": "", - # F3 -- persistent Chromecast device. When set, every new video - # URL is auto-cast to this device on detection. Empty = no - # persistent device (user must click + pick each time). - "castDevice": "", - # Display name of the persistent device, surfaced in the - # sidebar indicator. Updated together with castDevice on - # "Verbinden" click in the cast modal. - "castDeviceName": "", - # Console-window behaviour at launch. True = the launcher - # relaunches itself with `start /MIN` so the cmd window opens - # minimized in the taskbar. Default True so users who have - # finished onboarding don't get a console-stealing-focus on - # every start. The VERY first run still opens normal because - # the marker file (SerienJunkie/.console_minimize_on_start) - # doesn't exist yet — Python creates it after a successful - # boot, and `save_settings_file` keeps it in sync with this - # value going forward (toggle off -> marker deleted -> next - # launch console is normal again). - "consoleStartMinimized": True, - } + """Return a clean defaults dict. Delegates to + ``bw.settings_types.default_settings`` so the schema lives in + exactly one place.""" + return dict(_settings_types.default_settings()) def read_localstorage_value( @@ -4122,32 +3815,14 @@ def detect_provider_from_url(url: str) -> str | None: return provider_id return None -def parse_episode_info(url): - """Erweiterte Episode-Info-Parsing für verschiedene Streaming-Anbieter. - - Films-only providers (e.g. filmpalast.to) carry no `url_pattern` - because they have no season/episode concept — they are skipped here - so the main loop doesn't KeyError every tick when the user is on - one of those domains. - """ - for provider_id, provider_info in STREAMING_PROVIDERS.items(): - pattern = provider_info.get("url_pattern") - if not pattern: - continue # films-only provider, no episode tracking - m = re.search(pattern, url) - if m: - series = m.group(1).lower() - season = int(m.group(2)) - episode = int(m.group(3)) if m.group(3) else None - return series, season, episode, provider_id - return None, None, None, None +# parse_episode_info is imported from bw.episode_logic at module top. def navigate_to_episode(driver, series, season, episode, db, provider="s.to"): - """Navigiert zu einer Episode mit Unterstützung für verschiedene Streaming-Anbieter.""" - series = slugify_series(series) # <- Eingabe normalize + """Navigate to an episode, dispatched per streaming provider.""" + series = slugify_series(series) # normalise the slug - # Verwende den entsprechenden Anbieter + # Pick the matching provider provider_info = STREAMING_PROVIDERS.get(provider, STREAMING_PROVIDERS["s.to"]) target = provider_info["episode_url_template"].format( series=series, season=season, episode=episode @@ -4212,7 +3887,7 @@ def navigate_to_episode(driver, series, season, episode, db, provider="s.to"): if a_series and a_season and a_episode is None: try: driver.switch_to.default_content() - # Anbieter-spezifische CSS-Selektoren + # Provider-specific CSS selectors if provider == "s.to": selector = f'a[href*="/staffel-{a_season}/episode-"]' elif provider == "aniworld.to": @@ -4480,7 +4155,7 @@ def poll_ui_flags(driver): const out={}; try{out.quit = localStorage.getItem('bw_quit')==='1'; localStorage.removeItem('bw_quit');}catch(_){} try{out.skip = localStorage.getItem('bw_skip_now')==='1'; localStorage.removeItem('bw_skip_now');}catch(_){} - try{out.del = localStorage.getItem('bw_seriesToDelete'); if(out.del) localStorage.removeItem('bw_seriesToDelete');}catch(_){} + try{out.del = localStorage.getItem('bw_seriesToDelete'); if(out.del) localStorage.removeItem('bw_seriesToDelete');}catch(_){} try{ out.sel = localStorage.getItem('bw_series'); }catch(_){} // Explicit "user clicked a navigate-away button" signal set by // the provider-switch handlers + Continue-Watching shelf cards @@ -4497,13 +4172,12 @@ def _try_cycle_hoster_in_place(driver) -> bool: episode page. Used by the 232011 recovery as a step between "reload the iframe" and "refresh the whole page". - Why this exists: ``v.load()`` and iframe-src toggles both retry the - SAME hoster URL. If that specific hoster is dead, geo-blocked, or - rate-limiting our Tor exit, none of those recover -- the user - sees 232011 indefinitely. Most s.to / aniworld episodes have 3-5 - mirrors (VOE / Filemoon / Doodstream / Vidmoly / ...); cycling to - one we haven't tried this session is almost always cheaper than - waiting for the dead one to come back. + ``v.load()`` and iframe-src toggles both retry the SAME hoster URL, + so they don't help when a specific hoster is dead, geo-blocked, or + rate-limiting the Tor exit — the user sees 232011 indefinitely. + Most s.to / aniworld episodes have 3–5 mirrors (VOE / Filemoon / + Doodstream / Vidmoly / …); cycling to an untried mirror is almost + always cheaper than waiting for the dead one to come back. Stay-on-aniworld guarantee: the click can navigate the whole window (some templates route via ``/redirect/N`` instead of @@ -4583,7 +4257,7 @@ def _try_cycle_hoster_in_place(driver) -> bool: for (const el of document.querySelectorAll('[data-link-id]')) { const id = el.getAttribute('data-link-id') || ''; if (!id) continue; - if (id === activeId) continue; // belt + suspenders + if (id === activeId) continue; // belt + suspenders if (tried.has('dl:' + id)) continue; if (el.classList && el.classList.contains('active')) continue; const a = el.tagName === 'A' ? el : el.querySelector('a, button'); @@ -4728,7 +4402,7 @@ def _probe_and_switch_hoster_if_dead(driver, max_cycles: int = 2) -> bool: Reads the current player iframe's ``src``, HEAD-probes it through Tor, and -- if the URL responds with 4xx/5xx (or refuses to connect at all) -- clicks an alternative hoster button on the - s.to / aniworld episode page to cycle to a healthier mirror. Each + s.to / aniworld episode page to cycle to a healthere mirror. Each cycle waits up to 4 s for the iframe ``src`` to actually change before re-probing. @@ -4898,7 +4572,8 @@ def popout_player_iframe(driver) -> bool: return False -def _set_episode_stage(driver, label: str | None) -> None: +def _set_episode_stage(driver, label: str | None, *, key: str | None = None, + fmt: dict[str, Any] | None = None) -> None: """Surface the current episode-start phase to the sidebar UI. Writes ``localStorage.bw_episode_stage`` with a JSON payload that @@ -4911,6 +4586,14 @@ def _set_episode_stage(driver, label: str | None) -> None: check playback -> per-recovery-tier -> ready. Aniworld + s.to behave identically; the card is provider-agnostic. + i18n: a call site can also pass a ``key`` argument with an entry + from ``bw/i18n.py`` (and optional ``fmt`` dict for {placeholder} + substitution). The sidebar JS resolves the key against the live + ``window.__bwI18n`` on each render, so changing the language + mid-session updates the indicator without any extra plumbing. + The ``label`` argument stays as the fallback for the rare case + where __bwI18n hasn't loaded yet (sidebar polish-tick race). + Cheap by design (one execute_script per phase, ~30 ms). Failure is silent -- the indicator is informational, not playback-critical. """ @@ -4921,6 +4604,8 @@ def _set_episode_stage(driver, label: str | None) -> None: try: payload = json.dumps({ "label": (label or ""), + "key": (key or ""), + "fmt": (fmt or {}), "ts": int(time.time() * 1000), }) driver.execute_script( @@ -4999,7 +4684,7 @@ def _scroll_player_into_view(driver) -> None: const a = Math.max(0, r.width) * Math.max(0, r.height); if (a > bestArea) { bestArea = a; best = f; } } - if (best && bestArea > 60000) target = best; // >= ~300x200 + if (best && bestArea > 60000) target = best; // >= ~300x200 } if (target && typeof target.scrollIntoView === 'function') { target.scrollIntoView({block: 'center', behavior: 'smooth'}); @@ -5015,16 +4700,16 @@ def _force_unmute_for_streaming(driver) -> None: """Install a 1-Hz force-unmute interval in EVERY reachable frame, including the cross-origin video iframe. - Why this exists: + A persistent sweep is necessary because: * play_video() sets v.muted=true so the browser autoplay policy lets us start playback without a user gesture. - * JW Player (VOE / s.to) runs its OWN autoplay-muted state + * JW Player (VOE / s.to) runs its own autoplay-muted state machine and keeps re-applying v.muted=true on later seeks, - ad insertions, source switches, and during its own setup - -> idle -> playing transitions. - * A one-shot unmute after play_video isn't enough -- JW - Player can re-mute seconds later. A persistent 1-Hz - sweep guarantees we un-do every re-mute within 1 second. + ad insertions, source switches, and during its own + setup → idle → playing transitions. + * A one-shot unmute after play_video isn't enough — JW + Player can re-mute seconds later. A 1 Hz sweep undoes + every re-mute within a second. Why we need to switch INTO every frame: * The video lives in a CROSS-ORIGIN iframe (the streaming @@ -5197,10 +4882,10 @@ def _drain_filmpalast_ipc(driver) -> None: """Consume one tick of filmpalast IPC actions from localStorage. Actions on ``bw_filmpalast_action``: - * ``add`` -- {slug,title,desc,poster} -> persist to watchlist - * ``remove`` -- {slug} -> drop from watchlist - * ``note`` -- {slug, note} -> set/clear per-film note - * ``list`` -- (no args) -> publish current list + * ``add`` -- {slug,title,desc,poster} -> persist to watchlist + * ``remove`` -- {slug} -> drop from watchlist + * ``note`` -- {slug, note} -> set/clear per-film note + * ``list`` -- (no args) -> publish current list Every tick also republishes the full watchlist to ``bw_filmpalast_watchlist`` so the sidebar can re-render without @@ -5284,16 +4969,16 @@ def _drain_screen_stream_ipc(driver) -> None: """Consume one tick of screen-stream IPC actions from localStorage. Reads ``bw_screen_action`` and dispatches: - * ``start`` -- spawn FFmpeg, return ``{ok, url, audio_source, + * ``start`` -- spawn FFmpeg, return ``{ok, url, audio_source, video_only, audio_hint}`` (url = LAN URL the user opens on their phone) - * ``stop`` -- terminate FFmpeg, return ``{ok}`` + * ``stop`` -- terminate FFmpeg, return ``{ok}`` * ``status`` -- return current ScreenStream status WITHOUT spawning anything Result publishes to ``bw_screen_result`` (one-shot) AND ``bw_screen_status`` (continuous, like bw_cast_status -- updated - every tick so the UI can keep the "Stream läuft / Stream aus" + every tick so the UI can keep the "Stream is running / Stream aus" indicator fresh). """ try: @@ -5417,7 +5102,7 @@ def _drain_screen_stream_ipc(driver) -> None: except Exception: pass # Always publish current status (cheap, no spawn) so the UI's - # "Stream läuft / aus" pill stays fresh independent of action + # "Stream is running / aus" pill stays fresh independent of action # ticks. try: from bw import screen_stream as _bw_ss2 @@ -5447,7 +5132,7 @@ def _drain_os_hardening_ipc(driver) -> None: """Consume one tick of OS-hardening IPC actions from localStorage. Reads ``bw_os_hardening_action``, supports: - * ``rerun`` -- bypass the cache and re-run the audit (~1-3 s + * ``rerun`` -- bypass the cache and re-run the audit (~1-3 s blocking the polish-tick is fine; user clicked a button and expects feedback). * any other action is a no-op. @@ -5503,1784 +5188,104 @@ def _drain_os_hardening_ipc(driver) -> None: pass -def play_episodes_loop( - driver: webdriver.Firefox, - series: str, - season: int, - episode: int, - position: int = 0, - provider: str = "s.to", -) -> None: - global should_quit, exited_via_dashboard - current_episode = episode - current_season = season - current_provider = provider - - # Defensive guard: films-only providers (filmpalast.to, etc.) carry - # no episode_url_template/url_pattern and have no autoplay flow. - # parse_episode_info already skips them so this branch should be - # unreachable in normal use, but if any future code path ever - # synthesises an episode tuple for such a provider, bail out here - # instead of running play_video / toolbar inject / fullscreen logic - # against a page that doesn't even have a series concept. - prov_meta = STREAMING_PROVIDERS.get(provider) or {} - if prov_meta.get("films_only"): - logging.debug( - "play_episodes_loop: provider %r is films-only; skipping the " - "entire episode-loop. The user manages this provider via the " - "filmpalast watchlist UI in the sidebar, not via autoplay.", - provider, +# play_episodes_loop now lives in bw/player_loop.py +# (1700-line function body — see commit log for the extraction). +from bw.player_loop import play_episodes_loop # noqa: E402, F401 + + + +def _rotate_tor_circuit_between_episodes() -> None: + """Optionally fire SIGNAL NEWNYM at the Tor ControlPort between + episodes, plus a human-shaped pause so the auto-next doesn't fire + sub-second after the previous episode ends. + + NOW OPT-IN. Set ``BW_ROTATE_BETWEEN_EPISODES=1`` to enable. The + default is off because in practice the rotation breaks HLS + playback on the next episode often enough to be the dominant + cause of "video readyState stayed 0 / 232011 errors after + auto-advance" reports -- the new exit IP doesn't have the + hoster's session cookie, Cloudflare flags it, and the HLS + manifest fetch dies. Privacy-wise we're not losing much: the + CircuitWatchdog still rotates every ~45 min (when no video is + playing, by design), which is plenty for unlinkability without + the per-episode failure mode. + + Failure is logged and ignored: the bot continues on the old + circuit. We never block the auto-next flow on this. + """ + # Phase 4 — pause first, so the auto-next decision doesn't appear + # as a sub-second machine response. Honours BW_DISABLE_HUMAN_BEHAVIOR. + # This part stays on regardless of the rotation flag -- it's + # behavioural mimicry, not network-level state churn. + try: + from bw import human_behavior as _bw_hb + _bw_hb.inter_episode_pause() + except Exception as _e: + logging.debug(f"inter-episode pause skipped: {_e}") + + if os.getenv("BW_ROTATE_BETWEEN_EPISODES", "0").lower() not in {"1", "true", "yes"}: + bw_log_event( + "tor.newnym.between_episodes.skipped", + reason="disabled_by_default", ) return - # Spezielle Behandlung für One Piece (Staffel 11 Problem) - is_one_piece = series.lower() in ['one-piece', 'one piece', 'onepiece'] + # Probabilistic rotation: not every episode boundary triggers a + # NEWNYM. Real binge-watchers don't change their TCP fingerprint + # between every episode either — a typical human stays on the + # same TLS session for a run of consecutive episodes. We rotate + # roughly 70% of the time. The other 30% keep the existing + # circuit so a Tor-exit observer can't cluster the bot by the + # "rotate-every-episode" signature. + import random as _rand_rot + rotate_this_time = _rand_rot.random() < 0.70 - while True: - db = load_progress() - settings = get_settings(driver) - auto_fs = settings["autoFullscreen"] - auto_skip = settings["autoSkipIntro"] - auto_skip_end = settings["autoSkipEndScreen"] - auto_next = settings["autoNext"] - rate = settings["playbackRate"] - vol = settings["volume"] - fullscreen_attempted: bool = False - end_skip_applied: bool = False - - # New episode -> clear per-episode FS state (user-exit latch + detect cache). - try: - reset_user_fullscreen_latch(driver) - except Exception: - pass - try: - FullscreenStrategy.invalidate_cache() - except Exception: - pass + if not rotate_this_time: + bw_log_event( + "tor.newnym.between_episodes.skipped", + reason="probabilistic_skip", + ) + return - logging.info( - f"Playing {series.capitalize()} – Season {current_season}, Episode {current_episode}" + try: + from bw import tor_persistence as _bw_tor + ok = _bw_tor.rotate_circuit_between_episodes( + cookie_dir=TOR_DATA_DIR, + ) + bw_log_event( + "tor.newnym.between_episodes", + ok=bool(ok), ) + except Exception as _e: + logging.debug(f"NEWNYM between episodes skipped: {_e}") - # Surface the start-of-episode phases to the sidebar's status - # card (F8). Each helper call is cheap (~30 ms) and bot-side - # cost is invisible compared to the work each phase is doing. - _set_episode_stage(driver, "Lade Episodenseite…") - - # Navigiere zur Episode und prüfe auf Weiterleitungen - new_series, actual_season, actual_episode, actual_provider = navigate_to_episode(driver, series, current_season, current_episode, db, current_provider) - - if new_series != series: - logging.info(f"Canonical slug applied: {series} → {new_series}") - series = new_series - is_one_piece = series.replace('-', '').replace(' ', '') in ('onepiece',) - - if (actual_season != current_season) or (actual_episode is not None and actual_episode != current_episode) or (actual_provider != current_provider): - logging.info(f"Navigation angepasst: S{current_season}E{current_episode} → S{actual_season}E{actual_episode} (Provider: {current_provider} → {actual_provider})") - current_season = actual_season - current_provider = actual_provider - if actual_episode is not None: - current_episode = actual_episode - save_progress(series, current_season, current_episode, 0, provider=current_provider) - - sync_settings_to_localstorage(driver) - # Critical-state-only on the hot path. The remaining syncs - # (notes, stats, watchlist, descriptions, continue-watching) - # ride in on a daemon thread kicked off AFTER play_video has - # returned -- saves ~200-300 ms before the first frame. - sync_critical_state_to_localstorage(driver) - - # F3 -- drain screen-stream + OS-hardening IPC ALSO during playback. - # The dashboard loop has its own drain calls; without these here, - # a user who clicks the cast icon mid-episode would see nothing - # happen until they returned to the homepage. - _drain_os_hardening_ipc(driver) - _drain_screen_stream_ipc(driver) - _drain_filmpalast_ipc(driver) - - # s.to player gate: the "Video wird vorbereitet" modal that - # gates every s.to episode behind a Cloudflare Turnstile + - # ALTCHA double-check. Turnstile fails on Tor exits (error - # 600010); ALTCHA's proof-of-work passes fine. The "Weiter" - # button accepts whichever token is present, so the user can - # click through with just ALTCHA verified -- but the bot - # doesn't auto-click (that would be Turnstile-bypass; see - # bw/sto_player_gate.py module docstring for the policy - # rationale). Instead we surface a toast, pause this thread, - # and resume once the modal is gone. - # - # aniworld.to doesn't render this dialog, so we only check on - # s.to. The current_provider value is set by navigate_to_episode - # and reflects the URL we just landed on. - if (current_provider or "").lower() == "s.to": - try: - from bw import sto_player_gate as _bw_sto_gate - if _bw_sto_gate.detect_player_gate(driver): - bw_log_event( - "sto.player_gate.detected", - series=series, - season=current_season, - episode=current_episode, - ) - cleared = _bw_sto_gate.pause_for_user_click( - driver, timeout=300.0, - ) - bw_log_event( - "sto.player_gate.cleared" if cleared - else "sto.player_gate.timeout", - series=series, - season=current_season, - episode=current_episode, - ) - except Exception as _e: - logging.debug(f"s.to player gate check skipped: {_e}") - - # Tier-0 recovery: HEAD-probe the player iframe's src through - # Tor. If the hoster URL is 4xx/5xx we cycle to an alternative - # mirror BEFORE JW Player gets a chance to time out on the - # dead one (saves the user 5-15 s of staring at the red error - # overlay). Bounded to 2 cycles so a series where every - # hoster is dead falls through to the existing in-loop 232011 - # recovery rather than spinning forever. - # - # OPT-IN VIA BW_TIER0_PROBE=1. After landing this we observed - # bare ``WebDriverException(msg='')`` rising from somewhere in - # the post-navigation flow that triggered a full Firefox - # restart every episode start -- the user-reported symptom was - # the bot looping back to the homepage on every Play click. - # The probe is the leading suspect because it's the only new - # call between the page-load and the toolbar inject that does - # blocking Tor I/O on the main thread while Firefox is still - # rendering the heavy hoster page. Gating it lets the user - # confirm the crash stops without it; once the root cause is - # identified we can flip the default back on (or fix the - # underlying race). - if os.getenv("BW_TIER0_PROBE", "0").lower() in {"1", "true", "yes"}: - try: - cycled = _probe_and_switch_hoster_if_dead(driver) - if cycled: - # The iframe element changed -- previous frame - # handle is stale. Force a re-discovery on the - # next ensure_video_context call. - try: - driver.switch_to.default_content() - except Exception: - pass - except Exception as _e: - logging.debug(f"tier-0 hoster probe skipped: {_e}") - - # CRITICAL ORDER (2026-05-21): _set_episode_stage MUST run BEFORE - # ensure_video_context. The stage helper internally calls - # driver.switch_to.default_content() so it can write to the - # TOP frame's localStorage (where the sidebar reads from). If - # we set the stage AFTER ensuring video context, the driver is - # back in default content when play_video runs -- and play_video - # can't find the JW Player overlay because that lives in the - # cross-origin player iframe. Symptom: PRE-STATE shows - # url=aniworld.to/... + hasVideo=False + jwState=no-jw. - _set_episode_stage(driver, "Starte Wiedergabe…") - - # UX (2026-05-21): scroll the player into view. Anime sites put - # description + episode list ABOVE the player; after navigation - # the browser scroll-restores to top and the user can't see the - # video unless they scroll manually. We're already in default - # content (from _set_episode_stage); ensure_video_context() - # below re-establishes iframe context. - _scroll_player_into_view(driver) - if not ensure_video_context(driver): - ok_ctx = False - for _ in range(3): - time.sleep(0.4) - if ensure_video_context(driver): - ok_ctx = True - break - if not ok_ctx: - break +def _reveal_controls(driver): + try: + v = WebDriverWait(driver, 3).until( + EC.presence_of_element_located((By.TAG_NAME, "video")) + ) + ActionChains(driver).move_to_element(v).pause(0.05).move_by_offset( + 0, 0 + ).perform() + time.sleep(0.1) + except Exception: + pass - # play_video also self-defends: at entry it asserts iframe - # context (see ensure_video_context call inside). - play_video(driver) - # Apply a per-series playback rate (the user's last picked speed for THIS - # show) on top of the global default. Stored under progress[series].preferred_rate. - effective_rate = rate - # F2 -- per-series audio language preference. Pushed to localStorage so - # toolbar.js can auto-switch the audio track on `loadedmetadata`. - # Values: "auto" (no auto-switch), "de-dub", "de-sub", "en". - preferred_audio_lang = "auto" - try: - db_pr = load_progress() - ser_pr = db_pr.get(series) if isinstance(db_pr.get(series), dict) else None - if ser_pr and ser_pr.get("preferred_rate"): - effective_rate = float(ser_pr.get("preferred_rate")) - if ser_pr and ser_pr.get("preferred_audio_lang"): - preferred_audio_lang = str(ser_pr.get("preferred_audio_lang")) - except Exception: - pass - # Push audio-lang preference to the video iframe's localStorage so - # toolbar.js can read it. Stay in default content for execute_script; - # toolbar.js is loaded INTO the iframe, so this would need to target - # the iframe. Use the top-level localStorage as a relay -- toolbar.js - # currently reads from `window.parent.localStorage` indirectly via - # the postMessage shape used elsewhere. For simplicity, set on both. - try: - driver.execute_script( - "try { localStorage.setItem('bw_preferred_audio_lang', arguments[0]); } catch(_){}", - preferred_audio_lang, - ) - except Exception: - pass - apply_media_settings(driver, effective_rate, vol) - # When streaming is active, force-unmute Firefox in every - # frame (including cross-origin video iframes) so the WASAPI - # loopback captures real audio. apply_media_settings unmutes - # the TOP frame's video element if vol > 0 but the actual - # player