Files
embycovers/rotate_preroll.py
2026-06-08 21:58:16 +12:00

283 lines
8.9 KiB
Python

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())