diff --git a/.gitignore b/.gitignore index 1de41f1..ef3d79d 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ reference/*.json reference/*.log reference/*.md reference/*.txt +.claude diff --git a/AGENTS.md b/AGENTS.md index 838b24d..02fcb64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,10 @@ Use the unified `plexadm` CLI for new Plex automation. Keep mutation behavior co Do not add new top-level one-off scripts when a `plexadm` subcommand or helper module can cover the behavior. +## Running Scripts + +Always run `scripts/mass_process.sh` in the background (e.g. `bash scripts/mass_process.sh &> /tmp/mass_process.log &`). It takes several minutes and should not block the terminal. + ## Task Tracking Tasks, bugs, and follow-ups for this repository are filed in Odoo under the project **Plex Management** (`project.project` id `6`). diff --git a/bin/plexadm b/bin/plexadm index 768d5ea..d73acdf 100755 --- a/bin/plexadm +++ b/bin/plexadm @@ -2,6 +2,9 @@ import sys from pathlib import Path +sys.stdout.reconfigure(line_buffering=True) # type: ignore[union-attr] +sys.stderr.reconfigure(line_buffering=True) # type: ignore[union-attr] + repo_root = Path(__file__).resolve().parents[1] venv_site = next(iter((repo_root / ".venv" / "lib").glob("python*/site-packages")), None) if (repo_root / ".venv").exists() else None diff --git a/plexadm/cli.py b/plexadm/cli.py index 08c7857..717fbbb 100644 --- a/plexadm/cli.py +++ b/plexadm/cli.py @@ -14,6 +14,8 @@ from plexadm.filters import and_filter, in_collection, not_in_collection, rated, title_contains, unrated, writer_any from plexadm.plex import PlexContext, add_items, collection_titles, has_collection, reload_if_partial, remove_items from plexadm.progress import progress_prefix +from plexadm.stash_reconcile import reconcile as stash_reconcile +from plexadm.stash_sync_tags import sync_tags as stash_sync_tags from plexadm.writers import missing_title_writers, read_writer_file, writers_from_title NO_STUDIO_COLLECTION = "00A: NO STUDIO" @@ -200,6 +202,21 @@ def add_duration_collection(args: argparse.Namespace) -> int: return 0 +def add_orgy_collection(args: argparse.Namespace) -> int: + ctx = build_context(args) + collection = ctx.collection(args.collection) + results = ctx.search(filters=not_in_collection(collection.title), reload=True) + matches = [] + for video in results: + writers = getattr(video, "writers", None) or [] + if len(writers) >= args.min_writers: + print(warn(f"'{video.title}' needs to be added to '{collection.title}'")) + matches.append(video) + added = add_items(collection, matches) + print(info(f"{added} videos added to '{collection.title}'.")) + return 0 + + def add_vertical_collection(args: argparse.Namespace) -> int: ctx = build_context(args) collection = ctx.collection(args.collection) @@ -754,6 +771,7 @@ def build_parser() -> argparse.ArgumentParser: _build_smart_collection_commands(sub) _build_tools_commands(sub) _build_top_command(sub) + _build_stash_commands(sub) return parser @@ -1126,6 +1144,23 @@ def _build_collection_commands(sub: Any) -> None: ) set_func(add_short, add_duration_collection) + add_orgy = _make_sub( + collection_sub, + "add-orgy", + help="Add videos with 4+ writers to COLLECTION.", + description="Add every video with at least --min-writers writers to COLLECTION.", + epilog="Example:\n plexadm collection add-orgy '01: Category: Orgy'", + ) + add_orgy.add_argument("collection", metavar="COLLECTION", help="Target collection name.") + add_orgy.add_argument( + "--min-writers", + type=int, + default=4, + metavar="N", + help="Minimum number of writers to qualify (default: 4).", + ) + set_func(add_orgy, add_orgy_collection) + add_vertical = _make_sub( collection_sub, "add-vertical", @@ -1521,6 +1556,112 @@ def _build_top_command(sub: Any) -> None: set_func(top, print_top) +def _build_stash_commands(sub: Any) -> None: + stash_parser = _make_sub( + sub, + "stash", + help="Commands that sync Plex metadata into a Stash library.", + description=( + "Commands that read from Plex and write metadata into a Stash instance.\n" + "The Stash endpoint is read from the config file (stashEndpoint key)\n" + "or overridden with --stash-endpoint." + ), + epilog="Example:\n plexadm stash reconcile --limit 25", + ) + stash_sub = _add_subparsers(stash_parser, dest="stash_command", title="stash subcommands") + + reconcile_parser = _make_sub( + stash_sub, + "reconcile", + help="Backfill Stash scenes with Plex metadata (title, performers, studio, tags, rating).", + description=( + "Reads every Plex item in the configured library section, finds the\n" + "corresponding Stash scene(s) by file path, and writes Plex metadata\n" + "(title, writers→performers, studio, collections→tags, director,\n" + "rating, play count) to each matched Stash scene.\n" + "\n" + "Only Plex collections prefixed '01: ' are mapped to Stash tags;\n" + "the prefix is stripped (e.g. '01: Category: Blowjob' → 'Category: Blowjob').\n" + "\n" + "Outputs a per-scene change log and exports a CSV listing scenes that\n" + "still need enrichment (matched with no Plex data, or Stash scenes with\n" + "no Plex match at all).\n" + "\n" + "Writes are applied immediately. Use --limit for a test run against a\n" + "small subset before pointing this at the full library." + ), + epilog=( + "Examples:\n" + " plexadm stash reconcile --limit 25\n" + " plexadm stash reconcile --log-level INFO\n" + " plexadm stash reconcile --csv-output scope.csv" + ), + ) + reconcile_parser.add_argument( + "--limit", + metavar="N", + type=int, + help="Stop after processing N Plex items (useful for test runs before a full run).", + ) + reconcile_parser.add_argument( + "--path", + metavar="PREFIX", + help="Only process Plex items whose file path starts with PREFIX (e.g. /data/NSFW Scenes/00 Rin).", + ) + reconcile_parser.add_argument( + "--log-level", + metavar="LEVEL", + default="WARNING", + dest="log_level", + help="Python logging level: DEBUG, INFO, WARNING (default), ERROR.", + ) + reconcile_parser.add_argument( + "--stash-endpoint", + metavar="URL", + dest="stash_endpoint", + default=None, + help="Override the Stash base URL from config (e.g. http://localhost:9999).", + ) + reconcile_parser.add_argument( + "--csv-output", + metavar="PATH", + default="stash_scope.csv", + dest="csv_output", + help="Path for the scope CSV export (default: stash_scope.csv).", + ) + set_func(reconcile_parser, stash_reconcile) + + sync_tags_parser = _make_sub( + stash_sub, + "sync-tags", + help="Sync manual Plex collection membership as Stash tags.", + description=( + "Walks every non-smart Plex collection (excluding BROKEN, CORRUPT,\n" + "and auto-managed prefixes like '01: Category:', '02: Studio:', etc.)\n" + "and tags the corresponding Stash scenes with the derived tag name\n" + "(leading sort prefix stripped, e.g. '00A: FAVORITES' → 'FAVORITES').\n" + "\n" + "Safe to re-run: existing tags are preserved and duplicates are skipped." + ), + epilog="Example:\n plexadm stash sync-tags", + ) + sync_tags_parser.add_argument( + "--log-level", + metavar="LEVEL", + default="WARNING", + dest="log_level", + help="Python logging level: DEBUG, INFO, WARNING (default), ERROR.", + ) + sync_tags_parser.add_argument( + "--stash-endpoint", + metavar="URL", + dest="stash_endpoint", + default=None, + help="Override the Stash base URL from config.", + ) + set_func(sync_tags_parser, stash_sync_tags) + + def main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) diff --git a/plexadm/config.py b/plexadm/config.py index 3d5a466..fb2eea6 100644 --- a/plexadm/config.py +++ b/plexadm/config.py @@ -17,6 +17,7 @@ class PlexConfig: token: str section_name: str section_id: str | None = None + stash_endpoint: str | None = None @property def base_url(self) -> str: @@ -48,4 +49,5 @@ def load_config(path: str | Path | None = None) -> PlexConfig: token=section["plexToken"], section_name=section["plexSectionName"], section_id=section.get("plexSection"), + stash_endpoint=section.get("stashEndpoint"), ) diff --git a/plexadm/stash.py b/plexadm/stash.py new file mode 100644 index 0000000..f437b5e --- /dev/null +++ b/plexadm/stash.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +import logging +from typing import Any + +import requests + +log = logging.getLogger(__name__) + +PAGE_SIZE = 200 + +_FIND_SCENES = """ +query FindScenes($page: Int!, $per_page: Int!) { + findScenes(filter: { page: $page, per_page: $per_page }) { + count + scenes { + id + title + rating100 + director + files { path } + studio { id name } + performers { id name } + tags { id name } + } + } +} +""" + +_FIND_PERFORMER = """ +query FindPerformer($name: String!) { + findPerformers( + performer_filter: { name: { value: $name, modifier: EQUALS } } + filter: { per_page: 1 } + ) { performers { id } } +} +""" + +_CREATE_PERFORMER = """ +mutation CreatePerformer($name: String!) { + performerCreate(input: { name: $name }) { id } +} +""" + +_FIND_STUDIO = """ +query FindStudio($name: String!) { + findStudios( + studio_filter: { name: { value: $name, modifier: EQUALS } } + filter: { per_page: 1 } + ) { studios { id } } +} +""" + +_CREATE_STUDIO = """ +mutation CreateStudio($name: String!) { + studioCreate(input: { name: $name }) { id } +} +""" + +_FIND_TAG = """ +query FindTag($name: String!) { + findTags( + tag_filter: { name: { value: $name, modifier: EQUALS } } + filter: { per_page: 1 } + ) { tags { id } } +} +""" + +_CREATE_TAG = """ +mutation CreateTag($name: String!) { + tagCreate(input: { name: $name }) { id } +} +""" + +_UPDATE_SCENE = """ +mutation UpdateScene($input: SceneUpdateInput!) { + sceneUpdate(input: $input) { id } +} +""" + +_MERGE_SCENES = """ +mutation MergeScenes($input: SceneMergeInput!) { + sceneMerge(input: $input) { id } +} +""" + +_RESET_PLAY_COUNT = """ +mutation ResetPlayCount($id: ID!) { + sceneResetPlayCount(id: $id) +} +""" + +_ADD_PLAY = """ +mutation AddPlay($id: ID!, $times: [Timestamp!]) { + sceneAddPlay(id: $id, times: $times) { count } +} +""" + + +class StashClient: + def __init__(self, endpoint: str) -> None: + self.endpoint = endpoint.rstrip("/") + "/graphql" + self._session = requests.Session() + self._session.headers["Content-Type"] = "application/json" + self._performer_cache: dict[str, str] = {} + self._studio_cache: dict[str, str] = {} + self._tag_cache: dict[str, str] = {} + + def _gql(self, query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]: + payload: dict[str, Any] = {"query": query} + if variables: + payload["variables"] = variables + resp = self._session.post(self.endpoint, json=payload, timeout=30) + resp.raise_for_status() + result = resp.json() + if "errors" in result: + raise RuntimeError(f"GraphQL errors: {result['errors']}") + return result["data"] # type: ignore[return-value] + + def all_scenes(self) -> dict[str, dict[str, Any]]: + """Return a path -> scene dict for every scene in Stash.""" + index: dict[str, dict[str, Any]] = {} + seen_ids: set[str] = set() + page = 1 + while True: + data = self._gql(_FIND_SCENES, {"page": page, "per_page": PAGE_SIZE}) + result = data["findScenes"] + total = result["count"] + scenes = result["scenes"] + if not scenes: + break + for scene in scenes: + seen_ids.add(scene["id"]) + for f in scene.get("files") or []: + index[f["path"]] = scene + log.info("Loaded Stash page %d (%d/%d scenes indexed)", page, len(seen_ids), total) + if len(seen_ids) >= total: + break + page += 1 + return index + + def find_or_create_performer(self, name: str) -> str: + if name in self._performer_cache: + return self._performer_cache[name] + data = self._gql(_FIND_PERFORMER, {"name": name}) + performers = data["findPerformers"]["performers"] + if performers: + pid = performers[0]["id"] + else: + pid = self._gql(_CREATE_PERFORMER, {"name": name})["performerCreate"]["id"] + log.info("Created performer: %s (id=%s)", name, pid) + self._performer_cache[name] = pid + return pid + + def find_or_create_studio(self, name: str) -> str: + if name in self._studio_cache: + return self._studio_cache[name] + data = self._gql(_FIND_STUDIO, {"name": name}) + studios = data["findStudios"]["studios"] + if studios: + sid = studios[0]["id"] + else: + sid = self._gql(_CREATE_STUDIO, {"name": name})["studioCreate"]["id"] + log.info("Created studio: %s (id=%s)", name, sid) + self._studio_cache[name] = sid + return sid + + def find_or_create_tag(self, name: str) -> str: + if name in self._tag_cache: + return self._tag_cache[name] + data = self._gql(_FIND_TAG, {"name": name}) + tags = data["findTags"]["tags"] + if tags: + tid = tags[0]["id"] + else: + tid = self._gql(_CREATE_TAG, {"name": name})["tagCreate"]["id"] + log.info("Created tag: %s (id=%s)", name, tid) + self._tag_cache[name] = tid + return tid + + def update_scene(self, scene_id: str, fields: dict[str, Any]) -> None: + update = dict(fields) + update["id"] = scene_id + self._gql(_UPDATE_SCENE, {"input": update}) + + def sync_play_history(self, scene_id: str, timestamps: list[str]) -> None: + """Replace Stash play history with the given ISO8601 timestamps from Plex.""" + self._gql(_RESET_PLAY_COUNT, {"id": scene_id}) + if timestamps: + self._gql(_ADD_PLAY, {"id": scene_id, "times": timestamps}) + + def merge_scenes(self, source_ids: list[str], destination_id: str, fields: dict[str, Any]) -> None: + """Merge source scenes into destination, applying fields to the result. Sources are deleted.""" + values = dict(fields) + values["id"] = destination_id + self._gql( + _MERGE_SCENES, + { + "input": { + "source": source_ids, + "destination": destination_id, + "play_history": True, + "values": values, + } + }, + ) diff --git a/plexadm/stash_reconcile.py b/plexadm/stash_reconcile.py new file mode 100644 index 0000000..5d26a2b --- /dev/null +++ b/plexadm/stash_reconcile.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import base64 +import csv +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import requests + +from plexadm.config import PlexConfig, load_config +from plexadm.console import info, ok, warn +from plexadm.plex import PlexContext, reload_if_partial +from plexadm.stash import StashClient + +log = logging.getLogger(__name__) + +_TAG_PREFIX = "01: " + + +def _collection_to_tag(title: str) -> str | None: + """Return the tag name for a '01: ...' Plex collection title, or None to skip.""" + if title.startswith(_TAG_PREFIX): + return title[len(_TAG_PREFIX) :] + return None + + +def _has_usable_metadata(video: Any) -> bool: + if getattr(video, "studio", None): + return True + if getattr(video, "writers", None): + return True + if getattr(video, "userRating", None): + return True + if getattr(video, "directors", None): + return True + collections = getattr(video, "collections", None) or [] + return any(_collection_to_tag(str(c)) for c in collections) + + +def _fetch_plex_cover(video: Any, cfg: PlexConfig) -> str | None: + thumb = getattr(video, "thumb", None) + if not thumb: + return None + url = f"{cfg.base_url}{thumb}?X-Plex-Token={cfg.token}" + try: + resp = requests.get(url, timeout=15) + resp.raise_for_status() + content_type = resp.headers.get("Content-Type", "image/jpeg") + data = base64.b64encode(resp.content).decode("ascii") + return f"data:{content_type};base64,{data}" + except Exception as exc: + log.warning("Failed to fetch Plex cover for '%s': %s", video.title, exc) + return None + + +@dataclass +class _Stats: + updated: int = 0 + matched_no_data: list[dict[str, Any]] = field(default_factory=list) + + +def reconcile(args: Any) -> int: + log_level = getattr(args, "log_level", "WARNING").upper() + logging.basicConfig(level=getattr(logging, log_level, logging.WARNING), format="%(levelname)s: %(message)s") + + cfg = load_config(args.config) + endpoint = getattr(args, "stash_endpoint", None) or cfg.stash_endpoint + if not endpoint: + raise ValueError( + "No Stash endpoint configured. Add stashEndpoint to your config file or pass --stash-endpoint." + ) + + print(info("Connecting to Stash and building scene index...")) + stash = StashClient(endpoint) + stash_index = stash.all_scenes() # path -> scene dict + stash_scenes_by_id: dict[str, dict[str, Any]] = {s["id"]: s for s in stash_index.values()} + print(info(f"Stash: {len(stash_scenes_by_id)} scenes across {len(stash_index)} paths")) + + plex_ctx = PlexContext(cfg) + limit: int | None = getattr(args, "limit", None) + path_filter: str | None = getattr(args, "path", None) + stats = _Stats() + matched_stash_ids: set[str] = set() + processed = 0 + + print(info("Scanning Plex library...")) + for video in plex_ctx.all_videos(): + if limit is not None and processed >= limit: + break + + reload_if_partial(video) + locations = list(getattr(video, "locations", None) or []) + if not locations: + continue + + if path_filter and not any(loc.startswith(path_filter) for loc in locations): + continue + + matched: dict[str, dict[str, Any]] = {} + for path in locations: + scene = stash_index.get(path) + if scene: + matched[scene["id"]] = scene + + processed += 1 + + if not matched: + log.debug("UNMATCHED Plex item: %s", video.title) + continue + + for scene_id in matched: + matched_stash_ids.add(scene_id) + + if not _has_usable_metadata(video): + log.debug("MATCHED-NO-DATA: %s", video.title) + for scene_id, scene in matched.items(): + stats.matched_no_data.append({"id": scene_id, "paths": [f["path"] for f in (scene.get("files") or [])]}) + continue + + # Resolve entity IDs once — shared across all matched scenes for this Plex item + studio = getattr(video, "studio", None) + studio_id = stash.find_or_create_studio(studio) if studio else None + + writers = [str(w) for w in (getattr(video, "writers", None) or [])] + new_performer_ids = [stash.find_or_create_performer(w) for w in writers] + + directors = [str(d) for d in (getattr(video, "directors", None) or [])] + + raw_collections = getattr(video, "collections", None) or [] + tag_names = [t for c in raw_collections if (t := _collection_to_tag(str(c)))] + new_tag_ids = [stash.find_or_create_tag(t) for t in tag_names] + + user_rating = getattr(video, "userRating", None) + view_count = getattr(video, "viewCount", None) or 0 + cover_image = _fetch_plex_cover(video, cfg) + play_timestamps: list[str] = [] + if view_count: + play_timestamps = [ + h.viewedAt.strftime("%Y-%m-%dT%H:%M:%SZ") for h in video.history() if getattr(h, "viewedAt", None) + ] + + # Build update merging existing performer/tag IDs across all matched scenes + update: dict[str, Any] = {"title": video.title} + + if studio_id: + update["studio_id"] = studio_id + + if directors: + update["director"] = ", ".join(directors) + + if user_rating: + update["rating100"] = round(float(user_rating) * 10) + + if cover_image: + update["cover_image"] = cover_image + + if new_performer_ids: + existing_performer_ids = {p["id"] for s in matched.values() for p in (s.get("performers") or [])} + update["performer_ids"] = list(existing_performer_ids | set(new_performer_ids)) + + if new_tag_ids: + existing_tag_ids = {t["id"] for s in matched.values() for t in (s.get("tags") or [])} + update["tag_ids"] = list(existing_tag_ids | set(new_tag_ids)) + + if len(matched) > 1: + scene_ids = sorted(matched.keys(), key=lambda x: int(x)) + destination_id, source_ids = scene_ids[0], scene_ids[1:] + log.info("MERGE %s → scene %s ('%s')", source_ids, destination_id, video.title) + stash.merge_scenes(source_ids, destination_id, update) + if play_timestamps: + stash.sync_play_history(destination_id, play_timestamps) + print(warn(f" Merged {len(source_ids) + 1} scenes → {destination_id}: {video.title}")) + else: + scene_id = next(iter(matched)) + log.info("UPDATE scene %s ('%s'): %s", scene_id, video.title, sorted(update.keys())) + stash.update_scene(scene_id, update) + if play_timestamps: + stash.sync_play_history(scene_id, play_timestamps) + print(warn(f" Updated scene {scene_id}: {video.title}")) + + stats.updated += 1 + + # Stash scenes with no matching Plex item — only meaningful without --limit + unmatched_stash: list[dict[str, Any]] = [] + if limit is None: + for scene_id, scene in stash_scenes_by_id.items(): + if scene_id not in matched_stash_ids: + unmatched_stash.append(scene) + + print(ok(f"Scenes updated: {stats.updated}")) + print(info(f"Matched but no usable Plex metadata: {len(stats.matched_no_data)}")) + if limit is not None: + print(info("(Stash scenes with no Plex match: skipped — run without --limit for complete scope)")) + else: + print(info(f"Stash scenes with no Plex match: {len(unmatched_stash)}")) + + csv_path = Path(getattr(args, "csv_output", "stash_scope.csv")) + with csv_path.open("w", newline="", encoding="utf-8") as fh: + writer = csv.writer(fh) + writer.writerow(["bucket", "stash_scene_id", "path"]) + for entry in stats.matched_no_data: + for p in entry["paths"]: + writer.writerow(["matched_no_data", entry["id"], p]) + for scene in unmatched_stash: + for f in scene.get("files") or []: + writer.writerow(["unmatched", scene["id"], f["path"]]) + + print(info(f"Scope exported to {csv_path}")) + return 0 diff --git a/plexadm/stash_sync_tags.py b/plexadm/stash_sync_tags.py new file mode 100644 index 0000000..204f181 --- /dev/null +++ b/plexadm/stash_sync_tags.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import logging +import re +from typing import Any + +from plexadm.config import load_config +from plexadm.console import info, ok +from plexadm.plex import PlexContext, reload_if_partial +from plexadm.stash import StashClient + +log = logging.getLogger(__name__) + +_SKIP_COLLECTIONS = {"00A: BROKEN", "00D: Review: CORRUPT?"} +_SKIP_PREFIXES = ("01: Category:", "01: Hair:", "02: Studio:", "03: Star:", "99:") +_PREFIX_RE = re.compile(r"^\d{2}[A-Z]?: ") + + +def _tag_name(title: str) -> str: + return _PREFIX_RE.sub("", title, count=1) + + +def sync_tags(args: Any) -> int: + log_level = getattr(args, "log_level", "WARNING").upper() + logging.basicConfig(level=getattr(logging, log_level, logging.WARNING), format="%(levelname)s: %(message)s") + + cfg = load_config(args.config) + endpoint = getattr(args, "stash_endpoint", None) or cfg.stash_endpoint + if not endpoint: + raise ValueError("No Stash endpoint configured. Add stashEndpoint to config or pass --stash-endpoint.") + + print(info("Connecting to Stash and building scene index...")) + stash = StashClient(endpoint) + stash_index = stash.all_scenes() + unique_scenes = len({s["id"] for s in stash_index.values()}) + print(info(f"Stash: {unique_scenes} scenes across {len(stash_index)} paths")) + + plex_ctx = PlexContext(cfg) + total_tagged = 0 + + for collection in plex_ctx.section.collections(): + reload_if_partial(collection) + title = str(collection.title) + + if collection.smart: + continue + if title in _SKIP_COLLECTIONS: + log.debug("Skipping excluded collection: %s", title) + continue + if any(title.startswith(p) for p in _SKIP_PREFIXES): + log.debug("Skipping prefix-excluded collection: %s", title) + continue + + tag_name = _tag_name(title) + tag_id = stash.find_or_create_tag(tag_name) + count = 0 + + for item in collection.items(): + reload_if_partial(item) + locations = list(getattr(item, "locations", None) or []) + updated_ids: set[str] = set() + for path in locations: + scene = stash_index.get(path) + if not scene: + log.debug("No Stash scene for path: %s", path) + continue + scene_id = scene["id"] + if scene_id in updated_ids: + continue + existing_tag_ids = {t["id"] for t in (scene.get("tags") or [])} + if tag_id in existing_tag_ids: + updated_ids.add(scene_id) + continue + stash.update_scene(scene_id, {"tag_ids": list(existing_tag_ids | {tag_id})}) + scene.setdefault("tags", []).append({"id": tag_id, "name": tag_name}) + updated_ids.add(scene_id) + count += 1 + + total_tagged += count + print(info(f" '{tag_name}': {count} scenes tagged")) + + print(ok(f"Total scenes tagged: {total_tagged}")) + return 0 diff --git a/requirements.txt b/requirements.txt index 560c481..bb37ecc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ plexapi +requests pydantic-settings>=2.14.1 diff --git a/scripts/copy_collections.sh b/scripts/copy_collections.sh index eeb86b8..2aa6a30 100755 --- a/scripts/copy_collections.sh +++ b/scripts/copy_collections.sh @@ -10,6 +10,7 @@ date; time "$PLEXADM" collection copy "01: Category: Completely Throated" '01: C date; time "$PLEXADM" collection copy '01: Category: Deepthroat' '01: Category: Blowjob' date; time "$PLEXADM" collection copy '01: Category: Double Anal' '01: Category: Double Penetration' date; time "$PLEXADM" collection copy '01: Category: FFF+' '01: Category: Lesbian' +date; time "$PLEXADM" collection copy '01: Category: FFFM' '01: Category: Orgy' date; time "$PLEXADM" collection copy '01: Category: 69' '01: Category: Pussy Eating' date; time "$PLEXADM" collection copy '01: Category: Butt Plug' '01: Category: Sex Toys' date; time "$PLEXADM" collection copy '01: Category: Strap On' '01: Category: Sex Toys' diff --git a/scripts/mass_process.sh b/scripts/mass_process.sh index 863bc24..7302869 100755 --- a/scripts/mass_process.sh +++ b/scripts/mass_process.sh @@ -19,6 +19,9 @@ date; time "${SCRIPT_DIR}/copy_collections.sh" # Add short videos to a special collection date; time "$PLEXADM" collection add-short '01: Category: Short Videos' +# Add videos with 4+ performers to orgy +date; time "$PLEXADM" collection add-orgy '01: Category: Orgy' + # Update the contents of the unrated collection date; time "${SCRIPT_DIR}/set_unrated.sh" diff --git a/scripts/set_tags_based_on_writers.sh b/scripts/set_tags_based_on_writers.sh index 85eeca2..c85bda3 100755 --- a/scripts/set_tags_based_on_writers.sh +++ b/scripts/set_tags_based_on_writers.sh @@ -10,6 +10,7 @@ REFERENCE_DIR=${PLEXADM_REFERENCE_DIR:-/usr/local/share/plexadm/reference} "$PLEXADM" collection add-writers '01: Category: Solo' "${REFERENCE_DIR}/writers_solo.txt" "$PLEXADM" collection add-writers '01: Category: Asian' "${REFERENCE_DIR}/writers_asian.txt" "$PLEXADM" collection add-writers '01: Category: Pierced Nipples' "${REFERENCE_DIR}/writers_pierced_nipples.txt" +"$PLEXADM" collection add-writers '01: Category: Pierced Vagina' "${REFERENCE_DIR}/writers_pierced_vagina.txt" "$PLEXADM" collection add-writers '01: Category: Pierced Tongue' "${REFERENCE_DIR}/writers_pierced_tongue.txt" "$PLEXADM" collection add-writers '01: Category: Porcelain Skin' "${REFERENCE_DIR}/writers_porcelain.txt" "$PLEXADM" collection add-writers '01: Category: Trans MTF' "${REFERENCE_DIR}/writers_trans_mtf.txt" diff --git a/tests/test_stash_reconcile.py b/tests/test_stash_reconcile.py new file mode 100644 index 0000000..5b9d507 --- /dev/null +++ b/tests/test_stash_reconcile.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from plexadm.stash_reconcile import _collection_to_tag, _has_usable_metadata + + +def _mock_video(**kwargs: object) -> SimpleNamespace: + defaults: dict[str, object] = { + "title": "Test Scene", + "studio": None, + "writers": [], + "directors": [], + "collections": [], + "userRating": None, + "viewCount": None, + "locations": ["/data/NSFW Scenes/Test/test.mp4"], + } + defaults.update(kwargs) + return SimpleNamespace(**defaults) + + +class TestCollectionToTag: + def test_strips_01_prefix_from_category(self) -> None: + assert _collection_to_tag("01: Category: Blowjob") == "Category: Blowjob" + + def test_strips_01_prefix_from_hair(self) -> None: + assert _collection_to_tag("01: Hair: Red") == "Hair: Red" + + def test_returns_none_for_02_prefix(self) -> None: + assert _collection_to_tag("02: Studio: Brazzers") is None + + def test_returns_none_for_03_prefix(self) -> None: + assert _collection_to_tag("03: Star: Alice") is None + + def test_returns_none_for_99_prefix(self) -> None: + assert _collection_to_tag("99: LOCKED") is None + + def test_returns_none_for_unprefixed(self) -> None: + assert _collection_to_tag("Some Collection") is None + + +class TestHasUsableMetadata: + def test_true_when_studio(self) -> None: + assert _has_usable_metadata(_mock_video(studio="Brazzers")) is True + + def test_true_when_writers(self) -> None: + assert _has_usable_metadata(_mock_video(writers=["Alice"])) is True + + def test_true_when_rating(self) -> None: + assert _has_usable_metadata(_mock_video(userRating=8.5)) is True + + def test_true_when_directors(self) -> None: + assert _has_usable_metadata(_mock_video(directors=["Bob"])) is True + + def test_true_when_01_collection(self) -> None: + assert _has_usable_metadata(_mock_video(collections=["01: Category: Solo"])) is True + + def test_false_when_all_empty(self) -> None: + assert _has_usable_metadata(_mock_video()) is False + + def test_false_when_only_non_01_collections(self) -> None: + assert _has_usable_metadata(_mock_video(collections=["02: Studio: Brazzers", "99: LOCKED"])) is False + + def test_false_when_rating_is_zero(self) -> None: + assert _has_usable_metadata(_mock_video(userRating=0.0)) is False + + +class TestStashClientCaching: + def test_find_or_create_performer_uses_cache(self) -> None: + from plexadm.stash import StashClient + + client = StashClient("http://localhost:9999") + client._performer_cache["Alice"] = "42" + with patch.object(client, "_gql") as mock_gql: + result = client.find_or_create_performer("Alice") + mock_gql.assert_not_called() + assert result == "42" + + def test_find_or_create_studio_uses_cache(self) -> None: + from plexadm.stash import StashClient + + client = StashClient("http://localhost:9999") + client._studio_cache["Brazzers"] = "99" + with patch.object(client, "_gql") as mock_gql: + result = client.find_or_create_studio("Brazzers") + mock_gql.assert_not_called() + assert result == "99" + + def test_find_or_create_tag_uses_cache(self) -> None: + from plexadm.stash import StashClient + + client = StashClient("http://localhost:9999") + client._tag_cache["Category: Solo"] = "7" + with patch.object(client, "_gql") as mock_gql: + result = client.find_or_create_tag("Category: Solo") + mock_gql.assert_not_called() + assert result == "7" + + def test_find_or_create_performer_populates_cache_on_miss(self) -> None: + from plexadm.stash import StashClient + + client = StashClient("http://localhost:9999") + client._gql = MagicMock( # type: ignore[method-assign] + return_value={"findPerformers": {"performers": [{"id": "55"}]}} + ) + result = client.find_or_create_performer("Bob") + assert result == "55" + assert client._performer_cache["Bob"] == "55" + + def test_find_or_create_performer_creates_when_missing(self) -> None: + from plexadm.stash import StashClient + + client = StashClient("http://localhost:9999") + responses = [ + {"findPerformers": {"performers": []}}, + {"performerCreate": {"id": "88"}}, + ] + client._gql = MagicMock(side_effect=responses) # type: ignore[method-assign] + result = client.find_or_create_performer("New Star") + assert result == "88" + assert client._performer_cache["New Star"] == "88" + + +class TestRatingConversion: + def test_8_becomes_80(self) -> None: + assert round(8.0 * 10) == 80 + + def test_7_5_becomes_75(self) -> None: + assert round(7.5 * 10) == 75 + + def test_10_becomes_100(self) -> None: + assert round(10.0 * 10) == 100 + + def test_1_becomes_10(self) -> None: + assert round(1.0 * 10) == 10