Homelabtoolkit v2
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user