RunBSD

← Minecraft Backups

Shockbyte Backup Download

15 July 2026 (updated 8 August 2026)

What this does

Every night at 2AM: logs into the Shockbyte panel and downloads the latest JJAC_Survival_World instance.zip backup to ~/scripts/shockbyte_dl/incoming/instance.zip — all unattended, even with the screen locked. No manual login needed under normal circumstances; the download script reuses a saved browser session and only falls back to an actual login when that session is missing or close to expiring.

What happens to the zip after it lands (unzip, dechunk, rsync to gmktec, archive, DVD burn) is covered on the backup.sh & DVD archiving page — this page is just the download leg.

The problem: Cloudflare

panel.shockbyte.com and auth.shockbyte.com sit behind Cloudflare bot protection. AppleScript driving Safari (JS clicks, keystrokes, accessibility-tree clicks, coordinate clicks) was reliably blocked — every approach, every time.

Playwright driving a real, visible Chrome window (headless=False) gets through fine. Headless mode does not. Don't switch this back to headless — it will just start failing again.

Files

All of these live together in ~/scripts/shockbyte_dl/:

shockbyte_download_backup.py    nightly downloader
shockbyte_auth.py                shared Keychain + login-flow logic
shockbyte_login_setup_auto.py   standalone forced session refresh
shockbyte_login_setup.py        manual fallback login (by hand)
com.david.shockbyte-backup.plist    launchd schedule
shockbyte_auth_state.json        saved session (written by the scripts, not checked in)

shockbyte_login_autofill_test.py also lives in the folder but isn't part of the pipeline — it's an experimental/diagnostic script for testing the Cloudflare-protected login form directly, explicitly marked "do not run from cron or launchd" in its own docstring. Safe to ignore.

backup.sh (post-download processing) lives on the backup.sh & DVD archiving page now.

One-time setup

python3 --version   # confirm Python 3 is installed; install from python.org if not
cd ~/scripts/shockbyte_dl
python3 -m venv myvenv
source myvenv/bin/activate
pip3 install playwright psutil
playwright install chromium

psutil is used by shockbyte_download_backup.py to force-kill a stalled Chrome instance if a download hangs (see "Handling stalled downloads" below). Optional but strongly recommended — without it, a stalled download just logs a warning and leaves the hung Chrome process running.

Store credentials in Keychain — not Passwords.app!

Keychain Access.app → File → New Password Item:

Name:     Shockbyte
Account:  <login email>
Password: <login password>

Note to self: Keychain Access.app and the newer Passwords.app store different item types. Only Keychain Access's "generic password" is readable via security find-generic-password — a Safari-saved login under the same name won't be found by the script.

The plaintext SHOCKBYTE_USERNAME constant (not secret, only the password comes from Keychain) lives in shockbyte_auth.py, not shockbyte_login_setup_auto.py — that's the shared module both login scripts import, so it's the one to edit.

Get an initial session

python3 shockbyte_login_setup_auto.py

Approve the Keychain permission prompt with "Always Allow" so future unattended runs don't hang waiting on it.

Login form quirks (reverse-engineered by hand)

Set up the LaunchAgent

mkdir -p ~/scripts/shockbyte_dl/logs
cp com.david.shockbyte-backup.plist ~/Library/LaunchAgents/
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.david.shockbyte-backup.plist
launchctl enable gui/$(id -u)/com.david.shockbyte-backup
launchctl kickstart -k gui/$(id -u)/com.david.shockbyte-backup

Key detail: gui/<uid>, not system — runs inside the actual logged-in GUI session, which keeps working even with the screen locked (confirmed by testing). Sleep still kills it though — disable sleep in Energy settings.

The plist runs the whole thing through caffeinate -i (keeps the Mac awake for the duration) and writes stdout/stderr to ~/scripts/shockbyte_dl/logs/stdout.log and .../stderr.log — check those after a launchctl kickstart -k test run.

Note to self: a bootstrap failure ("5: Input/output error") usually just means the service is already loaded from an earlier attempt — check with launchctl print gui/$(id -u)/com.david.shockbyte-backup before assuming something's actually broken.

The schedule

<key>StartCalendarInterval</key>
<dict>
    <key>Hour</key><integer>2</integer>
    <key>Minute</key><integer>0</integer>
</dict>

Keeping the session alive

Fully automatic now, no calendar reminder needed. Every run of shockbyte_download_backup.py:

  1. Checks the real expiry of the saved session (the KEYCLOAK_SESSION cookie) before doing anything else.
  2. If there's no saved session at all, or it's within 48 hours of expiring, proactively logs back in via shockbyte_auth.py's login_and_refresh_state() (Keychain-based) before attempting the download — reusing its own already-launched browser for that login rather than spawning a second browser process.
  3. If it still gets bounced to the login page during the actual download (session expired despite the above, or expired between the proactive check and the download itself), it runs the same refresh once more and retries the download a single time before giving up.
  4. As a safety net independent of all that, it also logs a loud warning once you're within 3 days of expiry, in case the automatic refresh itself ever silently stops working.

If the automated login itself ever breaks (e.g. Cloudflare starts blocking it again, or the Shockbyte login form changes), the script exits with code 1 and tells you to run shockbyte_login_setup.py by hand as a fallback.

Handling stalled downloads

Failure mode this closes off: Playwright's Download.save_as() (and .path()/.failure()) has no timeout of its own — per the docs, it "will wait for the download to finish if necessary," i.e. forever. A download that starts fine but then stalls mid-transfer (flaky network, Chrome deciding to pause it, or the Mac sleeping mid-download) used to hang the script indefinitely, leaving a stalled Chrome window sitting open and "waiting to resume" for hours, unnoticed.

First attempt at a fix (reverted): wrapping save_as() in a Python watchdog thread with its own deadline. This broke a real run with greenlet.error: cannot switch to a different thread — Playwright's sync API isn't thread-safe; every call has to happen on the same thread that created the browser. The watchdog thread crashed the download immediately instead of timing it out gracefully.

Current design: process-level isolation instead of threads. Each full attempt (session refresh, navigation, and the download) runs single-threaded inside its own subprocess, invoked internally as:

python3 shockbyte_download_backup.py --internal-single-attempt <result_path>

