283 lines
8.9 KiB
Python
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())
|