Rewrite Curator in Rust with an egui GUI

This commit is contained in:
2026-10-17 11:28:17 +02:00
parent 364b515e3c
commit 65b5b74c9f
156 changed files with 24359 additions and 25795 deletions
-107
View File
@@ -1,107 +0,0 @@
"""One-off migration: rename a tag category inside a pool's metadata index.
Tags are stored in ``<pool>/.Curator.!index`` as ``"Category/Name"`` strings.
This rewrites every tag whose category matches ``--old`` to use ``--new``,
leaving the tag name untouched. A timestamped backup of the index is written
before saving.
Usage:
uv run python scripts/migrate_tag_category.py <pool_dir> \
--old "Země" --new "Země původu"
If ``<pool_dir>`` is omitted, the pool from the global config is used.
"""
from __future__ import annotations
import argparse
import json
import shutil
import sys
from datetime import datetime
from pathlib import Path
from loguru import logger
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.core.config import load_global_config
from src.core.pool_index import INDEX_FILENAME
def _rename_in_tags(tags: list[str], old: str, new: str) -> tuple[list[str], int]:
"""Return (rewritten tags, number of tags changed) for one record."""
prefix = f"{old}/"
changed = 0
result: list[str] = []
for tag in tags:
if isinstance(tag, str) and tag.startswith(prefix):
result.append(f"{new}/{tag[len(prefix) :]}")
changed += 1
else:
result.append(tag)
return result, changed
def migrate(index_path: Path, old: str, new: str) -> int:
"""Rewrite the category in place and return the number of tags changed."""
with open(index_path, "r", encoding="utf-8") as f:
data = json.load(f)
movies: dict[str, dict] = data.get("movies", {})
total_changed = 0
affected_records = 0
for key, record in movies.items():
tags = record.get("tags", [])
new_tags, changed = _rename_in_tags(tags, old, new)
if changed:
record["tags"] = new_tags
total_changed += changed
affected_records += 1
logger.debug(f"{key}: {changed} tag(s) renamed")
if total_changed == 0:
logger.info(f"No '{old}/…' tags found — nothing to migrate")
return 0
backup = index_path.with_suffix(
index_path.suffix + f".bak-{datetime.now():%Y%m%d-%H%M%S}"
)
shutil.copy2(index_path, backup)
logger.info(f"Backup written: {backup}")
with open(index_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
logger.info(
f"Migrated '{old}' → '{new}': {total_changed} tag(s) "
f"across {affected_records} record(s)"
)
return total_changed
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"pool_dir",
nargs="?",
help="Pool root (default: pool_dir from the global config)",
)
parser.add_argument("--old", default="Země", help="Category to rename from")
parser.add_argument("--new", default="Země původu", help="Category to rename to")
args = parser.parse_args()
pool_dir = args.pool_dir or load_global_config().get("pool_dir")
if not pool_dir:
parser.error("No pool_dir given and none configured in the global config")
index_path = Path(pool_dir) / INDEX_FILENAME
if not index_path.exists():
parser.error(f"No index found at {index_path}")
migrate(index_path, args.old, args.new)
if __name__ == "__main__":
main()
-124
View File
@@ -1,124 +0,0 @@
"""One-off migration: split combined country tags in a pool's metadata index.
Before multi-country support, a co-production fetched from ČSFD was stored as a
single ``"Země původu/USA / Velká Británie"`` tag. This rewrites each such tag
into one tag per country (``"Země původu/USA"`` + ``"Země původu/Velká
Británie"``), de-duplicating within each record. A timestamped backup of the
index is written before saving.
Usage:
uv run python scripts/split_country_tags.py [<pool_dir>] [--category "Země původu"]
If ``<pool_dir>`` is omitted, the pool from the global config is used.
"""
from __future__ import annotations
import argparse
import json
import shutil
import sys
from datetime import datetime
from pathlib import Path
from loguru import logger
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.core.config import load_global_config
from src.core.pool_index import INDEX_FILENAME
def _split_record_tags(tags: list[str], category: str) -> tuple[list[str], int]:
"""Return (rewritten tags, number of combined tags split) for one record.
Order is preserved; duplicates produced by the split are dropped.
"""
prefix = f"{category}/"
result: list[str] = []
seen: set[str] = set()
split_count = 0
def _add(tag: str) -> None:
if tag not in seen:
seen.add(tag)
result.append(tag)
for tag in tags:
if (
isinstance(tag, str)
and tag.startswith(prefix)
and "/" in tag[len(prefix) :]
):
value = tag[len(prefix) :]
countries = [c.strip() for c in value.split("/") if c.strip()]
for country in countries:
_add(f"{prefix}{country}")
split_count += 1
else:
_add(tag)
return result, split_count
def migrate(index_path: Path, category: str) -> int:
"""Split combined ``category`` tags in place; return number of tags split."""
with open(index_path, "r", encoding="utf-8") as f:
data = json.load(f)
movies: dict[str, dict] = data.get("movies", {})
total_split = 0
affected = 0
for key, record in movies.items():
tags = record.get("tags", [])
new_tags, split_count = _split_record_tags(tags, category)
if split_count:
record["tags"] = new_tags
total_split += split_count
affected += 1
logger.debug(f"{key}: {split_count} combined tag(s) split")
if total_split == 0:
logger.info(f"No combined '{category}/…' tags found — nothing to migrate")
return 0
backup = index_path.with_suffix(
index_path.suffix + f".bak-{datetime.now():%Y%m%d-%H%M%S}"
)
shutil.copy2(index_path, backup)
logger.info(f"Backup written: {backup}")
with open(index_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
logger.info(
f"Split {total_split} combined '{category}' tag(s) across {affected} record(s)"
)
return total_split
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"pool_dir",
nargs="?",
help="Pool root (default: pool_dir from the global config)",
)
parser.add_argument(
"--category", default="Země původu", help="Tag category to split"
)
args = parser.parse_args()
pool_dir = args.pool_dir or load_global_config().get("pool_dir")
if not pool_dir:
parser.error("No pool_dir given and none configured in the global config")
index_path = Path(pool_dir) / INDEX_FILENAME
if not index_path.exists():
parser.error(f"No index found at {index_path}")
migrate(index_path, args.category)
if __name__ == "__main__":
main()
-111
View File
@@ -1,111 +0,0 @@
"""One-off migration: drop the old decade-band rating tags from a pool index.
Earlier the ČSFD rating was stored bucketed (e.g. ``Hodnocení/90–100 %``). Now
the tag carries the exact value (``Hodnocení/90``) and the band is only a folder.
This removes the legacy band tags (``Hodnocení/<x>–<y> %``) so re-fetching from
ČSFD leaves only the exact ratings. Exact rating tags are kept. A timestamped
backup of the index is written first.
Usage:
uv run python scripts/strip_rating_bands.py [<pool_dir>] [--category "Hodnocení"]
"""
from __future__ import annotations
import argparse
import json
import re
import shutil
import sys
from datetime import datetime
from pathlib import Path
from loguru import logger
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.core.config import load_global_config
from src.core.pool_index import INDEX_FILENAME
# A band tag looks like "Hodnocení/90–100 %" — value has a dash range and a "%".
_BAND_RE = re.compile(r"\d+\s*[–-]\s*\d+\s*%")
def _strip_bands(tags: list[str], category: str) -> tuple[list[str], int]:
"""Return (kept tags, removed count), dropping ``category`` band tags."""
prefix = f"{category}/"
kept = [
t
for t in tags
if not (isinstance(t, str) and t.startswith(prefix) and _BAND_RE.search(t))
]
return kept, len(tags) - len(kept)
def migrate(index_path: Path, category: str) -> int:
"""Remove band rating tags in place; return number of tags removed."""
with open(index_path, "r", encoding="utf-8") as f:
data = json.load(f)
movies: dict[str, dict] = data.get("movies", {})
total_removed = 0
affected = 0
for key, record in movies.items():
tags = record.get("tags", [])
kept, removed = _strip_bands(tags, category)
if removed:
record["tags"] = kept
# also drop them from the ČSFD provenance set, if present
if isinstance(record.get("csfd_tags"), list):
record["csfd_tags"] = [
t
for t in record["csfd_tags"]
if not (t.startswith(f"{category}/") and _BAND_RE.search(t))
]
total_removed += removed
affected += 1
logger.debug(f"{key}: removed {removed} band tag(s)")
if total_removed == 0:
logger.info(f"No '{category}/…–… %' band tags found — nothing to migrate")
return 0
backup = index_path.with_suffix(
index_path.suffix + f".bak-{datetime.now():%Y%m%d-%H%M%S}"
)
shutil.copy2(index_path, backup)
logger.info(f"Backup written: {backup}")
with open(index_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
logger.info(
f"Removed {total_removed} band '{category}' tag(s) across {affected} record(s)"
)
return total_removed
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"pool_dir",
nargs="?",
help="Pool root (default: pool_dir from the global config)",
)
parser.add_argument("--category", default="Hodnocení", help="Rating category")
args = parser.parse_args()
pool_dir = args.pool_dir or load_global_config().get("pool_dir")
if not pool_dir:
parser.error("No pool_dir given and none configured in the global config")
index_path = Path(pool_dir) / INDEX_FILENAME
if not index_path.exists():
parser.error(f"No index found at {index_path}")
migrate(index_path, args.category)
if __name__ == "__main__":
main()
-101
View File
@@ -1,101 +0,0 @@
"""One-off migration: drop all tags of given categories from a pool's index.
Used to remove tag categories that turned out to be a bad idea (e.g. Režie /
Herec produced far too many folders). Cached ČSFD data is left intact — only the
``tags`` lists are pruned. A timestamped backup of the index is written first.
Usage:
uv run python scripts/strip_tag_categories.py [<pool_dir>] \
--categories "Režie" "Herec"
"""
from __future__ import annotations
import argparse
import json
import shutil
import sys
from datetime import datetime
from pathlib import Path
from loguru import logger
# Allow running as a plain script (``python scripts/...``) by exposing the repo root.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.core.config import load_global_config
from src.core.pool_index import INDEX_FILENAME
def _strip(tags: list[str], prefixes: tuple[str, ...]) -> tuple[list[str], int]:
"""Return (kept tags, number removed) dropping tags under any prefix."""
kept = [t for t in tags if not (isinstance(t, str) and t.startswith(prefixes))]
return kept, len(tags) - len(kept)
def migrate(index_path: Path, categories: list[str]) -> int:
"""Remove all tags of ``categories`` in place; return number of tags removed."""
prefixes = tuple(f"{c}/" for c in categories)
with open(index_path, "r", encoding="utf-8") as f:
data = json.load(f)
movies: dict[str, dict] = data.get("movies", {})
total_removed = 0
affected = 0
for key, record in movies.items():
tags = record.get("tags", [])
kept, removed = _strip(tags, prefixes)
if removed:
record["tags"] = kept
total_removed += removed
affected += 1
logger.debug(f"{key}: removed {removed} tag(s)")
if total_removed == 0:
logger.info(f"No tags in {categories} found — nothing to migrate")
return 0
backup = index_path.with_suffix(
index_path.suffix + f".bak-{datetime.now():%Y%m%d-%H%M%S}"
)
shutil.copy2(index_path, backup)
logger.info(f"Backup written: {backup}")
with open(index_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
logger.info(
f"Removed {total_removed} tag(s) of {categories} across {affected} record(s)"
)
return total_removed
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"pool_dir",
nargs="?",
help="Pool root (default: pool_dir from the global config)",
)
parser.add_argument(
"--categories",
nargs="+",
default=["Režie", "Herec"],
help="Tag categories to strip",
)
args = parser.parse_args()
pool_dir = args.pool_dir or load_global_config().get("pool_dir")
if not pool_dir:
parser.error("No pool_dir given and none configured in the global config")
index_path = Path(pool_dir) / INDEX_FILENAME
if not index_path.exists():
parser.error(f"No index found at {index_path}")
migrate(index_path, args.categories)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -7,6 +7,6 @@ RequiresMountsFor=/mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0
Type=oneshot
User=honza
Group=honza
ExecStart=/usr/bin/python3 /mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0/Filmotéka/Tools/tipy_dne.py --pool /mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0/Filmotéka --output /mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0/Filmy --count 15
ExecStart=/mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0/Filmotéka/Tools/tipy-dne --pool /mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0/Filmotéka --output /mnt/c34a47db-0bdf-4c4c-8187-14711c022cf0/Filmy --count 15
Nice=10
NoNewPrivileges=true
-143
View File
@@ -1,143 +0,0 @@
#!/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 (``<pool>/.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 argparse
import json
import os
import random
import sys
from pathlib import Path
INDEX_FILENAME = ".Curator.!index"
GLOBAL_CONFIG_FILENAME = ".Curator.!gtag"
DEFAULT_SUBFOLDER = "- Tipy dne" # "- " prefix sorts it first in DLNA/TV browsers
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()