main() spawns that subprocess with start_new_session=True (its own OS process group, covering Playwright's driver process, Chrome, and Chrome's helper processes) and waits on it with a plain timeout, SINGLE_ATTEMPT_TIMEOUT_SECONDS (currently 900s / 15 min). The subprocess writes its outcome as JSON to a result file before calling browser.close(), so a slow/hung teardown afterward doesn't turn a real success into a false failure.

A run that exhausts every retry (or a subprocess hangs past the timeout) exits with code 2, same as a page-structure-change error — check the log for "stalled" vs. a structure-change message to tell which one it was.

Detecting download completion via the filesystem, not Playwright's download event (added 8 August 2026)

Failure mode this closes off: the script used to start the download with page.expect_download(timeout=60000) and then block on download.save_as(save_path) — Playwright's own download-tracking event. On 2026-08-08, three consecutive real runs all died at exactly the 60-second expect_download timeout; after bumping that timeout to 5 minutes, a later run showed the download visibly complete in Chrome's own UI while Playwright's event still never fired at all. That pointed at Playwright's download-event delivery being unreliable here in general, not just slow to fire.

Likely root cause: this script launches Chrome via browser.launch(channel="chrome", ...), not launch_persistent_context(), so Playwright spins up a throwaway profile in a temp directory for the run and deletes it on browser.close(). If a native download landed inside that ephemeral profile's own download location instead of a real, persistent folder, it would explain both symptoms — the event never firing, and the finished file being absent from SAVE_DIR and the real ~/Downloads (confirmed by hand).

Fix: rather than chase down exactly which internal Chrome preference or event was misbehaving, detection was moved to the filesystem, which doesn't depend on any of that:

Both helpers are plain, blocking polling loops for the same reason save_as() was originally blocking and not wrapped in a watchdog thread — Playwright's sync API isn't thread-safe (see "Handling stalled downloads" above).

Exit codes (shockbyte_download_backup.py)

Troubleshooting

Known Playwright quirk: browser.close() can hang forever

Observed with real Chrome (channel="chrome") — happens after the important work (download, or a Keychain login) already succeeded, so no data is lost, but the script/terminal never gets control back without a Ctrl-C. Mainly relevant to shockbyte_login_setup_auto.py now — the main download script sidesteps this via the subprocess isolation described above.

Fix: run the whole browser flow in a background daemon thread with a generous, bounded timeout (LOGIN_TIMEOUT_SECONDS, 120s in shockbyte_login_setup_auto.py). If the close hangs, the main thread just stops waiting and exits anyway — a stuck daemon thread can't block that.

Known quirks worth remembering

Scripts

shockbyte_download_backup.py

#!/usr/bin/env python3
#!/usr/bin/env python3
"""
Shockbyte Panel -- download the latest backup for JJAC_Survival_World.

Cron-safe: reuses the session saved in shockbyte_auth_state.json, so it
never touches the login page on a normal run (and never has to fight
Cloudflare's bot protection on auth.shockbyte.com directly). If that saved
session is missing, expired, or close to expiring, this script now
automatically calls into shockbyte_auth.py (Keychain-based login,
shared with shockbyte_login_setup_auto.py) to refresh it -- no manual
intervention needed under normal circumstances. shockbyte_login_setup.py
(fully manual login) remains as a fallback for whenever the automated
login itself breaks (bad password, Shockbyte changed their form again,
etc).

When a refresh is needed, this script reuses its own already-launched
Chrome `browser` for it (just a separate, unauthenticated context) rather
than spawning a whole second browser process -- see shockbyte_auth.py's
login_and_refresh_state().

Setup (one-time):
    pip3 install playwright
    playwright install chromium
    python3 shockbyte_login_setup_auto.py    # or shockbyte_login_setup.py by hand

Usage:
    python3 shockbyte_download_backup.py

Exit codes:
    0  = backup downloaded successfully (and backup.sh, if present, also succeeded)
    1  = no valid session available, and automatic re-login also failed --
         run shockbyte_login_setup.py by hand
    2  = page structure changed / couldn't find expected elements, or the
         download stalled/hung on every attempt (see SINGLE_ATTEMPT_TIMEOUT_SECONDS
         and MAX_DOWNLOAD_ATTEMPTS)
    3  = playwright not installed / other setup problem
    4  = download succeeded but backup.sh (post-processing) failed

Internal use only -- do not run by hand:
    python3 shockbyte_download_backup.py --internal-single-attempt <result_path>
        Runs exactly one login-refresh+navigate+download attempt in this
        process (single-threaded -- see the note above SINGLE_ATTEMPT_TIMEOUT_SECONDS
        for why) and writes its outcome as JSON to <result_path>. main()
        spawns this as a subprocess so it can enforce a hard timeout by
        killing the whole thing at the OS level if it hangs, without
        touching Playwright from more than one thread.
"""

import json
import os
import signal
import subprocess
import sys
import time
import uuid
from datetime import datetime

try:
    from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError
except ImportError:
    print(
        "Playwright isn't installed yet. Run:\n"
        "    pip3 install playwright\n"
        "    playwright install chromium",
        file=sys.stderr,
    )
    sys.exit(3)

from shockbyte_auth import login_and_refresh_state

THIS_SCRIPT = os.path.abspath(__file__)
SCRIPT_DIR = os.path.dirname(THIS_SCRIPT)
STATE_FILE = os.path.join(SCRIPT_DIR, "shockbyte_auth_state.json")

SERVER_ID = "a3ab0419-59f8-4aa3-b6ee-7d1552d60bfc"
BACKUPS_URL = f"https://panel.shockbyte.com/server/{SERVER_ID}/backups"

# Where the downloaded zip gets saved, and what it's named. Other scripts
# expect a fixed filename at a fixed path (no timestamp), so this always
# overwrites the same file rather than accumulating dated copies.
SAVE_DIR = os.path.expanduser("/Users/david/scripts/shockbyte_dl/incoming/")
SAVE_FILENAME = "instance.zip"

# Runs right after a successful download: unzips instance.zip, prunes
# never-visited chunks, rsyncs the trimmed world to gmktec, and archives
# the raw zip to a few dated locations. Set to None to skip this step.
POST_PROCESS_SCRIPT = os.path.join(SCRIPT_DIR, "backup.sh")

# Start shouting in the log once the saved session is this close to expiring
# (belt-and-suspenders in case the automatic refresh below ever fails).
SESSION_WARNING_DAYS = 3

# Proactively refresh the saved session once it's within this many hours of
# expiring, *before* attempting a download -- rather than waiting to get
# bounced to the login page and reacting to it.
PROACTIVE_REFRESH_HOURS = 48

