from pathlib import Path from dataclasses import dataclass from datetime import datetime, timedelta from zoneinfo import ZoneInfo import json import os import shutil import sys import time TIMEZONE = ZoneInfo("Pacific/Auckland") def now() -> datetime: return datetime.now(TIMEZONE) DEFAULT_VIDEO_EXTENSIONS = tuple( ext.strip().lower() for ext in os.getenv("VIDEO_EXTENSIONS", ".mp4,.mkv,.mov,.avi,.webm").split(",") if ext.strip() ) @dataclass(frozen=True) class PrerollConfig: active_dir: Path inactive_dir: Path state_file: Path rotate_weekday: int = 0 schedule_time: str = "02:00" video_extensions: tuple[str, ...] = DEFAULT_VIDEO_EXTENSIONS def config_from_env() -> PrerollConfig: return PrerollConfig( active_dir=Path(os.getenv("ACTIVE_DIR", os.getenv("PREROLL_ACTIVE_DIR", "/media/Prerolls"))), inactive_dir=Path(os.getenv("INACTIVE_DIR", os.getenv("PREROLL_INACTIVE_DIR", "/media/Prerolls - Not Active"))), state_file=Path(os.getenv("STATE_FILE", os.getenv("PREROLL_STATE_FILE", "cache/preroll-state.json"))), rotate_weekday=int(os.getenv("ROTATE_WEEKDAY", os.getenv("PREROLL_WEEKDAY", "0"))), schedule_time=os.getenv("SCHEDULE_TIME", os.getenv("PREROLL_TIME", "02:00")).strip(), ) RUN_MODE = os.getenv("RUN_MODE", "schedule").strip().lower() # "schedule" or "once" def log(message: str) -> None: print(f"[{now().isoformat(timespec='seconds')}] {message}", flush=True) def get_videos(folder: Path, video_extensions: tuple[str, ...]): if not folder.exists(): raise FileNotFoundError(f"Folder does not exist: {folder}") return sorted( [ f for f in folder.iterdir() if f.is_file() and f.suffix.lower() in video_extensions ], key=lambda x: x.name.lower(), ) def current_week_key() -> str: # ISO year + ISO week is safer around New Year than %Y-%W. iso = now().isocalendar() return f"{iso.year}-W{iso.week:02d}" def should_rotate(config: PrerollConfig, *, quiet: bool = False) -> bool: today = now() if today.weekday() != config.rotate_weekday: if not quiet: log(f"Today is weekday {today.weekday()}; rotation weekday is {config.rotate_weekday}. No rotation.") return False week_key = current_week_key() if not config.state_file.exists(): if not quiet: log("No previous rotation state found. Rotation allowed.") return True try: state = json.loads(config.state_file.read_text(encoding="utf-8")) last_week = state.get("last_week") if last_week != week_key: if not quiet: log(f"Last rotation was {last_week}; current week is {week_key}. Rotation allowed.") return True if not quiet: log(f"Already rotated for {week_key}. No rotation.") return False except Exception as exc: if not quiet: log(f"Could not read state file: {exc}. Rotation allowed.") return True def read_rotation_state(config: PrerollConfig) -> dict: if not config.state_file.exists(): return {} try: state = json.loads(config.state_file.read_text(encoding="utf-8")) except Exception: return {} return {k: state.get(k) for k in ("last_week", "last_rotation", "active_file")} def save_rotation(config: PrerollConfig, active_name: str) -> dict: config.state_file.parent.mkdir(parents=True, exist_ok=True) payload = { "last_week": current_week_key(), "last_rotation": now().isoformat(timespec="seconds"), "active_file": active_name, } config.state_file.write_text( json.dumps( payload, indent=2, ), encoding="utf-8", ) return payload def move_file(src: Path, dst_dir: Path) -> Path: dst = dst_dir / src.name if dst.exists(): raise FileExistsError(f"Destination already exists: {dst}") shutil.move(str(src), str(dst)) return dst def rotate(config: PrerollConfig) -> str | None: config.active_dir.mkdir(parents=True, exist_ok=True) config.inactive_dir.mkdir(parents=True, exist_ok=True) active_files = get_videos(config.active_dir, config.video_extensions) inactive_files = get_videos(config.inactive_dir, config.video_extensions) if len(active_files) > 1: log("More than one active preroll found. Moving extras to inactive.") for extra in active_files[1:]: move_file(extra, config.inactive_dir) active_files = get_videos(config.active_dir, config.video_extensions) inactive_files = get_videos(config.inactive_dir, config.video_extensions) current_active = active_files[0] if active_files else None all_files = sorted(active_files + inactive_files, key=lambda x: x.name.lower()) if not all_files: log("No preroll videos found.") return None if len(all_files) == 1: only_file = all_files[0] if only_file.parent != config.active_dir: moved = move_file(only_file, config.active_dir) log(f"Only one preroll exists. Activated: {moved.name}") return moved.name log(f"Only one preroll exists and is already active: {only_file.name}") return only_file.name if current_active: current_index = next( i for i, f in enumerate(all_files) if f.name.lower() == current_active.name.lower() ) next_file = all_files[(current_index + 1) % len(all_files)] else: next_file = all_files[0] if current_active: moved_out = move_file(current_active, config.inactive_dir) log(f"Moved current active to inactive: {moved_out.name}") moved_in = move_file(next_file, config.active_dir) log(f"Activated new preroll: {moved_in.name}") return moved_in.name def run_rotation(config: PrerollConfig, *, force: bool = False) -> dict: try: if not force and not should_rotate(config): state = read_rotation_state(config) return { "ok": True, "rotated": False, "message": "Rotation not due yet.", "state": state, } active_name = rotate(config) if not active_name: return { "ok": True, "rotated": False, "message": "No preroll videos found.", "state": read_rotation_state(config), } state = save_rotation(config, active_name) return { "ok": True, "rotated": True, "message": f"Activated {active_name}", "active_file": active_name, "state": state, } except Exception as exc: log(f"ERROR: {exc}") return { "ok": False, "rotated": False, "message": str(exc), "state": read_rotation_state(config), } def run_once(config: PrerollConfig | None = None) -> int: result = run_rotation(config or config_from_env()) return 0 if result["ok"] else 1 def parse_schedule_time(value: str) -> tuple[int, int]: hh, mm = value.split(":", 1) hour = int(hh) minute = int(mm) if not (0 <= hour < 24 and 0 <= minute < 60): raise ValueError(f"SCHEDULE_TIME out of range: {value}") return hour, minute def next_run_after(config: PrerollConfig, base: datetime | None = None) -> datetime: current = base or now() hour, minute = parse_schedule_time(config.schedule_time) days_ahead = (config.rotate_weekday - current.weekday()) % 7 target = current.replace(hour=hour, minute=minute, second=0, microsecond=0) + timedelta(days=days_ahead) if days_ahead == 0 and target <= current: target += timedelta(days=7) return target def seconds_until_next(config: PrerollConfig) -> float: target = next_run_after(config) return (target - now()).total_seconds() def run_scheduled(config: PrerollConfig | None = None) -> int: config = config or config_from_env() try: parse_schedule_time(config.schedule_time) except Exception as exc: log(f"ERROR: invalid SCHEDULE_TIME '{config.schedule_time}': {exc}") return 1 log(f"Scheduler started. Weekly run on weekday {config.rotate_weekday} at {config.schedule_time} local time.") while True: delay = seconds_until_next(config) next_run = now() + timedelta(seconds=delay) log(f"Sleeping {int(delay)}s until next run at {next_run.isoformat(timespec='seconds')}.") time.sleep(delay) log("Scheduled run triggered.") run_rotation(config) def main() -> int: config = config_from_env() log("Starting preroll rotator.") log(f"Active folder: {config.active_dir}") log(f"Inactive folder: {config.inactive_dir}") log(f"Run mode: {RUN_MODE}") if RUN_MODE == "once": return run_once(config) return run_scheduled(config) if __name__ == "__main__": sys.exit(main())