diff --git a/CHANGELOG.md b/CHANGELOG.md index d86864d..a59bfda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,13 @@ Each version entry uses these sections (include only those that apply): ## Unreleased +### Added +- Standalone **`scripts/tipy_dne.py`** to regenerate the "Tipy dne" folder + independently of the app (deployable on the server / cron): reads the pool + index, picks N random movies (default 15), clears the tips folder and recreates + the hardlinks. Stdlib only; paths via `--pool`/`--output` or the global config. + Ships with a systemd oneshot `tipy-dne.service` + daily `tipy-dne.timer`. + ## 1.6.0 — 2026-06-16 ### Added diff --git a/scripts/tipy-dne.service b/scripts/tipy-dne.service new file mode 100644 index 0000000..a4f475d --- /dev/null +++ b/scripts/tipy-dne.service @@ -0,0 +1,27 @@ +# systemd oneshot service for the standalone "Tipy dne" generator. +# Runs scripts/tipy_dne.py once; schedule it with the matching tipy-dne.timer. +# +# Install (as root): +# cp scripts/tipy_dne.py /opt/curator/tipy_dne.py +# cp scripts/tipy-dne.service scripts/tipy-dne.timer /etc/systemd/system/ +# # edit User/paths below to match the server, then: +# systemctl daemon-reload +# systemctl enable --now tipy-dne.timer +# systemctl start tipy-dne.service # run once now to test +# +# Adjust: User/Group, the script path, and --pool / --output to your server. + +[Unit] +Description=Generate the "Tipy dne" Filmotéka folder +After=local-fs.target remote-fs.target + +[Service] +Type=oneshot +User=honza +Group=honza +ExecStart=/usr/bin/python3 /opt/curator/tipy_dne.py \ + --pool /mnt/RHD-SRV2/Filmoteka \ + --output /mnt/RHD-SRV2/Filmy/Filmoteka_v2 \ + --count 15 +Nice=10 +NoNewPrivileges=true diff --git a/scripts/tipy-dne.timer b/scripts/tipy-dne.timer new file mode 100644 index 0000000..d72d97a --- /dev/null +++ b/scripts/tipy-dne.timer @@ -0,0 +1,14 @@ +# systemd timer for tipy-dne.service — regenerates "Tipy dne" daily. +# Enable with: systemctl enable --now tipy-dne.timer + +[Unit] +Description=Daily regeneration of the "Tipy dne" Filmotéka folder + +[Timer] +# Every day at 06:00 (server local time). Change as needed, e.g. "hourly". +OnCalendar=*-*-* 06:00:00 +# Catch up if the machine was off at the scheduled time. +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/scripts/tipy_dne.py b/scripts/tipy_dne.py new file mode 100644 index 0000000..4cb6ddb --- /dev/null +++ b/scripts/tipy_dne.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Standalone "Tipy dne" generator — deployable next to a Filmotéka (e.g. cron). + +On each run it reads the pool index (``/.Curator.!index``), picks N random +pool movies, clears the tips folder in the output and recreates it as hardlinks. +It does not import Curator — only the Python standard library — so it can live on +the server with the Filmotéka, independent of the app. + +Paths come from CLI args, or fall back to the global config (``.Curator.!gtag``, +keys ``pool_dir`` / ``filmoteka_dir``) when ``--config`` is given or one is found. + +Examples: + python scripts/tipy_dne.py --pool /mnt/srv/Filmoteka --output /mnt/srv/Filmy/Filmoteka_v2 + python scripts/tipy_dne.py --count 15 # uses the repo's global config +""" + +from __future__ import annotations + +import os +import sys +import json +import random +import argparse +from pathlib import Path + +INDEX_FILENAME = ".Curator.!index" +GLOBAL_CONFIG_FILENAME = ".Curator.!gtag" +DEFAULT_SUBFOLDER = "Tipy dne" +DEFAULT_COUNT = 15 + + +def _load_config_paths(config_path: Path) -> tuple[Path | None, Path | None]: + """Read (pool_dir, filmoteka_dir) from a global config file, if present.""" + try: + with open(config_path, "r", encoding="utf-8") as f: + cfg = json.load(f) + except (OSError, json.JSONDecodeError): + return None, None + pool = cfg.get("pool_dir") + out = cfg.get("filmoteka_dir") + return (Path(pool) if pool else None, Path(out) if out else None) + + +def _pool_movies(pool_dir: Path) -> list[Path]: + """Existing pool movie files listed in the index (copy-as-is folders aren't + indexed, so this is the pool/Filmy set only).""" + index_path = pool_dir / INDEX_FILENAME + with open(index_path, "r", encoding="utf-8") as f: + data = json.load(f) + movies: list[Path] = [] + for rel in data.get("movies", {}): + path = pool_dir / rel + if path.is_file(): + movies.append(path) + return movies + + +def _clear_folder(folder: Path) -> int: + """Remove the files in ``folder`` (the old tips). Returns how many removed.""" + removed = 0 + if folder.exists(): + for child in folder.iterdir(): + if child.is_file() or child.is_symlink(): + try: + child.unlink() + removed += 1 + except OSError as exc: + print(f" ! nelze smazat {child}: {exc}", file=sys.stderr) + return removed + + +def generate_tips(pool_dir: Path, output_dir: Path, count: int, subfolder: str) -> int: + """Refresh the tips folder with ``count`` random pool movies as hardlinks.""" + movies = _pool_movies(pool_dir) + if not movies: + print("Index neobsahuje žádné filmy.", file=sys.stderr) + return 0 + + tips_dir = output_dir / subfolder + cleared = _clear_folder(tips_dir) + tips_dir.mkdir(parents=True, exist_ok=True) + + chosen = random.sample(movies, min(count, len(movies))) + created = 0 + for src in chosen: + target = tips_dir / src.name + try: + if target.exists(): + target.unlink() + os.link(src, target) + created += 1 + print(f" + {src.name}") + except OSError as exc: + print(f" ! {src.name}: {exc}", file=sys.stderr) + + print(f"\nTipy dne: smazáno {cleared}, vytvořeno {created} (z {len(movies)} filmů)") + return created + + +def main() -> None: + parser = argparse.ArgumentParser(description="Vygeneruje složku Tipy dne.") + parser.add_argument("--pool", type=Path, help="Pool root (obsahuje .Curator.!index)") + parser.add_argument("--output", type=Path, help="Výstupní složka Filmotéky") + parser.add_argument("--count", type=int, default=DEFAULT_COUNT, + help=f"Počet náhodných titulů (výchozí {DEFAULT_COUNT})") + parser.add_argument("--subfolder", default=DEFAULT_SUBFOLDER, + help=f'Název složky tipů (výchozí "{DEFAULT_SUBFOLDER}")') + parser.add_argument("--config", type=Path, + help="Cesta ke global configu (.Curator.!gtag) pro doplnění cest") + args = parser.parse_args() + + pool, output = args.pool, args.output + if pool is None or output is None: + config_path = args.config or ( + Path(__file__).resolve().parent.parent / GLOBAL_CONFIG_FILENAME) + cfg_pool, cfg_out = _load_config_paths(config_path) + pool = pool or cfg_pool + output = output or cfg_out + + if pool is None or output is None: + parser.error("Chybí --pool a/nebo --output (a nenalezeny v global configu).") + if not (pool / INDEX_FILENAME).is_file(): + parser.error(f"Index nenalezen: {pool / INDEX_FILENAME}") + + created = generate_tips(pool, output, args.count, args.subfolder) + sys.exit(0 if created else 1) + + +if __name__ == "__main__": + main() diff --git a/src/_version.py b/src/_version.py index 5623154..a1c6637 100644 --- a/src/_version.py +++ b/src/_version.py @@ -1,2 +1,2 @@ """Auto-generated — do not edit manually.""" -__version__ = "1.5.0" +__version__ = "1.6.0"