# Playwright's *sync* API (which this script uses) is not thread-safe --
# every call has to happen on the same thread that created the browser, or
# it raises "greenlet.error: cannot switch to a different thread" (see
# https://github.com/microsoft/playwright-python/issues/1422). An earlier
# version of this script tried to enforce a download timeout by calling
# the download-completion wait from a watchdog thread, which is exactly
# the pattern that breaks -- it crashed immediately instead of waiting.
#
# So timeouts here are enforced at the *process* level instead: each full
# attempt (session refresh + navigate + download, all single-threaded) runs
# in its own subprocess (see main() and --internal-single-attempt above).
# The parent just waits on that subprocess with a plain timeout and, if it
# hangs for any reason -- a stalled download, or browser.close()'s own
# known hang with real Chrome -- force-kills its entire process group
# rather than waiting on it forever.
SINGLE_ATTEMPT_TIMEOUT_SECONDS = 900

# How many full attempts (each a fresh subprocess + fresh browser) to make
# if one hangs/stalls. A stalled download may mean Chrome's networking
# stack itself is wedged, so retrying in the *same* browser/page isn't
# trusted -- each retry tears down and relaunches Chrome from scratch.
MAX_DOWNLOAD_ATTEMPTS = 2

# How long to wait for a new file to actually appear on disk in SAVE_DIR
# after clicking Confirm (see _wait_for_new_download_to_start, used in
# attempt_download). This used to be a 60s wait on Playwright's own
# "download" event instead; three consecutive real runs on 2026-08-08 all
# died at exactly that 60s mark, then -- after bumping this to 5 minutes --
# a later run showed the download visibly complete in Chrome's UI while
# Playwright's event tracking still never fired at all. That pointed at
# Playwright/real-Chrome download-event delivery being unreliable here
# generally, not just slow, so detection was moved to the filesystem (see
# the CDP setDownloadBehavior call in attempt_download). This timeout is
# now just "how long to wait for the transfer to begin on disk," with
# comfortable room under SINGLE_ATTEMPT_TIMEOUT_SECONDS (15 min) for the
# rest of the transfer to finish afterward.
DOWNLOAD_START_TIMEOUT_MS = 300000


def log(msg):
    timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    print(f"[{timestamp}] {msg}")


def kill_marked_chrome(marker):
    """Force-kills any process (Chrome itself or one of its helper
    processes) whose command line contains our unique per-launch marker
    flag. main() already kills the whole subprocess's process group on a
    timeout (which should take Chrome down with it, since it's normally
    still in that group) -- this is a belt-and-suspenders sweep for any
    Chrome/helper process that somehow escaped the group, plus routine
    cleanup after a normal exit in case browser.close() left something
    behind.

    Requires `psutil` (not a Playwright dependency -- install separately:
    `pip3 install psutil` in the same venv as this script). Degrades to a
    no-op with a warning if it isn't installed."""
    try:
        import psutil
    except ImportError:
        log(
            "psutil isn't installed, so a stalled/hung Chrome can't be force-killed "
            "automatically -- run 'pip3 install psutil' in this script's venv to "
            "enable that. Leaving any leftover Chrome process running for now."
        )
        return []

    killed = []
    for proc in psutil.process_iter(["pid", "cmdline"]):
        try:
            cmdline = proc.info.get("cmdline") or []
            if any(marker in arg for arg in cmdline):
                proc.kill()
                killed.append(proc.info["pid"])
        except (psutil.NoSuchProcess, psutil.AccessDenied):
            continue
    if killed:
        log(f"Force-killed {len(killed)} leftover Chrome process(es) tagged with marker {marker}: {killed}")
    return killed


def get_session_expiry():
    """Reads the KEYCLOAK_SESSION cookie's real expiry straight out of the
    saved storage_state file. Returns a datetime, or None if the file/cookie
    doesn't exist or has no fixed expiry. Doesn't touch the network."""
    try:
        with open(STATE_FILE, "r", encoding="utf-8") as f:
            state = json.load(f)
        cookie = next(
            (c for c in state.get("cookies", []) if c.get("name") == "KEYCLOAK_SESSION"),
            None,
        )
        if not cookie or not cookie.get("expires") or cookie["expires"] <= 0:
            return None
        return datetime.fromtimestamp(cookie["expires"])
    except Exception as e:
        log(f"(Couldn't check session expiry ahead of time: {e})")
        return None


def _list_dir_safe(d):
    try:
        return set(os.listdir(d))
    except FileNotFoundError:
        return set()


def _wait_for_new_download_to_start(watch_dir, before_names, timeout_seconds, poll_interval=1.0):
    """Polls watch_dir on disk for any new filesystem entry that wasn't in
    before_names -- either Chrome's in-progress '<name>.crdownload' file,
    or (if it finishes fast enough that we never observe the .crdownload
    stage) the final file itself. Returns the "base" name with any
    '.crdownload' suffix stripped, or None if nothing new appeared within
    timeout_seconds.

    Deliberately doesn't use Playwright's own expect_download()/download
    event -- see the note in attempt_download() above the CDP
    setDownloadBehavior call for why that isn't trustworthy here."""
    deadline = time.monotonic() + timeout_seconds
    while time.monotonic() < deadline:
        new_names = _list_dir_safe(watch_dir) - before_names
        if new_names:
            name = sorted(new_names)[0]
            if name.endswith(".crdownload"):
                name = name[: -len(".crdownload")]
            return name
        time.sleep(poll_interval)
    return None


def _wait_for_download_to_finish(watch_dir, base_name, poll_interval=3.0, stable_checks=2):
    """Polls watch_dir until the in-progress '<base_name>.crdownload' file
    is gone, '<base_name>' exists, and its size holds steady across
    `stable_checks` consecutive polls. Runs indefinitely -- deliberately
    unbounded, same as the old download.save_as() call it replaces: the
    outer SINGLE_ATTEMPT_TIMEOUT_SECONDS subprocess-level watchdog in
    main() is what catches a genuinely stalled/hung transfer, not this
    function itself."""
    final_path = os.path.join(watch_dir, base_name)
    crdownload_path = final_path + ".crdownload"

    stable_count = 0
    last_size = -1
    while True:
        if not os.path.exists(crdownload_path) and os.path.exists(final_path):
            size = os.path.getsize(final_path)
            if size == last_size and size > 0:
                stable_count += 1
                if stable_count >= stable_checks:
                    return final_path
            else:
                stable_count = 0
            last_size = size
        else:
            stable_count = 0
        time.sleep(poll_interval)


