Homelabtoolkit v2

This commit is contained in:
2026-06-08 21:58:16 +12:00
parent 040fbacc70
commit 3c77066beb
75 changed files with 16945 additions and 374 deletions
+282
View File
@@ -0,0 +1,282 @@
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())