Add "Tipy dne" folder and fix stale genre folder cleanup
This commit is contained in:
@@ -21,6 +21,21 @@ Each version entry uses these sections (include only those that apply):
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## 1.6.0 — 2026-06-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Filmotéka generation now also creates a **"Tipy dne"** folder with **10 random
|
||||||
|
pool movies** (hardlinks; copy-as-is folders like Seriály are excluded). The
|
||||||
|
folder is refreshed each run (`HardlinkManager.generate_random_tips`).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Stale **root-level genre folders** were left in the Filmotéka output when the
|
||||||
|
last movie carrying a genre dropped that tag: obsolete-link cleanup only
|
||||||
|
scanned genre folders still present on current movies, so an emptied one was
|
||||||
|
never visited. Cleanup now also sweeps other root-level folders (excluding the
|
||||||
|
grouping roots and `reserved_subfolders` — copy-as-is mirrors, "Tipy dne") and
|
||||||
|
removes the now-empty folder. Grouping folders (Dle roku/…) were already fine.
|
||||||
|
|
||||||
## 1.5.0 — 2026-06-16
|
## 1.5.0 — 2026-06-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "curator"
|
name = "curator"
|
||||||
version = "1.5.0"
|
version = "1.6.0"
|
||||||
description = ""
|
description = ""
|
||||||
authors = [
|
authors = [
|
||||||
{name = "jan.doubravsky@gmail.com"}
|
{name = "jan.doubravsky@gmail.com"}
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Minimal PySide6 GUI for filtering magnet lists from ``rargb_magnets.py``.
|
|
||||||
|
|
||||||
Just a text box on top and a list below — type to filter live (same syntax as
|
|
||||||
the CLI: space-separated AND terms, ``-term`` to exclude). Double-click or press
|
|
||||||
Enter on a row to copy its magnet link to the clipboard.
|
|
||||||
|
|
||||||
python tools/filter_magnets_gui.py [files/glob/dir ...]
|
|
||||||
|
|
||||||
With no arguments it loads ``magnets_*.txt`` from the current directory. The
|
|
||||||
loading/filtering logic is reused from ``filter_magnets.py`` in this folder.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
# Reuse the CLI tool's parsing/filtering (same folder).
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
||||||
from filter_magnets import Entry, load_entries, apply_filter, resolve_inputs # noqa: E402
|
|
||||||
|
|
||||||
from PySide6.QtCore import Qt # noqa: E402
|
|
||||||
from PySide6.QtWidgets import ( # noqa: E402
|
|
||||||
QApplication, QWidget, QVBoxLayout, QLineEdit, QListWidget, QListWidgetItem,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MagnetFilter(QWidget):
|
|
||||||
def __init__(self, entries: list[Entry]) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self.entries = entries
|
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
|
||||||
layout.setContentsMargins(6, 6, 6, 6)
|
|
||||||
|
|
||||||
self.search = QLineEdit()
|
|
||||||
self.search.setPlaceholderText("filtr… (např. 1080p 2022 -hindi) — ↵/dvojklik = kopírovat magnet")
|
|
||||||
self.search.setClearButtonEnabled(True)
|
|
||||||
self.search.textChanged.connect(self._refilter)
|
|
||||||
layout.addWidget(self.search)
|
|
||||||
|
|
||||||
self.list = QListWidget()
|
|
||||||
self.list.itemActivated.connect(self._copy) # Enter / double-click
|
|
||||||
layout.addWidget(self.list)
|
|
||||||
|
|
||||||
self.resize(820, 600)
|
|
||||||
self._refilter("")
|
|
||||||
self.search.setFocus()
|
|
||||||
|
|
||||||
def _refilter(self, text: str) -> None:
|
|
||||||
self.list.clear()
|
|
||||||
for entry in apply_filter(self.entries, text):
|
|
||||||
short = entry.magnet.split("&", 1)[0] # only the part before the first &
|
|
||||||
item = QListWidgetItem(f"{entry.name}\n{short}")
|
|
||||||
item.setData(Qt.UserRole, short)
|
|
||||||
item.setToolTip(short)
|
|
||||||
self.list.addItem(item)
|
|
||||||
self._update_title()
|
|
||||||
|
|
||||||
def _copy(self, item: QListWidgetItem) -> None:
|
|
||||||
QApplication.clipboard().setText(item.data(Qt.UserRole))
|
|
||||||
self._update_title(copied=item.text())
|
|
||||||
|
|
||||||
def _update_title(self, copied: str | None = None) -> None:
|
|
||||||
base = f"Magnet filtr — {self.list.count()} / {len(self.entries)}"
|
|
||||||
self.setWindowTitle(f"{base} ✓ zkopírováno" if copied else base)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
paths = [p for p in resolve_inputs(sys.argv[1:]) if p.exists()]
|
|
||||||
if not paths:
|
|
||||||
print("Žádné vstupní soubory (magnets_*.txt) nenalezeny.", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
entries = load_entries(paths)
|
|
||||||
if not entries:
|
|
||||||
print("Vstupní soubory neobsahují žádné magnet odkazy.", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
app = QApplication(sys.argv)
|
|
||||||
window = MagnetFilter(entries)
|
|
||||||
window.show()
|
|
||||||
sys.exit(app.exec())
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Standalone scraper: collect magnet links from a rargb.to search.
|
|
||||||
|
|
||||||
Given a search query it walks every results page
|
|
||||||
(``https://rargb.to/search/?search=<query>`` and ``/search/<N>/?search=<query>``),
|
|
||||||
opens each torrent's detail page and saves its magnet link.
|
|
||||||
|
|
||||||
This is a self-contained tool — it only needs ``requests`` and
|
|
||||||
``beautifulsoup4`` and does not import anything from the Curator project.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
python scripts/rargb_magnets.py "ubuntu 24.04"
|
|
||||||
python scripts/rargb_magnets.py test --output test_magnets.txt --max-pages 3
|
|
||||||
python scripts/rargb_magnets.py test --tsv # also write name<TAB>magnet
|
|
||||||
|
|
||||||
Be considerate: a polite delay is inserted between requests by default. Use the
|
|
||||||
results responsibly and respect the target site's terms and your local law.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import argparse
|
|
||||||
from pathlib import Path
|
|
||||||
from urllib.parse import quote, urljoin
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
|
|
||||||
BASE_URL = "https://rargb.to"
|
|
||||||
HEADERS = {
|
|
||||||
"User-Agent": (
|
|
||||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
|
||||||
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
||||||
),
|
|
||||||
"Accept-Language": "en-US,en;q=0.9",
|
|
||||||
}
|
|
||||||
MAGNET_RE = re.compile(r"magnet:\?[^\"'\s<>]+")
|
|
||||||
|
|
||||||
|
|
||||||
def search_page_url(query: str, page: int) -> str:
|
|
||||||
"""URL of the N-th results page for a query (page 1 has no number)."""
|
|
||||||
q = quote(query)
|
|
||||||
if page <= 1:
|
|
||||||
return f"{BASE_URL}/search/?search={q}"
|
|
||||||
return f"{BASE_URL}/search/{page}/?search={q}"
|
|
||||||
|
|
||||||
|
|
||||||
def fetch(session: requests.Session, url: str, timeout: float, retries: int) -> str | None:
|
|
||||||
"""GET ``url`` and return the HTML, or None after exhausting retries."""
|
|
||||||
for attempt in range(1, retries + 1):
|
|
||||||
try:
|
|
||||||
resp = session.get(url, headers=HEADERS, timeout=timeout)
|
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.text
|
|
||||||
except requests.RequestException as exc:
|
|
||||||
wait = attempt * 2
|
|
||||||
print(f" ! chyba ({attempt}/{retries}) u {url}: {exc} — čekám {wait}s",
|
|
||||||
file=sys.stderr)
|
|
||||||
time.sleep(wait)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def parse_result_links(html: str) -> list[tuple[str, str]]:
|
|
||||||
"""Return (name, detail_url) for each result row on a search page."""
|
|
||||||
soup = BeautifulSoup(html, "html.parser")
|
|
||||||
results: list[tuple[str, str]] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
for row in soup.select("tr.lista2"):
|
|
||||||
link = row.find("a", href=re.compile(r"^/torrent/"))
|
|
||||||
if not link:
|
|
||||||
continue
|
|
||||||
href = link.get("href")
|
|
||||||
if not href or href in seen:
|
|
||||||
continue
|
|
||||||
seen.add(href)
|
|
||||||
name = link.get("title") or link.get_text(strip=True) or href
|
|
||||||
results.append((name.strip(), urljoin(BASE_URL, href)))
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
def parse_last_page(html: str) -> int:
|
|
||||||
"""Best-effort highest page number from the pager (1 if none found)."""
|
|
||||||
pages = [int(n) for n in re.findall(r"/search/(\d+)/\?search=", html)]
|
|
||||||
return max(pages) if pages else 1
|
|
||||||
|
|
||||||
|
|
||||||
def extract_magnet(html: str) -> str | None:
|
|
||||||
"""First magnet link found on a torrent detail page, or None."""
|
|
||||||
match = MAGNET_RE.search(html)
|
|
||||||
return match.group(0) if match else None
|
|
||||||
|
|
||||||
|
|
||||||
def scrape(query: str, max_pages: int | None, delay: float,
|
|
||||||
timeout: float, retries: int) -> list[tuple[str, str]]:
|
|
||||||
"""Walk all result pages and return a de-duplicated [(name, magnet)] list."""
|
|
||||||
session = requests.Session()
|
|
||||||
collected: list[tuple[str, str]] = []
|
|
||||||
seen_magnets: set[str] = set()
|
|
||||||
seen_details: set[str] = set()
|
|
||||||
|
|
||||||
first_html = fetch(session, search_page_url(query, 1), timeout, retries)
|
|
||||||
if first_html is None:
|
|
||||||
print("Nepodařilo se načíst první stránku výsledků.", file=sys.stderr)
|
|
||||||
return collected
|
|
||||||
|
|
||||||
last_page = parse_last_page(first_html)
|
|
||||||
if max_pages is not None:
|
|
||||||
last_page = min(last_page, max_pages)
|
|
||||||
print(f"Dotaz: {query!r} — stránek k projití: ~{last_page}")
|
|
||||||
|
|
||||||
page = 1
|
|
||||||
while True:
|
|
||||||
html = first_html if page == 1 else fetch(
|
|
||||||
session, search_page_url(query, page), timeout, retries)
|
|
||||||
if html is None:
|
|
||||||
break
|
|
||||||
|
|
||||||
rows = parse_result_links(html)
|
|
||||||
new_rows = [(n, u) for n, u in rows if u not in seen_details]
|
|
||||||
if not new_rows:
|
|
||||||
# No fresh results → past the last real page; stop.
|
|
||||||
break
|
|
||||||
|
|
||||||
print(f"[strana {page}] nalezeno položek: {len(new_rows)}")
|
|
||||||
for name, detail_url in new_rows:
|
|
||||||
seen_details.add(detail_url)
|
|
||||||
time.sleep(delay)
|
|
||||||
detail_html = fetch(session, detail_url, timeout, retries)
|
|
||||||
if detail_html is None:
|
|
||||||
print(f" - {name}: detail se nenačetl", file=sys.stderr)
|
|
||||||
continue
|
|
||||||
magnet = extract_magnet(detail_html)
|
|
||||||
if not magnet:
|
|
||||||
print(f" - {name}: magnet nenalezen", file=sys.stderr)
|
|
||||||
continue
|
|
||||||
if magnet in seen_magnets:
|
|
||||||
continue
|
|
||||||
seen_magnets.add(magnet)
|
|
||||||
collected.append((name, magnet))
|
|
||||||
print(f" + {name}")
|
|
||||||
|
|
||||||
if max_pages is not None and page >= max_pages:
|
|
||||||
break
|
|
||||||
page += 1
|
|
||||||
if page > last_page:
|
|
||||||
# Probe one page past the detected last page in case the pager was
|
|
||||||
# windowed; the empty-results check above will stop us if it's truly
|
|
||||||
# the end.
|
|
||||||
last_page = page
|
|
||||||
time.sleep(delay)
|
|
||||||
|
|
||||||
return collected
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Vyparsuje magnet odkazy z vyhledávání na rargb.to.")
|
|
||||||
parser.add_argument("query", help="Vyhledávací dotaz (např. \"ubuntu 24.04\")")
|
|
||||||
parser.add_argument("-o", "--output", type=Path,
|
|
||||||
help="Výstupní soubor (výchozí: magnets_<dotaz>.txt)")
|
|
||||||
parser.add_argument("--max-pages", type=int, default=None,
|
|
||||||
help="Maximální počet stránek (výchozí: všechny)")
|
|
||||||
parser.add_argument("--delay", type=float, default=1.0,
|
|
||||||
help="Prodleva mezi requesty v sekundách (výchozí: 1.0)")
|
|
||||||
parser.add_argument("--timeout", type=float, default=20.0,
|
|
||||||
help="Timeout requestu v sekundách (výchozí: 20)")
|
|
||||||
parser.add_argument("--retries", type=int, default=3,
|
|
||||||
help="Počet pokusů při chybě (výchozí: 3)")
|
|
||||||
parser.add_argument("--tsv", action="store_true",
|
|
||||||
help="Uložit i <název>\\t<magnet> vedle čistých magnetů")
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
output = args.output or Path(
|
|
||||||
f"magnets_{re.sub(r'[^A-Za-z0-9._-]+', '_', args.query).strip('_')}.txt")
|
|
||||||
|
|
||||||
results = scrape(args.query, args.max_pages, args.delay, args.timeout, args.retries)
|
|
||||||
|
|
||||||
if not results:
|
|
||||||
print("Nenalezeny žádné magnet odkazy.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
output.write_text("".join(f"{magnet}\n" for _, magnet in results), encoding="utf-8")
|
|
||||||
print(f"\nUloženo {len(results)} magnet odkazů do: {output}")
|
|
||||||
|
|
||||||
if args.tsv:
|
|
||||||
tsv_path = output.with_suffix(".tsv")
|
|
||||||
tsv_path.write_text(
|
|
||||||
"".join(f"{name}\t{magnet}\n" for name, magnet in results), encoding="utf-8")
|
|
||||||
print(f"Uloženo také název+magnet do: {tsv_path}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -18,6 +18,7 @@ Example:
|
|||||||
└── film.mkv (hardlink)
|
└── film.mkv (hardlink)
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List, Tuple, Optional, Dict, Set
|
from typing import List, Tuple, Optional, Dict, Set
|
||||||
from .file import File
|
from .file import File
|
||||||
@@ -115,25 +116,40 @@ class HardlinkManager:
|
|||||||
def _managed_top_dirs(
|
def _managed_top_dirs(
|
||||||
self, files: List[File], roots: Optional[Dict[str, str]],
|
self, files: List[File], roots: Optional[Dict[str, str]],
|
||||||
transforms: Optional[Dict[str, str]] = None,
|
transforms: Optional[Dict[str, str]] = None,
|
||||||
|
reserved_subfolders: Optional[Set[str]] = None,
|
||||||
) -> Optional[Set[str]]:
|
) -> Optional[Set[str]]:
|
||||||
"""Top-level output folders owned by the tag tree (None = all of them).
|
"""Top-level output folders owned by the tag tree (None = all of them).
|
||||||
|
|
||||||
For a category with a non-empty root the root folder is owned; for a
|
A category with a non-empty root owns that root folder. A category placed
|
||||||
category placed at the output root (empty root, e.g. genres) each of its
|
at the output root (empty root, e.g. genres) owns its own folders at the
|
||||||
(transformed) tag values is its own top-level folder. This lets cleanup
|
root — and, so a genre whose last movie dropped the tag still gets its
|
||||||
skip unrelated root entries such as the copy-as-is mirror (Seriály).
|
now-stale folder cleaned, *every* root-level folder is treated as owned
|
||||||
|
except the grouping roots and any ``reserved_subfolders`` (copy-as-is
|
||||||
|
mirrors, "Tipy dne", …). The obsolete check still only removes links to
|
||||||
|
current pool files, so reserved/foreign folders are doubly protected.
|
||||||
"""
|
"""
|
||||||
if roots is None:
|
if roots is None:
|
||||||
return None
|
return None
|
||||||
tops: Set[str] = set()
|
grouping_roots = {folder for folder in roots.values() if folder}
|
||||||
|
has_root_level = any(not folder for folder in roots.values())
|
||||||
|
tops: Set[str] = set(grouping_roots)
|
||||||
|
|
||||||
|
# genre folders currently present on the movies
|
||||||
for cat, folder in roots.items():
|
for cat, folder in roots.items():
|
||||||
if folder:
|
if not folder:
|
||||||
tops.add(folder)
|
|
||||||
else:
|
|
||||||
for file_obj in files:
|
for file_obj in files:
|
||||||
for tag in file_obj.tags:
|
for tag in file_obj.tags:
|
||||||
if tag.category == cat:
|
if tag.category == cat:
|
||||||
tops.add(self._folder_value(tag, transforms))
|
tops.add(self._folder_value(tag, transforms))
|
||||||
|
|
||||||
|
# plus any other root-level folder (catches genres that lost their last
|
||||||
|
# movie), minus grouping roots and reserved (mirrors / Tipy dne / …)
|
||||||
|
if has_root_level and self.output_dir.exists():
|
||||||
|
reserved = set(reserved_subfolders or set())
|
||||||
|
for entry in self.output_dir.iterdir():
|
||||||
|
if (entry.is_dir() and entry.name not in grouping_roots
|
||||||
|
and entry.name not in reserved):
|
||||||
|
tops.add(entry.name)
|
||||||
return tops
|
return tops
|
||||||
|
|
||||||
def create_structure_for_files(
|
def create_structure_for_files(
|
||||||
@@ -258,6 +274,53 @@ class HardlinkManager:
|
|||||||
|
|
||||||
return success_count, fail_count
|
return success_count, fail_count
|
||||||
|
|
||||||
|
def generate_random_tips(
|
||||||
|
self,
|
||||||
|
files: List[File],
|
||||||
|
count: int = 10,
|
||||||
|
subfolder: str = "Tipy dne",
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> Tuple[int, int]:
|
||||||
|
"""(Re)fill ``subfolder`` with ``count`` random files as hardlinks.
|
||||||
|
|
||||||
|
The folder is emptied first so each run picks a fresh random selection.
|
||||||
|
``files`` should be the pool movies only (copy-as-is mirrors excluded).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (created_links, failed_links)
|
||||||
|
"""
|
||||||
|
base = self.output_dir / subfolder
|
||||||
|
|
||||||
|
if not dry_run and base.exists():
|
||||||
|
for child in base.iterdir():
|
||||||
|
if child.is_file():
|
||||||
|
try:
|
||||||
|
child.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
chosen = random.sample(files, min(count, len(files)))
|
||||||
|
created = 0
|
||||||
|
fail = 0
|
||||||
|
for file_obj in chosen:
|
||||||
|
target = base / file_obj.filename
|
||||||
|
try:
|
||||||
|
if not dry_run:
|
||||||
|
base.mkdir(parents=True, exist_ok=True)
|
||||||
|
if target.exists():
|
||||||
|
if self._is_same_file(file_obj.file_path, target):
|
||||||
|
created += 1
|
||||||
|
continue
|
||||||
|
target = self._get_unique_name(target)
|
||||||
|
os.link(file_obj.file_path, target)
|
||||||
|
self.created_links.append(target)
|
||||||
|
created += 1
|
||||||
|
except OSError as e:
|
||||||
|
self.errors.append((file_obj.file_path, str(e)))
|
||||||
|
fail += 1
|
||||||
|
|
||||||
|
return created, fail
|
||||||
|
|
||||||
def _is_same_file(self, path1: Path, path2: Path) -> bool:
|
def _is_same_file(self, path1: Path, path2: Path) -> bool:
|
||||||
"""Check if two paths point to the same file (same inode)."""
|
"""Check if two paths point to the same file (same inode)."""
|
||||||
try:
|
try:
|
||||||
@@ -359,6 +422,7 @@ class HardlinkManager:
|
|||||||
category_roots: Optional[Dict[str, str]] = None,
|
category_roots: Optional[Dict[str, str]] = None,
|
||||||
category_transforms: Optional[Dict[str, str]] = None,
|
category_transforms: Optional[Dict[str, str]] = None,
|
||||||
category_filename_templates: Optional[Dict[str, str]] = None,
|
category_filename_templates: Optional[Dict[str, str]] = None,
|
||||||
|
reserved_subfolders: Optional[Set[str]] = None,
|
||||||
) -> List[Tuple[Path, Path]]:
|
) -> List[Tuple[Path, Path]]:
|
||||||
"""
|
"""
|
||||||
Find hardlinks in the output directory that no longer match file tags.
|
Find hardlinks in the output directory that no longer match file tags.
|
||||||
@@ -411,7 +475,8 @@ class HardlinkManager:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Scan only the tag-tree's own top-level folders (skip copy-as-is mirrors)
|
# Scan only the tag-tree's own top-level folders (skip copy-as-is mirrors)
|
||||||
top_dirs = self._managed_top_dirs(files, roots, category_transforms)
|
top_dirs = self._managed_top_dirs(
|
||||||
|
files, roots, category_transforms, reserved_subfolders)
|
||||||
for top in self.output_dir.iterdir():
|
for top in self.output_dir.iterdir():
|
||||||
if not top.is_dir():
|
if not top.is_dir():
|
||||||
continue
|
continue
|
||||||
@@ -441,6 +506,7 @@ class HardlinkManager:
|
|||||||
category_roots: Optional[Dict[str, str]] = None,
|
category_roots: Optional[Dict[str, str]] = None,
|
||||||
category_transforms: Optional[Dict[str, str]] = None,
|
category_transforms: Optional[Dict[str, str]] = None,
|
||||||
category_filename_templates: Optional[Dict[str, str]] = None,
|
category_filename_templates: Optional[Dict[str, str]] = None,
|
||||||
|
reserved_subfolders: Optional[Set[str]] = None,
|
||||||
) -> Tuple[int, List[Path]]:
|
) -> Tuple[int, List[Path]]:
|
||||||
"""
|
"""
|
||||||
Remove hardlinks that no longer match file tags.
|
Remove hardlinks that no longer match file tags.
|
||||||
@@ -453,13 +519,15 @@ class HardlinkManager:
|
|||||||
``categories`` when given).
|
``categories`` when given).
|
||||||
category_transforms: Optional category → transform-name map for folders.
|
category_transforms: Optional category → transform-name map for folders.
|
||||||
category_filename_templates: Optional category → hardlink-name template.
|
category_filename_templates: Optional category → hardlink-name template.
|
||||||
|
reserved_subfolders: Output top-level folders to never touch (mirrors,
|
||||||
|
"Tipy dne", …).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (removed_count, list_of_removed_paths)
|
Tuple of (removed_count, list_of_removed_paths)
|
||||||
"""
|
"""
|
||||||
obsolete = self.find_obsolete_links(
|
obsolete = self.find_obsolete_links(
|
||||||
files, categories, category_roots, category_transforms,
|
files, categories, category_roots, category_transforms,
|
||||||
category_filename_templates)
|
category_filename_templates, reserved_subfolders)
|
||||||
removed_paths = []
|
removed_paths = []
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
@@ -485,6 +553,7 @@ class HardlinkManager:
|
|||||||
category_roots: Optional[Dict[str, str]] = None,
|
category_roots: Optional[Dict[str, str]] = None,
|
||||||
category_transforms: Optional[Dict[str, str]] = None,
|
category_transforms: Optional[Dict[str, str]] = None,
|
||||||
category_filename_templates: Optional[Dict[str, str]] = None,
|
category_filename_templates: Optional[Dict[str, str]] = None,
|
||||||
|
reserved_subfolders: Optional[Set[str]] = None,
|
||||||
) -> Tuple[int, int, int, int]:
|
) -> Tuple[int, int, int, int]:
|
||||||
"""
|
"""
|
||||||
Synchronize hardlink structure with current file tags.
|
Synchronize hardlink structure with current file tags.
|
||||||
@@ -501,6 +570,8 @@ class HardlinkManager:
|
|||||||
``categories`` when given).
|
``categories`` when given).
|
||||||
category_transforms: Optional category → transform-name map for folders.
|
category_transforms: Optional category → transform-name map for folders.
|
||||||
category_filename_templates: Optional category → hardlink-name template.
|
category_filename_templates: Optional category → hardlink-name template.
|
||||||
|
reserved_subfolders: Output top-level folders to never touch (mirrors,
|
||||||
|
"Tipy dne", …).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (created, create_failed, removed, remove_failed)
|
Tuple of (created, create_failed, removed, remove_failed)
|
||||||
@@ -508,12 +579,12 @@ class HardlinkManager:
|
|||||||
# First find how many obsolete links there are
|
# First find how many obsolete links there are
|
||||||
obsolete_count = len(self.find_obsolete_links(
|
obsolete_count = len(self.find_obsolete_links(
|
||||||
files, categories, category_roots, category_transforms,
|
files, categories, category_roots, category_transforms,
|
||||||
category_filename_templates))
|
category_filename_templates, reserved_subfolders))
|
||||||
|
|
||||||
# Remove obsolete links
|
# Remove obsolete links
|
||||||
removed, removed_paths = self.remove_obsolete_links(
|
removed, removed_paths = self.remove_obsolete_links(
|
||||||
files, categories, dry_run, category_roots, category_transforms,
|
files, categories, dry_run, category_roots, category_transforms,
|
||||||
category_filename_templates
|
category_filename_templates, reserved_subfolders
|
||||||
)
|
)
|
||||||
remove_failed = obsolete_count - removed if not dry_run else 0
|
remove_failed = obsolete_count - removed if not dry_run else 0
|
||||||
|
|
||||||
|
|||||||
+11
-4
@@ -957,12 +957,14 @@ class QtApp(QMainWindow):
|
|||||||
QMessageBox.information(self, "Filmotéka", "Pool je prázdný.")
|
QMessageBox.information(self, "Filmotéka", "Pool je prázdný.")
|
||||||
return
|
return
|
||||||
manager = HardlinkManager(out)
|
manager = HardlinkManager(out)
|
||||||
|
reserved = set(self.filehandler.copyasis_folders) | {"Tipy dne"}
|
||||||
created, create_fail, removed, remove_fail = manager.sync_structure(
|
created, create_fail, removed, remove_fail = manager.sync_structure(
|
||||||
files,
|
files,
|
||||||
category_roots=self.filehandler.filmoteka_category_roots(),
|
category_roots=self.filehandler.filmoteka_category_roots(),
|
||||||
category_transforms=self.filehandler.filmoteka_category_transforms(),
|
category_transforms=self.filehandler.filmoteka_category_transforms(),
|
||||||
category_filename_templates=(
|
category_filename_templates=(
|
||||||
self.filehandler.filmoteka_category_filename_templates()),
|
self.filehandler.filmoteka_category_filename_templates()),
|
||||||
|
reserved_subfolders=reserved,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Copy-as-is folders (e.g. Seriály): mirror each 1:1 (hardlinked)
|
# Copy-as-is folders (e.g. Seriály): mirror each 1:1 (hardlinked)
|
||||||
@@ -976,17 +978,22 @@ class QtApp(QMainWindow):
|
|||||||
mirrored += m_created
|
mirrored += m_created
|
||||||
mirror_fail += m_failed
|
mirror_fail += m_failed
|
||||||
|
|
||||||
|
# "Tipy dne": 10 random pool movies (copy-as-is folders excluded)
|
||||||
|
tips, tips_fail = manager.generate_random_tips(files, count=10)
|
||||||
|
|
||||||
msg = (
|
msg = (
|
||||||
f"Filmy — vytvořeno: {created}, odebráno zastaralých: {removed}\n"
|
f"Filmy — vytvořeno: {created}, odebráno zastaralých: {removed}\n"
|
||||||
f"Copy-as-is — zrcadleno: {mirrored}"
|
f"Copy-as-is — zrcadleno: {mirrored}\n"
|
||||||
|
f"Tipy dne — náhodně: {tips}"
|
||||||
)
|
)
|
||||||
if create_fail or remove_fail or mirror_fail:
|
if create_fail or remove_fail or mirror_fail or tips_fail:
|
||||||
msg += f"\nSelhalo: {create_fail + remove_fail + mirror_fail}"
|
msg += f"\nSelhalo: {create_fail + remove_fail + mirror_fail + tips_fail}"
|
||||||
QMessageBox.warning(self, "Filmotéka dokončena s chybami", msg)
|
QMessageBox.warning(self, "Filmotéka dokončena s chybami", msg)
|
||||||
else:
|
else:
|
||||||
QMessageBox.information(self, "Filmotéka vygenerována", msg)
|
QMessageBox.information(self, "Filmotéka vygenerována", msg)
|
||||||
self.status.showMessage(
|
self.status.showMessage(
|
||||||
f"Filmotéka: filmy +{created}/-{removed}, copy-as-is +{mirrored}", 5000
|
f"Filmotéka: filmy +{created}/-{removed}, copy-as-is +{mirrored}, "
|
||||||
|
f"tipy +{tips}", 5000
|
||||||
)
|
)
|
||||||
|
|
||||||
def edit_copyasis_folders(self) -> None:
|
def edit_copyasis_folders(self) -> None:
|
||||||
|
|||||||
@@ -136,11 +136,30 @@ class TestHardlinkManager:
|
|||||||
mirror_link = mirror / "file1.txt"
|
mirror_link = mirror / "file1.txt"
|
||||||
os.link(temp_source_dir / "file1.txt", mirror_link)
|
os.link(temp_source_dir / "file1.txt", mirror_link)
|
||||||
|
|
||||||
manager.sync_structure(files_with_tags, category_roots=roots)
|
manager.sync_structure(
|
||||||
|
files_with_tags, category_roots=roots,
|
||||||
|
reserved_subfolders={"Seriály"})
|
||||||
|
|
||||||
# The mirror (not a managed tag folder) is left alone
|
# The mirror (reserved folder) is left alone
|
||||||
assert mirror_link.exists()
|
assert mirror_link.exists()
|
||||||
|
|
||||||
|
def test_sync_removes_root_genre_folder_when_last_movie_drops_tag(
|
||||||
|
self, temp_source_dir, temp_output_dir, tag_manager
|
||||||
|
):
|
||||||
|
"""A genre folder at the output root is cleaned when no movie has it."""
|
||||||
|
f = File(temp_source_dir / "file1.txt", tag_manager)
|
||||||
|
f.tags.clear()
|
||||||
|
f.add_tag(Tag("žánr", "Akční"))
|
||||||
|
roots = {"žánr": ""}
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
manager.sync_structure([f], category_roots=roots)
|
||||||
|
assert (temp_output_dir / "Akční").is_dir()
|
||||||
|
|
||||||
|
# last movie with the tag loses it → folder must go
|
||||||
|
f.tags.clear()
|
||||||
|
manager.sync_structure([f], category_roots=roots)
|
||||||
|
assert not (temp_output_dir / "Akční").exists()
|
||||||
|
|
||||||
def test_category_transform_groups_folder_by_band(
|
def test_category_transform_groups_folder_by_band(
|
||||||
self, temp_source_dir, temp_output_dir, tag_manager
|
self, temp_source_dir, temp_output_dir, tag_manager
|
||||||
):
|
):
|
||||||
@@ -202,6 +221,40 @@ class TestHardlinkManager:
|
|||||||
assert [p.name for p in folder.iterdir()] == ["1962 - Dr. No.txt"]
|
assert [p.name for p in folder.iterdir()] == ["1962 - Dr. No.txt"]
|
||||||
assert removed == 0 # nothing treated as obsolete on the second run
|
assert removed == 0 # nothing treated as obsolete on the second run
|
||||||
|
|
||||||
|
def test_generate_random_tips_creates_hardlinks(
|
||||||
|
self, files_with_tags, temp_output_dir
|
||||||
|
):
|
||||||
|
"""Tips folder gets `count` random pool files as hardlinks."""
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
created, fail = manager.generate_random_tips(files_with_tags, count=2)
|
||||||
|
|
||||||
|
tips = temp_output_dir / "Tipy dne"
|
||||||
|
assert created == 2
|
||||||
|
assert fail == 0
|
||||||
|
links = sorted(p.name for p in tips.iterdir())
|
||||||
|
assert len(links) == 2
|
||||||
|
# each is a hardlink to one of the source files
|
||||||
|
srcs = {f.filename for f in files_with_tags}
|
||||||
|
assert set(links) <= srcs
|
||||||
|
|
||||||
|
def test_generate_random_tips_caps_at_available(
|
||||||
|
self, files_with_tags, temp_output_dir
|
||||||
|
):
|
||||||
|
"""Asking for more than available yields all of them, no error."""
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
created, _ = manager.generate_random_tips(files_with_tags, count=99)
|
||||||
|
assert created == len(files_with_tags)
|
||||||
|
|
||||||
|
def test_generate_random_tips_refreshes_each_run(
|
||||||
|
self, files_with_tags, temp_output_dir
|
||||||
|
):
|
||||||
|
"""A second run replaces the previous selection (folder is emptied)."""
|
||||||
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
manager.generate_random_tips(files_with_tags, count=1)
|
||||||
|
manager.generate_random_tips(files_with_tags, count=1)
|
||||||
|
tips = temp_output_dir / "Tipy dne"
|
||||||
|
assert len(list(tips.iterdir())) == 1 # not accumulated to 2
|
||||||
|
|
||||||
def test_dry_run(self, files_with_tags, temp_output_dir):
|
def test_dry_run(self, files_with_tags, temp_output_dir):
|
||||||
"""Test dry run (bez skutečného vytváření)"""
|
"""Test dry run (bez skutečného vytváření)"""
|
||||||
manager = HardlinkManager(temp_output_dir)
|
manager = HardlinkManager(temp_output_dir)
|
||||||
|
|||||||
Reference in New Issue
Block a user