def attempt_download(browser):
    """Runs the actual Playwright flow on an already-launched `browser`:
    navigate to the backups page, find and click the download button, save
    the file. Returns one of:
        "success" -- downloaded fine
        "expired" -- bounced to the login page, saved session is no good
        "error"   -- some other page-structure/timeout problem (see log/debug files)
    A stalled/hung download isn't detected here at all -- see the note
    above SINGLE_ATTEMPT_TIMEOUT_SECONDS for why (Playwright's sync API
    isn't thread-safe, so we can't watchdog this call from another
    thread). Instead, main() runs this whole attempt in a subprocess and
    enforces the timeout from outside it.
    Never closes `browser` itself (the caller owns its lifecycle), and
    never calls sys.exit(), so the caller can retry after a refresh.
    """
    context = browser.new_context(storage_state=STATE_FILE)
    try:
        page = context.new_page()

        log(f"Navigating to {BACKUPS_URL}")
        page.goto(BACKUPS_URL, wait_until="domcontentloaded", timeout=60000)

        if "auth.shockbyte.com" in page.url:
            log("Got bounced to the login page -- saved session has expired.")
            return "expired"

        def dump_debug(tag):
            debug_png = os.path.join(SCRIPT_DIR, f"debug_screenshot_{tag}.png")
            debug_html = os.path.join(SCRIPT_DIR, f"debug_page_{tag}.html")
            try:
                page.screenshot(path=debug_png, full_page=True)
                with open(debug_html, "w", encoding="utf-8") as f:
                    f.write(page.content())
                log(f"Saved screenshot to {debug_png}")
                log(f"Saved page HTML to {debug_html}")
            except Exception as e:
                log(f"Couldn't capture debug info: {e}")

        # The page has more than one "Extra Options" menu on it (e.g. the
        # Scheduled Backups card has its own). We need the one that belongs
        # to the real Backups table specifically, so scope everything to
        # the <table> whose header row contains a "Created" column.
        try:
            page.wait_for_selector("table:has(th:has-text('Created'))", timeout=30000)
        except PlaywrightTimeoutError:
            log("Timed out waiting for the backups table to appear at all.")
            log(f"Current URL: {page.url}")
            dump_debug("no_table")
            return "error"

        backups_table = page.locator("table:has(th:has-text('Created'))").first

        # The table shows animated skeleton placeholder rows while the real
        # backup rows are still loading. Wait for those to clear.
        try:
            page.wait_for_function(
                """(table) => !table.querySelector('tbody .animate-pulse')""",
                arg=backups_table.element_handle(),
                timeout=30000,
            )
        except PlaywrightTimeoutError:
            log("Timed out waiting for the backups table's loading skeleton to clear.")
            dump_debug("skeleton_stuck")
            return "error"

        first_row = backups_table.locator("tbody tr").first
        try:
            first_row.wait_for(state="visible", timeout=15000)
        except PlaywrightTimeoutError:
            log("Backups table finished loading but no row appeared -- maybe there are no backups?")
            dump_debug("no_rows")
            return "error"

        row_buttons = first_row.locator("button")
        row_button_count = row_buttons.count()

        extra_options_idx = None
        for i in range(row_button_count):
            text = (row_buttons.nth(i).inner_text() or "").strip().lower()
            if text == "extra options":
                extra_options_idx = i
                break

        if extra_options_idx is None:
            log("Could not find the row's 'Extra Options' button -- page structure may have changed.")
            dump_debug("no_extra_options_in_row")
            return "error"

        trigger = None
        for i in range(extra_options_idx - 1, -1, -1):
            b = row_buttons.nth(i)
            label = (b.inner_text() or b.get_attribute("aria-label") or "").strip()
            icononly = b.get_attribute("data-icononly")
            if icononly == "true" and label == "":
                trigger = b
                break

        if trigger is None:
            log("Could not find the row's download-trigger icon button.")
            log(f"Inspecting the {row_button_count} buttons found in the first backup row:")
            for i in range(row_button_count):
                b = row_buttons.nth(i)
                text = (b.inner_text() or "").strip()
                aria = b.get_attribute("aria-label")
                icononly = b.get_attribute("data-icononly")
                cls = b.get_attribute("class")
                outer = b.evaluate("el => el.outerHTML")
                log(f"  [{i}] text={text!r} aria-label={aria!r} data-icononly={icononly!r} class={cls!r}")
                log(f"       outerHTML={outer[:300]}")
            dump_debug("no_trigger_in_row")
            return "error"

        log("Clicking the download-trigger icon...")
        trigger.click()

        # Wait for the "Download Backup" verification popup's real Download button
        try:
            page.wait_for_selector("button:has-text('Download')", timeout=15000)
        except PlaywrightTimeoutError:
            log("Timed out waiting for the Download confirmation popup.")
            return "error"

        confirm_btn = page.locator("button:has-text('Download')").last

        os.makedirs(SAVE_DIR, exist_ok=True)

        # Force Chrome to save directly into SAVE_DIR via CDP, and confirm
        # completion by watching that directory on disk -- deliberately
        # NOT using Playwright's own expect_download()/download event.
        # On 2026-08-08, a real run visibly completed a download in
        # Chrome's own UI, but expect_download() still timed out, and the
        # finished file wasn't in SAVE_DIR *or* the real ~/Downloads
        # (confirmed by hand). The likely explanation: this script
        # launches Chrome via browser.launch(channel="chrome", ...), not
        # launch_persistent_context(), so Playwright spins up a throwaway
        # profile in a temp dir for the run and deletes it on
        # browser.close() -- if the native download landed inside that
        # ephemeral profile's own download location instead of a real,
        # persistent folder, it would explain the file vanishing without
        # a trace either place we checked. Rather than chase down exactly
        # which internal event/preference is misbehaving, this pins the
        # download location explicitly and confirms success at the
        # filesystem level, which doesn't depend on any of that.
        cdp = context.new_cdp_session(page)
        cdp.send("Page.setDownloadBehavior", {"behavior": "allow", "downloadPath": SAVE_DIR})

        before_names = _list_dir_safe(SAVE_DIR)

        log("Confirming download...")
        confirm_btn.click()

        base_name = _wait_for_new_download_to_start(
            SAVE_DIR, before_names, timeout_seconds=DOWNLOAD_START_TIMEOUT_MS / 1000
        )
        if base_name is None:
            log(
                f"Timed out after {DOWNLOAD_START_TIMEOUT_MS / 1000:.0f}s waiting for a new "
                f"file to appear in {SAVE_DIR} -- dumping a screenshot/HTML snapshot to see "
                f"what the page actually looked like when it gave up."
            )
            dump_debug("download_never_started")
            return "error"

        log(f"Download started on disk as {base_name!r} -- waiting for it to finish...")

        # Deliberately a plain, blocking poll loop -- see the note above
        # SINGLE_ATTEMPT_TIMEOUT_SECONDS for why this can't be wrapped in
        # a watchdog thread, and the note on _wait_for_download_to_finish
        # for why it's intentionally unbounded here: main()'s
        # subprocess-level timeout is what catches a genuine stall.
        downloaded_path = _wait_for_download_to_finish(SAVE_DIR, base_name)

        save_path = os.path.join(SAVE_DIR, SAVE_FILENAME)
        if downloaded_path != save_path:
            os.replace(downloaded_path, save_path)

        log(f"Backup saved to: {save_path}")
        return "success"
    finally:
        context.close()


def run_session_and_download(browser):
    """Handles session freshness (proactive + reactive refresh) and the
    download attempt(s), all reusing the same already-launched `browser`.
    Returns the final status string: "success", "expired", or "error"."""
    if not os.path.exists(STATE_FILE):
        log(f"No saved session found at {STATE_FILE}.")
        log("Attempting an automatic initial login...")
        if not login_and_refresh_state(browser=browser, log=log):
            log("Automatic login failed and no saved session exists.")
            log("Run shockbyte_login_setup.py by hand to get started.")
            return "expired"
    else:
        expires_at = get_session_expiry()
        if expires_at is not None:
            hours_left = (expires_at - datetime.now()).total_seconds() / 3600
            days_left = int(hours_left // 24)

            if hours_left <= SESSION_WARNING_DAYS * 24:
                log(
                    f"*** HEADS UP: saved session expires in ~{days_left} day(s), "
                    f"on {expires_at:%Y-%m-%d}. ***"
                )

            if hours_left <= PROACTIVE_REFRESH_HOURS:
                log(
                    f"Session expires in ~{hours_left:.1f} hour(s), which is within "
                    f"the {PROACTIVE_REFRESH_HOURS}-hour proactive-refresh window -- "
                    f"refreshing now before attempting the download."
                )
                # Best-effort: if this fails, still fall through and try the
                # download with the old session -- it might still be valid.
                login_and_refresh_state(browser=browser, log=log)

    status = attempt_download(browser)

    if status == "expired":
        log("Saved session was expired -- attempting an automatic re-login and one retry.")
        if login_and_refresh_state(browser=browser, log=log):
            status = attempt_download(browser)
        else:
            log("Automatic re-login failed. Run shockbyte_login_setup.py by hand to recover.")
            return "expired"

    if status == "expired":
        # Refresh succeeded but we still got bounced on retry -- something
        # deeper is wrong (bad credentials in Keychain, Shockbyte changed
        # the login flow again, etc). Don't loop forever.
        log("Still couldn't get a valid session after refreshing once. Giving up for this run.")

    return status


def _single_attempt_main(result_path):
    """Runs exactly one login-refresh+navigate+download attempt, entirely
    on this (the only) thread, and writes {"status": ...} as JSON to
    result_path. This is invoked as `python3 shockbyte_download_backup.py
    --internal-single-attempt <result_path>` by main(), which is the only
    thing that ever calls this. Never raises -- any exception is caught
    and reported as an "error" status so the parent always gets a result
    file to read (or a clear reason it didn't)."""
    marker = os.environ.get("SHOCKBYTE_CHROME_MARKER", "")
    launch_args = [f"--shockbyte-marker={marker}"] if marker else []

    def write_result(status):
        try:
            with open(result_path, "w", encoding="utf-8") as f:
                json.dump({"status": status}, f)
        except Exception as e:
            log(f"Couldn't write result file {result_path}: {e}")

    status = "error"
    try:
        with sync_playwright() as p:
            try:
                browser = p.chromium.launch(headless=False, channel="chrome", args=launch_args)
            except Exception:
                browser = p.chromium.launch(headless=False, args=launch_args)

            try:
                status = run_session_and_download(browser)
            except Exception as e:
                log(f"Unhandled exception during browser flow: {e}")
                status = "error"

            # Write the result *before* calling browser.close(). Real
            # Chrome's close()/teardown has its own known hang in some
            # environments; if that happens here, main() will time this
            # whole subprocess out and kill it -- but by then the result
            # file already correctly says "success" (or whatever really
            # happened), so a slow teardown doesn't turn a real success
            # into a false failure.
            write_result(status)

            try:
                browser.close()
            except Exception as e:
                log(f"browser.close() raised (known quirk, ignoring): {e}")
    except Exception as e:
        log(f"Unhandled exception launching/tearing down the browser: {e}")
        write_result("error")

    sys.exit(0)


def main():
    status = "error"

    for attempt in range(1, MAX_DOWNLOAD_ATTEMPTS + 1):
        marker = f"shockbyte-run-{uuid.uuid4().hex[:12]}"
        result_path = os.path.join(SCRIPT_DIR, f".shockbyte_attempt_result_{marker}.json")

        # Tagging every launch with a unique, otherwise-meaningless flag
        # lets us find and force-kill *this* Chrome instance later (via
        # kill_marked_chrome) without touching any other Chrome window on
        # the machine. Chrome ignores flags it doesn't recognize, so this
        # is harmless to the browser itself.
        env = os.environ.copy()
        env["SHOCKBYTE_CHROME_MARKER"] = marker

        log(f"Starting download attempt {attempt}/{MAX_DOWNLOAD_ATTEMPTS} (marker={marker})...")

        # Each attempt runs the whole login-refresh+navigate+download flow
        # in its own subprocess -- single-threaded inside (Playwright's
        # sync API isn't thread-safe, see the note above
        # SINGLE_ATTEMPT_TIMEOUT_SECONDS), so we enforce the hang/stall
        # timeout from *outside* the process instead of from a watchdog
        # thread inside it. start_new_session=True puts the subprocess (and
        # everything it spawns -- Playwright's driver, Chrome, Chrome's
        # helper processes) into its own OS process group, so a timeout can
        # take the whole tree down in one os.killpg() call.
        proc = subprocess.Popen(
            [sys.executable, THIS_SCRIPT, "--internal-single-attempt", result_path],
            cwd=SCRIPT_DIR,
            env=env,
            start_new_session=True,
        )

        try:
            proc.wait(timeout=SINGLE_ATTEMPT_TIMEOUT_SECONDS)
        except subprocess.TimeoutExpired:
            log(
                f"Attempt {attempt} didn't finish within {SINGLE_ATTEMPT_TIMEOUT_SECONDS}s -- "
                f"treating it as stalled/hung and force-killing its whole process group."
            )
            try:
                os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
            except ProcessLookupError:
                pass
            proc.wait()  # reap the now-dead process
            kill_marked_chrome(marker)  # belt-and-suspenders for anything outside the group
            status = "stalled"
        else:
            status = None
            if os.path.exists(result_path):
                try:
                    with open(result_path, "r", encoding="utf-8") as f:
                        status = json.load(f).get("status")
                except Exception as e:
                    log(f"Couldn't read result file from attempt {attempt}: {e}")
            if status is None:
                log(
                    f"Attempt {attempt} exited (code {proc.returncode}) without writing a "
                    f"result file -- treating as error."
                )
                status = "error"
            kill_marked_chrome(marker)  # routine cleanup in case close() left something behind

        if os.path.exists(result_path):
            try:
                os.remove(result_path)
            except OSError:
                pass

        if status != "stalled":
            break

        if attempt < MAX_DOWNLOAD_ATTEMPTS:
            log(f"Retrying with a fresh browser (attempt {attempt + 1}/{MAX_DOWNLOAD_ATTEMPTS})...")
        else:
            log(f"Download stalled/hung on the final attempt ({attempt}/{MAX_DOWNLOAD_ATTEMPTS}). Giving up for this run.")

    if status in ("error", "stalled"):
        sys.exit(2)

    if status == "expired":
        sys.exit(1)

    # status == "success" from here on.
    if POST_PROCESS_SCRIPT and os.path.exists(POST_PROCESS_SCRIPT):
        log(f"Running post-processing script: {POST_PROCESS_SCRIPT}")
        # Inherits this process's stdout/stderr, so its output lands in the
        # same log file launchd is already writing to.
        result = subprocess.run(["/bin/zsh", POST_PROCESS_SCRIPT])
        if result.returncode != 0:
            log(f"backup.sh exited with code {result.returncode} -- check the log above for where it failed.")
            sys.exit(4)
        log("Post-processing finished successfully.")
    elif POST_PROCESS_SCRIPT:
        log(f"POST_PROCESS_SCRIPT is set to {POST_PROCESS_SCRIPT} but that file doesn't exist -- skipping.")

    sys.exit(0)


if __name__ == "__main__":
    if len(sys.argv) >= 3 and sys.argv[1] == "--internal-single-attempt":
        _single_attempt_main(sys.argv[2])
    else:
        main()

shockbyte_auth.py

#!/usr/bin/env python3
"""
Shared Shockbyte login/Keychain logic, used by both:
    - shockbyte_login_setup_auto.py (thin standalone CLI wrapper -- run this
      by hand whenever you just want to force a session refresh, or test
      Keychain access in isolation)
    - shockbyte_download_backup.py (imports login_and_refresh_state()
      directly and, when possible, reuses its own already-launched Chrome
      `browser` instead of spawning a second one via subprocess)

Credential storage:
    Your password is NOT stored anywhere on disk in plaintext. It's read
    from macOS Keychain at runtime via the `security` command-line tool.
    One-time setup:

        1. Open Keychain Access.app
        2. File -> New Password Item...
           - Keychain Item Name: Shockbyte
           - Account Name:       your Shockbyte login email
           - Password:           your Shockbyte password
        3. Click Add.

    The first time this runs, macOS will show a permission prompt
    ("security" wants to use your confidential information...). Click
    "Always Allow" so future unattended runs (e.g. from launchd) don't
    hang waiting for that dialog.

Why headed (visible) Chrome, not headless:
    The Shockbyte panel and login pages sit behind Cloudflare bot
    protection that has reliably distinguished headless automation from a
    normal browser session in our testing. headless=False (a real, visible
    browser window) has worked consistently; headless=True has not.

Login flow quirks handled here (reverse-engineered via manual testing):
    - The login form is "identifier-first": you submit just the email,
      then a second page/step asks for the password.
    - The email step's submit button (#login) starts disabled until
      their JS validates the field, and -- confirmed by hand, this isn't
      a Playwright artifact -- the first click on it only shifts focus
      rather than submitting; it takes a second click to actually submit.
      login_flow() below retries the click a few times to handle that.
"""

import os
import subprocess
import time

from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
STATE_FILE = os.path.join(SCRIPT_DIR, "shockbyte_auth_state.json")
PANEL_URL = "https://panel.shockbyte.com/"

KEYCHAIN_SERVICE = "Shockbyte"

# Your Shockbyte login email. Not sensitive the way the password is, so it
# just lives here as plain text -- only the password comes from Keychain.
SHOCKBYTE_USERNAME = ""  # <-- fill this in


def default_log(msg):
    print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}")


def dump_debug(page, tag, log=default_log):
    png = os.path.join(SCRIPT_DIR, f"login_auto_{tag}.png")
    html = os.path.join(SCRIPT_DIR, f"login_auto_{tag}.html")
    try:
        page.screenshot(path=png, full_page=True)
        with open(html, "w", encoding="utf-8") as f:
            f.write(page.content())
        log(f"Saved screenshot to {png}")
        log(f"Saved page HTML to {html}")
    except Exception as e:
        log(f"Couldn't capture debug info: {e}")


def get_keychain_password(service=KEYCHAIN_SERVICE):
    """Reads just the password for a generic-password Keychain item using
    the `security` CLI. Raises RuntimeError with a clear message if the
    item isn't found or Keychain access is denied.

    (We deliberately don't try to also extract the account/email from the
    item's attributes via `security ... -g` -- that output's format is
    inconsistent across macOS versions/encodings and proved too fragile to
    parse reliably. The email isn't actually sensitive, so it's just set
    above as SHOCKBYTE_USERNAME instead.)"""

    pw = subprocess.run(
        ["security", "find-generic-password", "-s", service, "-w"],
        capture_output=True,
        text=True,
    )
    if pw.returncode != 0:
        raise RuntimeError(
            f"No Keychain item named '{service}' found (or access was denied). "
            f"security said: {pw.stderr.strip()}"
        )

    return pw.stdout.strip()


def login_flow(page, username, password, log=default_log):
    """Drives the actual Shockbyte login form on an already-created,
    unauthenticated Playwright `page`. Returns True on success, False on
    any failure (with debug screenshot/HTML saved via dump_debug)."""

    log(f"Navigating to {PANEL_URL}")
    page.goto(PANEL_URL, wait_until="domcontentloaded", timeout=60000)

    page.wait_for_timeout(3000)
    log(f"Current URL: {page.url}")

    if "Just a moment" in (page.title() or "") or "challenge" in page.url.lower():
        log("Cloudflare challenge appeared before the login form loaded.")
        log("Waiting up to 20s to see if it clears...")
        for _ in range(20):
            page.wait_for_timeout(1000)
            if "Just a moment" not in (page.title() or ""):
                break
        log(f"URL after waiting: {page.url}")

    username_selectors = ["#username", "input[name='username']", "input[type='email']"]
    step1_submit_selectors = ["#login", "input[type='submit']", "button[type='submit']"]
    password_selectors = ["#password", "input[name='password']", "input[type='password']"]
    step2_submit_selectors = ["#kc-login", "#login", "input[type='submit']", "button[type='submit']"]

    username_field = None
    for sel in username_selectors:
        try:
            page.wait_for_selector(sel, timeout=10000)
            username_field = page.locator(sel).first
            log(f"Found username field via selector: {sel}")
            break
        except PlaywrightTimeoutError:
            continue

    if username_field is None:
        log("Could not find a username field on the page -- page structure may have changed.")
        dump_debug(page, "no_username_field", log=log)
        return False

    log("Typing username...")
    username_field.click()
    username_field.type(username, delay=80)

    step1_submit = None
    for sel in step1_submit_selectors:
        if page.locator(sel).count() > 0:
            step1_submit = page.locator(sel).first
            break

    if step1_submit is None:
        log("Could not find the step-1 submit button. Trying Enter key instead.")
        username_field.press("Enter")
    else:
        try:
            page.wait_for_function(
                "(btn) => !btn.disabled",
                arg=step1_submit.element_handle(),
                timeout=5000,
            )
        except PlaywrightTimeoutError:
            log("Step-1 submit button never became enabled -- clicking anyway.")

        # Known quirk: the first click just shifts focus; it takes a
        # second click to actually submit. Retry a few times.
        for attempt in range(1, 4):
            log(f"Clicking step-1 submit (email) -- attempt {attempt}...")
            step1_submit.click()
            try:
                page.wait_for_selector(password_selectors[0], timeout=4000)
                break
            except PlaywrightTimeoutError:
                if any(page.locator(sel).count() > 0 for sel in password_selectors):
                    break
                continue

    password_field = None
    for sel in password_selectors:
        try:
            page.wait_for_selector(sel, timeout=15000)
            password_field = page.locator(sel).first
            log(f"Found password field via selector: {sel}")
            break
        except PlaywrightTimeoutError:
            continue

    if password_field is None:
        log("Could not find a password field after submitting the username step.")
        log(f"Current URL: {page.url}")
        dump_debug(page, "no_password_field_step2", log=log)
        return False

    log("Typing password...")
    password_field.click()
    password_field.type(password, delay=80)

    submit_btn = None
    for sel in step2_submit_selectors:
        if page.locator(sel).count() > 0:
            submit_btn = page.locator(sel).first
            log(f"Found step-2 submit button via selector: {sel}")
            break

    if submit_btn is None:
        log("Could not find a step-2 submit button. Trying Enter key instead.")
        password_field.press("Enter")
    else:
        log("Clicking submit...")
        submit_btn.click()

    page.wait_for_timeout(5000)
    log(f"URL after submit: {page.url}")

    if "auth.shockbyte.com" in page.url:
        log("Login failed -- still on auth.shockbyte.com. Could be a bad password,")
        log("an unexpected form flow, or a Cloudflare block. Capturing debug info.")
        dump_debug(page, "still_on_login", log=log)
        return False

    # Make sure we can actually see the panel (not, say, stuck on some
    # intermediate account-setup screen) before the caller saves the session.
    try:
        page.wait_for_url("**panel.shockbyte.com**", timeout=15000)
    except PlaywrightTimeoutError:
        log(f"Landed somewhere unexpected: {page.url}")
        dump_debug(page, "unexpected_landing", log=log)
        return False

    return True


def login_and_refresh_state(browser=None, log=default_log):
    """High-level entry point: gets credentials, logs in, and saves a fresh
    shockbyte_auth_state.json on success. Returns True/False.

    If `browser` is given (an already-launched Playwright Browser), reuses
    it -- just opens a new, unauthenticated context on it for the login,
    and leaves the browser itself open for the caller to keep using
    afterward (e.g. shockbyte_download_backup.py reusing the same browser
    for the download that follows). If `browser` is None, launches and
    fully tears down its own browser -- used when running standalone via
    shockbyte_login_setup_auto.py.
    """
    if not SHOCKBYTE_USERNAME:
        log("SHOCKBYTE_USERNAME isn't set yet -- edit shockbyte_auth.py and fill in")
        log("your Shockbyte login email near the top of the file.")
        return False

    try:
        password = get_keychain_password(KEYCHAIN_SERVICE)
    except RuntimeError as e:
        log(f"Couldn't get password from Keychain: {e}")
        log(
            f"Set it up via Keychain Access.app: New Password Item, "
            f"name '{KEYCHAIN_SERVICE}', account = your Shockbyte email, "
            f"password = your Shockbyte password."
        )
        return False

    username = SHOCKBYTE_USERNAME
    log(f"Got password from Keychain. Using username: {username}")

    if browser is not None:
        return _login_with_browser(browser, username, password, log)

    with sync_playwright() as p:
        try:
            b = p.chromium.launch(headless=False, channel="chrome")
        except Exception:
            log("(Real Chrome not found, using bundled Chromium instead.)")
            b = p.chromium.launch(headless=False)
        try:
            return _login_with_browser(b, username, password, log)
        finally:
            b.close()


def _login_with_browser(browser, username, password, log):
    context = browser.new_context()
    try:
        page = context.new_page()
        ok = login_flow(page, username, password, log=log)
        if ok:
            context.storage_state(path=STATE_FILE)
            log(f"Login succeeded. Saved refreshed session to: {STATE_FILE}")
        return ok
    finally:
        context.close()

shockbyte_login_setup_auto.py

#!/usr/bin/env python3
"""
Shockbyte Panel -- standalone entry point for forcing a session refresh by
hand (e.g. to test Keychain access in isolation, or just get a fresh
shockbyte_auth_state.json right now without waiting for the nightly run).

The actual login logic lives in shockbyte_auth.py, shared with
shockbyte_download_backup.py (which imports it directly and reuses its own
already-launched browser rather than spawning a second one via this script).

Usage:
    python3 shockbyte_login_setup_auto.py

Exit codes:
    0  = login succeeded, shockbyte_auth_state.json refreshed
    1  = login failed -- see the log output / debug screenshot+HTML above
    3  = playwright not installed

Note on hanging:
    Playwright's browser.close() (and/or its own internal teardown) has
    been observed to hang indefinitely with real Chrome (channel="chrome")
    in some environments -- a known upstream quirk, not something in our
    own login logic. It happens *after* the important part (saving the
    session file) already succeeded. To make sure this script always
    returns control to your terminal, the whole flow runs in a background
    daemon thread with a generous but bounded timeout; if it's still stuck
    after that, we stop waiting and exit anyway rather than hang forever.
"""

import sys
import threading

try:
    import playwright  # noqa: F401
except ImportError:
    print(
        "Playwright isn't installed yet. Run:\n"
        "    pip3 install playwright\n"
        "    playwright install chromium\n"
        "then try again.",
        file=sys.stderr,
    )
    sys.exit(3)

from shockbyte_auth import login_and_refresh_state, default_log

LOGIN_TIMEOUT_SECONDS = 120


def main():
    result = {}

    def worker():
        result["ok"] = login_and_refresh_state(log=default_log)

    t = threading.Thread(target=worker, daemon=True)
    t.start()
    t.join(timeout=LOGIN_TIMEOUT_SECONDS)

    if "ok" not in result:
        default_log(
            f"Didn't finish within {LOGIN_TIMEOUT_SECONDS}s -- likely stuck in "
            f"Playwright/Chrome teardown *after* a successful login (check "
            f"shockbyte_auth_state.json's modified time to confirm). Giving "
            f"up waiting and exiting rather than hanging the terminal."
        )
        sys.exit(1)

    sys.exit(0 if result["ok"] else 1)


if __name__ == "__main__":
    main()

shockbyte_login_setup.py

#!/usr/bin/env python3
"""
Shockbyte Panel -- one-time interactive login setup.

Run this manually (not from cron). It opens a real, visible Chrome window,
you log in by hand exactly as you normally would, and once you're in,
Playwright saves the authenticated session (cookies etc.) to a JSON file.

The download script (shockbyte_download_backup.py) reuses that saved
session so it never has to go through the login page itself -- which
matters because auth.shockbyte.com sits behind Cloudflare bot protection
that reliably distinguishes genuine human interaction (this script) from
automated form-filling (which is why the AppleScript approach kept failing
inconsistently).

Re-run this whenever the saved session expires (the download script will
tell you clearly if that's happened).

Setup (one-time):
    pip3 install playwright
    playwright install chromium

Usage:
    python3 shockbyte_login_setup.py
"""

import os
import sys

try:
    from playwright.sync_api import sync_playwright
except ImportError:
    sys.exit(
        "Playwright isn't installed yet. Run:\n"
        "    pip3 install playwright\n"
        "    playwright install chromium\n"
        "then try again."
    )

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
STATE_FILE = os.path.join(SCRIPT_DIR, "shockbyte_auth_state.json")
PANEL_URL = "https://panel.shockbyte.com/"


def main():
    with sync_playwright() as p:
        # Prefer real installed Chrome over the bundled Chromium if available --
        # it's a more "normal" browser fingerprint. Falls back automatically
        # if Chrome isn't installed.
        try:
            browser = p.chromium.launch(headless=False, channel="chrome")
        except Exception:
            print("(Real Chrome not found, using Playwright's bundled Chromium instead.)")
            browser = p.chromium.launch(headless=False)

        context = browser.new_context()
        page = context.new_page()
        page.goto(PANEL_URL)

        print()
        print("=" * 70)
        print("A browser window has opened to the Shockbyte panel.")
        print("Please log in by hand -- email, then password, same as always.")
        print("Once you can see your server list (My Servers page), come back")
        print("to this terminal window and press Enter.")
        print("=" * 70)
        input("Press Enter after you've finished logging in... ")

        # Sanity check: make sure we're not still sitting on the login page
        if "auth.shockbyte.com" in page.url:
            print()
            print("It looks like you're still on the login page (URL contains")
            print("auth.shockbyte.com). Finish logging in, then press Enter again.")
            input("Press Enter once you're actually logged in... ")

        context.storage_state(path=STATE_FILE)
        print()
        print(f"Saved your session to: {STATE_FILE}")
        print("You can now run shockbyte_download_backup.py (including from cron).")

        browser.close()


if __name__ == "__main__":
    main()

com.david.shockbyte-backup.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.david.shockbyte-backup</string>

    <key>ProgramArguments</key>
    <array>
        <string>/usr/bin/caffeinate</string>
        <string>-i</string>
        <string>/Users/david/scripts/shockbyte_dl/myvenv/bin/python3</string>
        <string>/Users/david/scripts/shockbyte_dl/shockbyte_download_backup.py</string>
    </array>

    <key>WorkingDirectory</key>
    <string>/Users/david/scripts/shockbyte_dl</string>

    <key>StartCalendarInterval</key>
    <dict>
        <key>Hour</key>
        <integer>2</integer>
        <key>Minute</key>
        <integer>0</integer>
    </dict>

    <key>RunAtLoad</key>
    <false/>

    <key>StandardOutPath</key>
    <string>/Users/david/scripts/shockbyte_dl/logs/stdout.log</string>

    <key>StandardErrorPath</key>
    <string>/Users/david/scripts/shockbyte_dl/logs/stderr.log</string>

    <key>ProcessType</key>
    <string>Interactive</string>
</dict>
</plist>