From 23c78d53334c284343adc8831e83abba4c24051a Mon Sep 17 00:00:00 2001 From: Gykes Date: Sun, 10 Nov 2024 13:36:03 -0800 Subject: [PATCH] Adding NFOSceneParser --- plugins/nfoSceneParser/README.md | 200 ++++++++++ plugins/nfoSceneParser/abstractParser.py | 32 ++ plugins/nfoSceneParser/config.py | 70 ++++ plugins/nfoSceneParser/log.py | 52 +++ plugins/nfoSceneParser/nfoParser.py | 191 +++++++++ plugins/nfoSceneParser/nfoSceneParser.py | 447 ++++++++++++++++++++++ plugins/nfoSceneParser/nfoSceneParser.yml | 19 + plugins/nfoSceneParser/reParser.py | 136 +++++++ plugins/nfoSceneParser/stashInterface.py | 423 ++++++++++++++++++++ 9 files changed, 1570 insertions(+) create mode 100644 plugins/nfoSceneParser/README.md create mode 100644 plugins/nfoSceneParser/abstractParser.py create mode 100644 plugins/nfoSceneParser/config.py create mode 100644 plugins/nfoSceneParser/log.py create mode 100644 plugins/nfoSceneParser/nfoParser.py create mode 100644 plugins/nfoSceneParser/nfoSceneParser.py create mode 100644 plugins/nfoSceneParser/nfoSceneParser.yml create mode 100644 plugins/nfoSceneParser/reParser.py create mode 100644 plugins/nfoSceneParser/stashInterface.py diff --git a/plugins/nfoSceneParser/README.md b/plugins/nfoSceneParser/README.md new file mode 100644 index 00000000..52086e8b --- /dev/null +++ b/plugins/nfoSceneParser/README.md @@ -0,0 +1,200 @@ +# nfoFileParser +Automatically and transparently populates your scenes data (during scan) based on either: +- NFO files +- patterns in your file names, configured through regex. + +Ideal to "initial load" a large set of new files (or even a whole library) and not "start from scratch" in stash! *...provided you have nfo files and/or consistent patterns in your file names of course...* + +# Installation + +- If you have not done it yet, install the required python module: `pip install requests` (or `pip3 install requests` depending on your python setup). Note: if you are running stash as a Docker container, this is not needed as it is already installed. +- Download the whole folder `nfoFileParser` +- Place it in your `plugins` folder (where the `config.yml` is) +- Reload plugins (`Settings > Plugins > Reload`) +- `nfoFileParser` appears +- Scan some new files... + +The plug-in is automatically triggered on each new scene creation (typically during scan) + +# Usage + +Imports scene details from nfo files or from regex patterns. + +Every time a new scene is created, it will: + - look for a matching NFO file and parse it into the scene data (studio, performers, date, name,...) + - if no NFO are found, it uses a regular expression (regex) to parse your filename for patterns. This fallback works only if you have consistent & identifiable patterns in (some of) your file names. Read carefully below how to configure regex to match your file name pattern(s). + - If none of the above is found: it will do nothing ;-) + +NFO complies with KODI's 'Movie Template' specification (https://kodi.wiki/view/NFO_files/Movies). Note: although initially created by KODI, this NFO structure has become a de-facto standard among video management software and is used today far beyond its KODI roots to store the video files's metadata. + +regex patterns complies with Python's regular expressions. A good tool to write/test regex is: https://regex101.com/ + +## config.py + +nfoFileParser works without any config edits. If you want more control, have a look at `config.py`, where you can change some default behavior. + +## Reload task + +nfoFileParser typically processes everything during scan. If you want to reload the nfo/regex at a later time, you can execute a "reload" task. + +It works in three steps: configure, select & run: +- Configure: edit `reload_tags` in the plugin's `config.py` file. Set the name to an existing tag in your stash. It is used as the 'marker" tag by the plugin to identify which scenes to reload. +- Select: add the configured tag to your scenes to "mark" them. +- Run: execute the "reload" task: stash's settings -> "Tasks" -> Scroll down to "plugin tasks" / nfoSceneParser (at the bottom) -> "Reload tagged scenes" button + +A reload essentially merges the new file data with the existing scene data, giving priority to the nfo/regex content. More specifically: +- For single-value fields, overrides what is already set if another content is found +- For single-value fields, keeps what is already set if nothing is found +- For multi-value fields, adds to existing values. + +Note: The marker tag is removed from the reloaded scenes (unless it is present in the nfo or regex) => no need to remove it manually... + +# NFO files organization + +## Scene NFO + +The plugin automatically looks for .nfo files (and optionally thumbnail images) in the same directory and with the same filename as your video file (for instance for a `BestSceneEver.mp4` video, it will look for a corresponding `BestSceneEver.nfo` file). Through config, you can specify an alternate location for your NFO files. + +## Folder NFO + +If a "folder.nfo" file is present, it will be loaded and applied as default for all scene files within the same folder. A scene specific nfo will override the default from the folder.nfo. + +So if you have a folder.nfo, with a studio, or an performer, they will automatically be applied to all scenes in the folder, even if there is no specific nfo for each scene file. + +folder.nfo are also used to create movies. See below for details on movie support. + +## Image support + +Thumbnails images are supported either from `` tags in the NFO itself (link to image URL) or alternatively will be loaded from the local disk (following KODI's naming convention for movie artwork). The plug-in will use the first image it finds among: +- A local image with the `-landscape` or `-poster` or no suffix (example: `BestSceneEver-landscape.jpg` or `BestSceneEver.jpg`). If you have movie info in your nfo, two images will be loaded for front & back posters (example: `folder-poster.jpg` and `folder-poster1.jpg`) +- A download of the `` tags url (if there are multiple thumb fields in the nfo, uses the one with the "landscape" attribute has priority over "poster"). + +## Movie support + +Movies are automatically found and created in stash from the nfo files. The plugin supports two different alternatives: +- folder.nfo if present contains data valid for all scene files in the same directory. That is the very definition of a movie. The ``tag designate the movie name, with all other relevant tags used to create the movie with all its details (`<date>`, `<studio>`, `<director>`, front/back image from `<thumb>`) +- Inside the scene nfo, through the `<set>` tag that designate the group/set to which multiple scenes belong. + +example for `folder.nfo`: +```xml +<movie> + <title>My Movie Title + You have to see it to believe it... + https://front_cover.jpg + https://back_cover.jpg + Best studio ever + Georges Lucas + +``` + +example for `BestSceneEver.nfo`: + +```xml + + BestSceneEver + Scene of the century + https://scene_cover.jpg + Best studio ever + + My Movie Title + 2 + + +``` + +## url support + +The nfo spec does not officially support `` tags, but given the importance for stash, it is supported by nfoSceneParser as an nfo extension and will be correctly recognized and updated to your scenes and movies. + +## Mapping between stash data and nfo fields + +stash scene fields | nfo movie fields +------------------------ | --------------------- +`title` | `title` or `originaltitle` or `sorttitle` +`details` | `plot` or `outline` or `tagline` +`studio` | `studio` +`performers` | `actor.name` (sorted by `actor.order`) +`movie` | `set.name` (sorted by `set.index`) or `title` from folder.nfo +`rating` | `userrating` or `ratings.rating` +`tags` | `tag` or `genre` +`date` | `premiered` or `year` +`url` | `url` +`director` (for movies) | `director` (only for folder.nfo) +`cover image` (or `front`/`back`for movies) | `thumb` (or local file) +`id` | `uniqueid` + +Note: `uniqueid` support is only for existing stash scenes that were exported before (to they are updated "in place" with their existing id) + + + + +# Regex pattern matching + +Regular expressions work by recognizing patterns in your files. It is a fallback if no NFO can be found. + +You need to configure a custom pattern (like studio, actors or movie) that is specific to your naming convention. So a little bit of configuration is needed to "tell the plugin" how to recognize the right patterns. + +patterns use the "regular expression" standard to match patterns (regex). + +## Regex configuration - not your typical plugin + +A consistent and uniform naming convention across a whole media library is extremely unlikely. Therefore, nfoSceneParser supports not one, but multiple `nfoSceneParser.json` regex config files. They are placed alongside your media files, directly into the library. + +A configuration file applies to all files and subdirectories below it. + +Config files can be nested inside the library's directories tree. In this case, the deepest and most specific config is always used. + +`nfoSceneParser.json` configs are searched and loaded when the plug-in is executed. They can be added, modified or removed while stash is running, without the need to "reload" the plugins. + +## File structure `nfoSceneParser.json` + +Configuration files consist of one regex and some attributes. + +| Name | Required | Description | +| ------------- | -------- | -------------------------------------- | +| regex | true | A regular expression (regex). Regex can be easily learned, previewed and tested via [https://regex101.com/](https://regex101.com/)| +| splitter | false | Used to further split the matched "performers" or 'tags" text into an array of strings (the most frequent use case being a list of actors or tags). For instance, if performers matches to `"Megan Rain, London Keyes"`, a splitter of `", "` will separate the two performers from the matched string | +| scope | false | possible values are "path" or "filename". Whether the regex is applied to the scene's whole path or just the filename. Defaults to "path" | + +## Example `nfoSceneParser.json` + +Let's assume the following directory and file structure: + +`/movies/movie series/Movie Name 17/Studio name - first1 last1, first2 last2 - Scene title - 2017-12-31.mp4` + +A common naming convention is used for all files under "movie series" directory => the `nfoSceneParser.json` file is placed in `/movies/movie series`. + +We want to identify the following patterns: +- The deepest folder is the `movie` +- The file name has different sections, all separated by the same `' - '` delimiter. We can therefore use this to delimit and match the `studio`, the `performers` and the scene's `title`. +- The `date` is matched automatically. There is nothing to configure for that. + +`nfoSceneParser.json` (remember: to be placed in your library) +```json +{ + "regex": "^.*[/\\\\](?P.*?)[/\\](?P.*?) - (?P.*?) - (?P.*?)[-]+.*\\.mp4$", + "splitter": ", ", + "scope": "path" +} +``` + +A quick look at the regex: +- `[/\\]` Matches slash & backslash, making it work on Windows and Unix path alike (Macos, Linux,...) +- Capturing groups like `(?P<movie>.*?)` have name that must match the supported nfoFileParser attributes (see below) + +Note: in json, every `\` is escaped to `\\` => `\\` in json is actually `\` in the regex. If you are unfamiliar, look for a json regex formatter online and paste your regex there to get the properly "escaped" string you need to use in the config file. + +## Supported regex capturing group names + +The following can be used in your regex capturing group names: +- title +- date +- performers +- tags +- studio +- rating +- movie +- director +- index (mapped to stash scene_index - only relevant for movies) + +Note: if `date` is not specified, the plug-in attempts to detect the date anywhere in the file name. diff --git a/plugins/nfoSceneParser/abstractParser.py b/plugins/nfoSceneParser/abstractParser.py new file mode 100644 index 00000000..350c53cf --- /dev/null +++ b/plugins/nfoSceneParser/abstractParser.py @@ -0,0 +1,32 @@ +import os + + +class AbstractParser: + + empty_default = { "actors": [], "tags": [] } + + # Max number if images to process (2 for front/back cover in movies). + _image_Max = 2 + + def __init__(self): + self._defaults = [self.empty_default] + + def _find_in_parents(self, start_path, searched_file): + parent_dir = os.path.dirname(start_path) + file = os.path.join(start_path, searched_file) + if os.path.exists(file): + return file + elif start_path != parent_dir: + # Not found => recurse via parent + return self._find_in_parents(parent_dir, searched_file) + + def _get_default(self, key, source=None): + for default in self._defaults: + # Source filter: skip default if it is not of the specified source + if source and default.get("source") != source: + continue + if default.get(key) is not None: + return default.get(key) + + def parse(self): + pass diff --git a/plugins/nfoSceneParser/config.py b/plugins/nfoSceneParser/config.py new file mode 100644 index 00000000..db01319a --- /dev/null +++ b/plugins/nfoSceneParser/config.py @@ -0,0 +1,70 @@ +# If dry is True, will do a trial run with no permanent changes. +# Look in the log file for what would have been updated... +dry_mode = False + +# nfo file location & naming. +# Possible options: +# - "with files": with the video files: Follows NFO standard naming: https://kodi.wiki/view/NFO_files/Movies +# - "...": a specific directory you mention. In this case, the nfo names will match your stash scene ids. +# if you set the above to "with files", it'll force filename anyway, to match the filename. +# ! Not yet implemented. Currently, only "with files" is supported +nfo_location = "with files" + +# If True, will never update already "organized" scenes. +skip_organized = True + +# If True, will set the scene to "organized" on update from nfo file. +set_organized_nfo = True + +# Set of fields that must be set from the nfo (i.e. "not be empty") for the scene to be marked organized. +# Possible values: "performers", "studio", "tags", "movie", "title", "details", "date", +# "rating", "urls" and "cover_image" +set_organized_only_if = ["title", "performers", "details", "date", "studio", "tags", "cover_image"] + +# Blacklist: array of nfo fields that will not be loaded into the scene. +# Possible values: "performers", "studio", "tags", "movie", "title", "details", "date", +# "rating", "urls" and "cover_image", "director" +# Note: "tags" is a special case: if blacklisted, new tags will not be created, but existing tags will be mapped. +blacklist = ["rating"] + +# List of tags that will never be created or set to the scene. +# Example: blacklisted_tags = ["HD", "Now in HD"] +blacklisted_tags = ["HD", "4K", "Now in HD", "1080p Video", "4k Video"] + +# Name of the tag used as 'marker" by the plugin to identify which scenes to reload. +# Empty string or None disables the reload feature +reload_tag = "_NFO_RELOAD" + +# Creates missing entities in stash's database (or not) +create_missing_performers = True +create_missing_studios = True +create_missing_tags = True +create_missing_movies = True + +############################################################################### +# Do not change config below unless you are absolutely sure of what you do... +############################################################################### + +# Wether to Looks for existing entries also in aliases +search_performer_aliases = True +search_studio_aliases = True + +levenshtein_distance_tolerance = 2 + +# "Single names" means performers with only one word as name like "Anna" or "Siri". +# If true, single names aliases will be ignored: +# => only the "main" performer name determines if a performer exists or is created. +# Only relevant if search_performer_aliases is True. +ignore_single_name_performer_aliases = True + +# If the above is set to true, it can be overruled for some allowed (whitelisted) names +single_name_whitelist = ["MJFresh", "JMac", "Mazee"] + +############################################################################### +# Reminder: if no matching NFO file can be found for the scene, a fallback +# "regular expressions" parsing is supported. +# +# ! regex patterns are defined in their own config files. +# +# See README.md for details +############################################################################### diff --git a/plugins/nfoSceneParser/log.py b/plugins/nfoSceneParser/log.py new file mode 100644 index 00000000..57ad79e4 --- /dev/null +++ b/plugins/nfoSceneParser/log.py @@ -0,0 +1,52 @@ +import sys + + +# Log messages sent from a plugin instance are transmitted via stderr and are +# encoded with a prefix consisting of special character SOH, then the log +# level (one of t, d, i, w, e, or p - corresponding to trace, debug, info, +# warning, error and progress levels respectively), then special character +# STX. +# +# The LogTrace, LogDebug, LogInfo, LogWarning, and LogError methods, and their equivalent +# formatted methods are intended for use by plugin instances to transmit log +# messages. The LogProgress method is also intended for sending progress data. +# + +def __prefix(level_char): + start_level_char = b'\x01' + end_level_char = b'\x02' + + ret = start_level_char + level_char + end_level_char + return ret.decode() + + +def __log(level_char, s): + if level_char == "": + return + + print(__prefix(level_char) + s + "\n", file=sys.stderr, flush=True) + + +def LogTrace(s): + __log(b't', s) + + +def LogDebug(s): + __log(b'd', s) + + +def LogInfo(s): + __log(b'i', s) + + +def LogWarning(s): + __log(b'w', s) + + +def LogError(s): + __log(b'e', s) + + +def LogProgress(p): + progress = min(max(0, p), 1) + __log(b'p', str(progress)) diff --git a/plugins/nfoSceneParser/nfoParser.py b/plugins/nfoSceneParser/nfoParser.py new file mode 100644 index 00000000..adb7c0c5 --- /dev/null +++ b/plugins/nfoSceneParser/nfoParser.py @@ -0,0 +1,191 @@ +import os +import xml.etree.ElementTree as xml +import base64 +import glob +import re +import requests +import config +import log +from abstractParser import AbstractParser + +class NfoParser(AbstractParser): + + def __init__(self, scene_path, defaults=None, folder_mode=False): + super().__init__() + if defaults: + self._defaults = defaults + # Finds nfo file + self._nfo_file = None + if config.nfo_location.lower() == "with files": + if folder_mode: + # look in current dir & parents for a folder.nfo file... + dir_path = os.path.dirname(scene_path) + self._nfo_file = self._find_in_parents(dir_path, "folder.nfo") + else: + self._nfo_file = os.path.splitext(scene_path)[0] + ".nfo" + # else: + # TODO: support dedicated dir instead of "with files" (compatibility with nfo exporters) + self._nfo_root = None + + def __match_image_files(self, files, pattern): + thumb_images = [] + index = 0 + for file in files: + if index >= self._image_Max: + break + if pattern.match(file): + with open(file, "rb") as img: + img_bytes = img.read() + thumb_images.append(img_bytes) + index += 1 + return thumb_images + + def __extract_nfo_uniqueid(self): + return self._nfo_root.findtext("uniqueid") + + def __read_cover_image_file(self): + path_no_ext = os.path.splitext(self._nfo_file)[0] + file_no_ext = os.path.split(path_no_ext)[1] + # First look for images for a given scene name... + files = sorted(glob.glob(f"{glob.escape(path_no_ext)}*.*")) + file_pattern = re.compile("^.*" + re.escape(file_no_ext) + \ + "(-landscape\\d{0,2}|-thumb\\d{0,2}|-poster\\d{0,2}|-cover\\d{0,2}|\\d{0,2})\\.(jpe?g|png|webp)$", re.I) + result = self.__match_image_files(files, file_pattern) + if result: + return result + # Not found? Look tor folder image... + path_dir = os.path.dirname(self._nfo_file) + folder_files = sorted(glob.glob(f"{glob.escape(path_dir)}{os.path.sep}*.*")) + folder_pattern = re.compile("^.*(landscape\\d{0,2}|thumb\\d{0,2}|poster\\d{0,2}|cover\\d{0,2})\\.(jpe?g|png|webp)$", re.I) + result = self.__match_image_files(folder_files, folder_pattern) + return result + + def ___find_thumb_urls(self, query): + result = [] + matches = self._nfo_root.findall(query) + for match in matches: + result.append(match.text) + return result + + def __download_cover_images(self): + # Prefer "landscape" images, then "poster", otherwise take any thumbnail image... + thumb_urls = self.___find_thumb_urls("thumb[@aspect='landscape']") \ + or self.___find_thumb_urls("thumb[@aspect='poster']") \ + or self.___find_thumb_urls("thumb") + # Ensure there are images and the count does not exceed the max allowed... + if len(thumb_urls) == 0: + return [] + del thumb_urls[self._image_Max:] + # Download images from url + thumb_images = [] + for thumb_url in thumb_urls: + img_bytes = None + try: + r = requests.get(thumb_url, timeout=10) + img_bytes = r.content + thumb_images.append(img_bytes) + except Exception as e: + log.LogDebug( + f"Failed to download the cover image from {thumb_url}: {repr(e)}") + return thumb_images + + def __extract_cover_images_b64(self): + if "cover_image" in config.blacklist: + return [] + file_images = [] + # Get image from disk (file), otherwise from <thumb> tag (url) + thumb_images = self.__read_cover_image_file() or self.__download_cover_images() + for thumb_image in thumb_images: + thumb_b64img = base64.b64encode(thumb_image) + if thumb_b64img: + file_images.append( + f"data:image/jpeg;base64,{thumb_b64img.decode('utf-8')}") + return file_images + + def __extract_nfo_rating(self): + user_rating = round(float(self._nfo_root.findtext("userrating") or 0)) + if user_rating > 0: + return user_rating + # <rating> is converted to a scale of 5 if needed + rating = None + rating_elem = self._nfo_root.find("ratings/rating") + if rating_elem is not None: + max_value = float(rating_elem.attrib["max"] or 1) + value = float(rating_elem.findtext("value") or 0) + # ratings on scale 100 (since stashapp v24) + rating = round(value / max_value * 100) + return rating + + def __extract_nfo_date(self): + # date either in <premiered> (full) or <year> (only the year) + year = self._nfo_root.findtext("year") + if year is not None: + year = f"{year}-01-01" + return self._nfo_root.findtext("premiered") or year + + def __extract_nfo_tags(self): + file_tags = [] + # from nfo <tag> + tags = self._nfo_root.findall("tag") + for tag in tags: + if tag.text: + file_tags.append(tag.text) + # from nfo <genre> + genres = self._nfo_root.findall("genre") + for genre in genres: + if genre.text: + file_tags.append(genre.text) + return list(set(file_tags)) + + def __extract_nfo_actors(self): + file_actors = [] + actors = self._nfo_root.findall("actor/name") + for actor in actors: + if actor.text: + file_actors.append(actor.text) + return file_actors + + def parse(self): + if not self._nfo_file or not os.path.exists(self._nfo_file): + return {} + log.LogDebug("Parsing '{}'".format(self._nfo_file)) + # Parse NFO xml content + try: + with open(self._nfo_file, mode="r", encoding="utf-8") as nfo: + # Tolerance: strip non-standard whitespaces/new lines + clean_nfo_content = nfo.read().strip() + # Tolerance: replace illegal " " + clean_nfo_content = clean_nfo_content.replace(" ", " ") + self._nfo_root = xml.fromstring(clean_nfo_content) + except Exception as e: + log.LogError( + f"Could not parse nfo '{self._nfo_file}': {repr(e)}") + return {} + # Extract data from XML tree. Spec: https://kodi.wiki/view/NFO_files/Movies + b64_images = self.__extract_cover_images_b64() + file_data = { + # TODO: supports stash uniqueid to match to existing scenes (compatibility with nfo exporter) + "file": self._nfo_file, + "source": "nfo", + "title": self._nfo_root.findtext("originaltitle") or self._nfo_root.findtext("title") \ + or self._nfo_root.findtext("sorttitle") or self._get_default("title", "re"), + "director": self._nfo_root.findtext("director") or self._get_default("director"), + "details": self._nfo_root.findtext("plot") or self._nfo_root.findtext("outline") \ + or self._nfo_root.findtext("tagline") or self._get_default("details"), + "studio": self._nfo_root.findtext("studio") or self._get_default("studio"), + "uniqueid": self.__extract_nfo_uniqueid(), + "date": self.__extract_nfo_date() or self._get_default("date"), + "actors": self.__extract_nfo_actors() or self._get_default("actors"), + # Tags are merged with defaults + "tags": list(set(self.__extract_nfo_tags() + self._get_default("tags"))), + "rating": self.__extract_nfo_rating() or self._get_default("rating"), + "cover_image": None if len(b64_images) < 1 else b64_images[0], + "other_image": None if len(b64_images) < 2 else b64_images[1], + # Below are NFO extensions or liberal tag interpretations (not part of the nfo spec) + "movie": self._nfo_root.findtext("set/name") or self._get_default("title", "nfo"), + "scene_index": self._nfo_root.findtext("set/index") or None, + # TODO: read multiple URL tags into array + "urls": None if not self._nfo_root.findtext("url") else [self._nfo_root.findtext("url")], + + } + return file_data diff --git a/plugins/nfoSceneParser/nfoSceneParser.py b/plugins/nfoSceneParser/nfoSceneParser.py new file mode 100644 index 00000000..c3f73446 --- /dev/null +++ b/plugins/nfoSceneParser/nfoSceneParser.py @@ -0,0 +1,447 @@ +from math import log10 +import sys +import json +import difflib +import config +import log +import re +from abstractParser import AbstractParser +from nfoParser import NfoParser +from reParser import RegExParser +from stashInterface import StashInterface + + +class NfoSceneParser: + + def __init__(self, stash): + self._stash: StashInterface = stash + self._scene_id: str = None + self._scene: dict = None + self._folder_data: dict = {} + self._file_data: dict = {} + self._reload_tag_id = None + + # For reload mode, checks & preload ids matching marker tag config + if self._stash.get_mode() == "reload" and config.reload_tag: + reload_tag_found = False + results = self._stash.gql_findTags(config.reload_tag) + for tag in results.get("tags"): + if tag["name"].lower() == config.reload_tag.lower(): + self._reload_tag_id = tag["id"] + reload_tag_found = True + break + if not reload_tag_found: + log.LogError( + f"Reload cancelled: '{config.reload_tag}' do not exist in stash.") + self._stash.exit_plugin("Reload task cancelled!") + + def __prepare(self, scene_id): + self._scene_id = scene_id + self._scene = self._stash.gql_findScene(self._scene_id) + self._folder_data = {} + self._file_data = {} + + # def __substitute_file_data(self): + # # Nothing to do if no config or actors... + # if not config.performers_substitutions or not self._file_data.get("actors"): + # return + # # Substitute performers names according to config + # index = 0 + # for actor in self._file_data.get("actors"): + # for subst in config.performers_substitutions: + # if subst[0].lower() in actor.lower(): + # self._file_data.get("actors")[index] = actor.replace( + # subst[0], subst[1]) + # break + # index += 1 + + # Parses data from files. Supports nfo & regex + def __parse(self): + if self._scene["organized"] and config.skip_organized: + log.LogInfo( + f"Skipping already organized scene id: {self._scene['id']}") + return + + # Parse folder nfo (used as default) + # TODO: Manage file path array. + folder_nfo_parser = NfoParser(self._scene["files"][0]["path"], None, True) + self._folder_data = folder_nfo_parser.parse() + + # Parse scene nfo (nfo & regex). + re_parser = RegExParser(self._scene["files"][0]["path"], [ + self._folder_data or AbstractParser.empty_default + ]) + re_file_data = re_parser.parse() + nfo_parser = NfoParser(self._scene["files"][0]["path"], [ + self._folder_data or AbstractParser.empty_default, + re_file_data or AbstractParser.empty_default + ]) + nfo_file_data = nfo_parser.parse() + + # nfo as preferred input. re as fallback + self._file_data = nfo_file_data or re_file_data + # self.__substitute_file_data() + return self._file_data + + def __strip_b64(self, data): + if data.get("cover_image"): + data["cover_image"] = "*** Base64 image removed for readability ***" + return json.dumps(data) + + # Updates the parsed data into stash db (and creates what is missing) + def __update(self): + # Must have found at least a "title" in the nfo or regex... + if not self._file_data: + log.LogDebug( + "Skipped or no matching NFO or RE found: nothing done...") + return + + # Retrieve/create performers, studios, movies,... + scene_data = self.__find_create_scene_data() + + if config.dry_mode: + log.LogInfo( + f"Dry mode. Would have updated scene based on: {self.__strip_b64(scene_data)}") + return scene_data + + # Update scene data from parsed info + updated_scene = self._stash.gql_updateScene(self._scene_id, scene_data) + if updated_scene is not None and updated_scene["id"] == str(self._scene_id): + log.LogInfo( + f"Successfully updated scene: {self._scene_id} using '{self._file_data['file']}'") + else: + log.LogError( + f"Error updating scene: {self._scene_id} based on: {self.__strip_b64(scene_data)}.") + return scene_data + + def __find_create_scene_data(self): + # Lookup and/or create satellite objects in stash database + file_performer_ids = [] + file_studio_id = None + file_movie_id = None + if "performers" not in config.blacklist: + file_performer_ids = self.__find_create_performers() + if "studio" not in config.blacklist: + file_studio_id = self.__find_create_studio() + if "movie" not in config.blacklist: + file_movie_id = self.__find_create_movie(file_studio_id) + # "tags" blacklist applied inside func (blacklist create, allow find): + file_tag_ids = self.__find_create_tags() + + # Existing scene satellite data + scene_studio_id = self._scene.get("studio").get( + "id") if self._scene.get("studio") else None + scene_performer_ids = list( + map(lambda p: p.get("id"), self._scene["performers"])) + scene_tag_ids = list(map(lambda t: t.get("id"), self._scene["tags"])) + # in "reload" mode, removes the reload marker tag as part of the scene update + if config.reload_tag and self._reload_tag_id: + scene_tag_ids.remove(self._reload_tag_id) + # Currently supports only one movie (the first one...) + scene_movie_id = scene_movie_index = None + if self._scene.get("movies"): + scene_movie_id = self._scene.get("movies")[0]["movie"]["id"] + scene_movie_index = self._scene.get("movies")[0]["scene_index"] + + # Merges file data with the existing scene data (priority to the nfo/regex content) + bl = config.blacklist + scene_data = { + "source": self._file_data["source"], + "title": (self._file_data["title"] or self._scene["title"] or None) if "title" not in bl else None, + "details": (self._file_data["details"] or self._scene["details"] or None) if "details" not in bl else None, + "date": (self._file_data["date"] or self._scene["date"] or None) if "date" not in bl else None, + "rating": (self._file_data["rating"] or self._scene["rating"] or None) if "rating" not in bl else None, + # TODO: scene URL is now an array + "urls": (self._file_data["urls"] or self._scene["urls"] or None) if "urls" not in bl else None, + "studio_id": file_studio_id or scene_studio_id or None, + "code": self._file_data["uniqueid"] if "uniqueid" in self._file_data else None, + "performer_ids": list(set(file_performer_ids + scene_performer_ids)), + "tag_ids": list(set(file_tag_ids + scene_tag_ids)), + "movie_id": file_movie_id or scene_movie_id or None, + "scene_index": self._file_data["scene_index"] or scene_movie_index or None, + "cover_image": (self._file_data["cover_image"] or None) if "image" not in bl else None, + } + return scene_data + + def levenshtein_distance(self, str1, str2, ): + counter = {"+": 0, "-": 0} + distance = 0 + for edit_code, *_ in difflib.ndiff(str1, str2): + if edit_code == " ": + distance += max(counter.values()) + counter = {"+": 0, "-": 0} + else: + counter[edit_code] += 1 + distance += max(counter.values()) + return distance + + def __is_matching(self, text1, text2, tolerance=False): + if not text1 or not text2: + return text1 == text2 + if tolerance: + distance = self.levenshtein_distance(text1.lower(), text2.lower()) + match = distance < (config.levenshtein_distance_tolerance * log10(len(text1))) + if match and distance: + log.LogDebug(f"Matched with distance {distance}: '{text1}' with '{text2}'") + return match + else: + return text1.lower() == text2.lower() + + def __find_create_performers(self): + performer_ids = [] + created_performers = [] + for actor in self._file_data["actors"]: + if not actor: + continue + performers = self._stash.gql_findPerformers(actor) + match_direct = False + match_alias = False + matching_id = None + matching_name = None + match_count = 0 + # 1st pass for direct name matches + for performer in performers["performers"]: + if self.__is_matching(actor, performer["name"]): + if not matching_id: + matching_id = performer["id"] + match_direct = True + match_count += 1 + # log.LogDebug( + # f"Direct '{actor}' performer search: matching_id: {matching_id}, match_count: {match_count}") + # 2nd pass for alias matches + if not matching_id and \ + config.search_performer_aliases and \ + (not config.ignore_single_name_performer_aliases or " " in actor or actor in config.single_name_whitelist): + for performer in performers["performers"]: + for alias in performer["alias_list"]: + if self.__is_matching(actor, alias): + if not matching_id: + matching_id = performer["id"] + matching_name = performer["name"] + match_alias = True + match_count += 1 + # log.LogDebug( + # f"Aliases '{actor}' performer search: matching_id: {matching_id}, matching_name: {matching_name}, match_count: {match_count}") + if not matching_id: + # Create a new performer when it does not exist + if not config.create_missing_performers or config.dry_mode: + log.LogInfo( + f"'{actor}' performer creation prevented by config") + else: + new_performer = self._stash.gql_performerCreate(actor) + created_performers.append(actor) + performer_ids.append(new_performer["id"]) + else: + performer_ids.append(matching_id) + log.LogDebug(f"Matched existing performer '{actor}' with \ + id {matching_id} name {matching_name or actor} \ + (direct: {match_direct}, alias: {match_alias}, match_count: {match_count})") + if match_count > 1: + log.LogInfo(f"Linked scene with title '{self._file_data['title']}' to existing \ + performer '{actor}' (id {matching_id}). Attention: {match_count} matches \ + were found. Check to de-duplicate your performers and their aliases...") + if created_performers: + log.LogInfo(f"Created missing performers '{created_performers}'") + return performer_ids + + def __find_create_studio(self) -> str: + if not self._file_data["studio"]: + return + studio_id = None + studios = self._stash.gql_findStudios(self._file_data["studio"]) + match_direct = False + match_alias = False + matching_id = None + match_count = 0 + # 1st pass for direct name matches + for studio in studios["studios"]: + if self.__is_matching(self._file_data["studio"], studio["name"]): + if not matching_id: + matching_id = studio["id"] + match_direct = True + match_count += 1 + # 2nd pass for alias matches + if not matching_id and config.search_studio_aliases: + for studio in studios["studios"]: + if studio["aliases"]: + for alias in studio["aliases"]: + if self.__is_matching(self._file_data["studio"], alias): + if not matching_id: + matching_id = studio["id"] + match_alias = True + match_count += 1 + # Create a new studio when it does not exist + if not matching_id: + if not config.create_missing_studios or config.dry_mode: + log.LogInfo( + f"'{self._file_data['studio']}' studio creation prevented by config") + else: + new_studio = self._stash.gql_studioCreate( + self._file_data["studio"]) + studio_id = new_studio["id"] + log.LogInfo( + f"Created missing studio '{self._file_data['studio']}' with id {new_studio['id']}") + else: + studio_id = matching_id + log.LogDebug(f"Matched existing studio '{self._file_data['studio']}' with id \ + {matching_id} (direct: {match_direct}, alias: {match_alias}, match_count: {match_count})") + if match_count > 1: + log.LogInfo(f"Linked scene with title '{self._file_data['title']}' to existing studio \ + '{self._file_data['studio']}' (id {matching_id}). \ + Attention: {match_count} matches were found. Check to de-duplicate...") + return studio_id + + def __find_create_tags(self): + tag_ids = [] + created_tags = [] + blacklisted_tags = [tag.lower() for tag in config.blacklisted_tags] + # find all stash tags + all_tags = self._stash.gql_findTags() + for file_tag in self._file_data["tags"]: + # skip empty or blacklisted tags + if not file_tag or file_tag.lower() in blacklisted_tags: + continue + match_direct = False + match_alias = False + matching_id = None + match_count = 0 + # 1st pass for direct name matches + for tag in all_tags["tags"]: + if self.__is_matching(file_tag, tag["name"], True): + if not matching_id: + matching_id = tag["id"] + match_direct = True + match_count += 1 + # 2nd pass for alias matches + if not matching_id and config.search_studio_aliases: + for tag in all_tags["tags"]: + if tag["aliases"]: + for alias in tag["aliases"]: + if self.__is_matching(file_tag, alias, True): + if not matching_id: + matching_id = tag["id"] + match_alias = True + match_count += 1 + # Create a new tag when it does not exist + if not matching_id: + if not config.create_missing_tags or config.dry_mode or "tags" in config.blacklist: + log.LogDebug( + f"'{file_tag}' tag creation prevented by config") + else: + new_tag = self._stash.gql_tagCreate(file_tag) + created_tags.append(file_tag) + tag_ids.append(new_tag["id"]) + else: + tag_ids.append(matching_id) + log.LogDebug( + f"Matched existing tag '{file_tag}' with id {matching_id} \ + (direct: {match_direct}, alias: {match_alias}, match_count: {match_count})") + if match_count > 1: + log.LogInfo(f"Linked scene with title '{self._file_data['title']}' to existing tag \ + '{file_tag}' (id {matching_id}). \ + Attention: {match_count} matches were found. Check to de-duplicate...") + if created_tags: + log.LogInfo(f"Created missing tags '{created_tags}'") + return tag_ids + + def __find_create_movie(self, studio_id): + if not self._file_data["movie"]: + return + movie_id = None + movies = self._stash.gql_findMovies(self._file_data["movie"]) + matching_id = None + # [ ] possible improvement: support movie aliases? + # Ensure direct name match + for movie in movies["movies"]: + if self.__is_matching(self._file_data["movie"], movie["name"]): + if not matching_id: + matching_id = movie["id"] + # Create a new movie when it does not exist + if not matching_id: + if not config.create_missing_movies or config.dry_mode: + log.LogInfo( + f"'{self._file_data['movie']}' movie creation prevented by config") + else: + new_movie = self._stash.gql_movieCreate( + self._file_data, studio_id, self._folder_data) + movie_id = new_movie["id"] + log.LogInfo( + f"Created missing movie '{self._file_data['movie']}' with id {new_movie['id']}") + else: + # [ ] Possible improvement: update existing movie with nfo data + movie_id = matching_id + log.LogDebug( + f"Matched existing movie '{self._file_data['movie']}' with id {matching_id}") + return movie_id + + def __process_scene(self, scene_id): + self.__prepare(scene_id) + file_data = self.__parse() + try: + scene_data = self.__update() + except Exception as e: + log.LogError( + f"Error updating stash for scene {scene_id}: {repr(e)}") + scene_data = None + return [file_data, scene_data] + + def __process_reload(self): + # Check if the required config was done + if not config.reload_tag: + log.LogInfo( + "Reload disabled: 'reload_tag' is empty in plugin's config.py") + return + # Find all scenes in stash with the reload marker tag + scenes = self._stash.gql_findScenes(self._reload_tag_id) + log.LogDebug( + f"Found {len(scenes['scenes'])} scenes with the reload_tag in stash") + scene_count = len(scenes["scenes"]) + if not scene_count: + log.LogInfo("No scenes found with the 'reload_tag' tag") + return + reload_count = 0 + progress = 0 + progress_step = 1 / scene_count + reload_tag = config.reload_tag.lower() + + # Reloads only scenes marked with configured tags + for scene in scenes["scenes"]: + for tag in scene.get("tags"): + if tag.get("name").lower() == reload_tag: + log.LogDebug( + f"Scene {scene['id']} is tagged to be reloaded.") + self.__process_scene(scene["id"]) + reload_count += 1 + break + progress += progress_step + log.LogProgress(progress) + + # Inform if nothing was done + if reload_count == 0: + log.LogInfo( + f"Scanned {scene_count} scenes. None had the '{config.reload_tag}' tag.") + + def process(self): + if self._stash.get_mode() == "normal": + return self.__process_scene(self._stash.get_scene_id()) + elif self._stash.get_mode() == "reload": + return self.__process_reload() + else: + raise Exception( + f"nfoSceneParser error: unsupported mode {self._stash.get_mode()}") + + +if __name__ == '__main__': + # Init + if len(sys.argv) > 1: + # Loads from argv for testing... + fragment = json.loads(sys.argv[1]) + else: + fragment = json.loads(sys.stdin.read()) + + # Start processing: parse file data and update scenes + # (+ create missing performer, tag, movie,...) + stash_interface = StashInterface(fragment) + nfoSceneParser = NfoSceneParser(stash_interface) + nfoSceneParser.process() + stash_interface.exit_plugin("Successful!") diff --git a/plugins/nfoSceneParser/nfoSceneParser.yml b/plugins/nfoSceneParser/nfoSceneParser.yml new file mode 100644 index 00000000..12aa636d --- /dev/null +++ b/plugins/nfoSceneParser/nfoSceneParser.yml @@ -0,0 +1,19 @@ +name: nfoSceneParser +description: Fills scene data from NFO or filename pattern +url: https://github.com/stashapp/CommunityScripts/tree/main/plugins/nfoSceneParser +version: 1.3.1 +exec: + - python + - "{pluginDir}/nfoSceneParser.py" +interface: raw +hooks: + - name: hook_nfoSceneParser + description: Fills scene data on creation + triggeredBy: + - Scene.Create.Post +tasks: + - name: 'Reload tagged scenes' + description: Reload all scenes that have specific "marker" tag (see plugin's config.py) + defaultArgs: + mode: reload +# Last Updated January 3, 2024 \ No newline at end of file diff --git a/plugins/nfoSceneParser/reParser.py b/plugins/nfoSceneParser/reParser.py new file mode 100644 index 00000000..e52bf536 --- /dev/null +++ b/plugins/nfoSceneParser/reParser.py @@ -0,0 +1,136 @@ +import os +import re +import json +from datetime import datetime +import log +from abstractParser import AbstractParser + + +class RegExParser(AbstractParser): + + def __init__(self, scene_path, defaults=None): + self._defaults = defaults or [self.empty_default] + self._scene_path = scene_path + self._re_config_file = self._find_in_parents( + os.path.dirname(scene_path), + "nfoSceneParser.json") + self._groups = {} + if self._re_config_file: + try: + # Config found => load it + with open(self._re_config_file, mode="r", encoding="utf-8") as f: + config = json.load(f) + # TODO: support stash patterns and build a regex out of it... + self._regex = config["regex"] + self._splitter = config.get("splitter") + self._scope = config.get("scope") + # Scope defaults to the full path. Change to filename if so configured + if self._scope is not None and self._scope.lower() == "filename": + self._name = os.path.split(self._scene_path)[1] + else: + self._name = self._scene_path + log.LogDebug(f"Using regex config file {self._re_config_file}") + except Exception as e: + log.LogInfo( + f"Could not load regex config file '{self._re_config_file}': {repr(e)}") + else: + log.LogDebug(f"No re config found for {self._scene_path}") + + def __format_date(self, re_findall, date_format): + date_text = "-".join(re_findall[0] if re_findall else ()) + date = datetime.strptime(date_text, date_format) if date_text else None + return date.isoformat()[:10] if date else None + + def __find_date(self, text): + if not text: + return + # For proper boundary detection in regex, switch _ to - + safe_text = text.replace("_", "-") + # Finds dates in various formats + re_yyyymmdd = re.findall( + r"(\b(?:19|20)\d\d)[- /.](\b1[012]|0[1-9])[- /.](\b3[01]|[12]\d|0[1-9])", safe_text) + re_ddmmyyyy = re.findall( + r"(\b3[01]|[12]\d|0[1-9])[- /.](\b1[012]|0[1-9])[- /.](\b(?:19|20)\d\d)", safe_text) + re_yymmdd = re.findall( + r"(\b\d\d)[- /.](\b1[012]|0[1-9])[- /.](\b3[01]|[12]\d|0[1-9])", safe_text) + re_ddmmyy = re.findall( + r"(\b3[01]|[12]\d|0[1-9])[- /.](\b1[012]|0[1-9])[- /.](\b\d\d)", safe_text) + re_yyyymm = re.findall( + r"\b((?:19|20)\d\d)[- /.](\b1[012]|0[1-9])", safe_text) + re_mmyyyy = re.findall( + r"(\b1[012]|0[1-9])[- /.](\b(?:19|20)\d\d)", safe_text) + re_yyyy = re.findall(r"(\b(?:19|20)\d\d)", safe_text) + # Builds iso formatted dates + yyyymmdd = self.__format_date(re_yyyymmdd, "%Y-%m-%d") + ddmmyyyy = self.__format_date(re_ddmmyyyy, "%d-%m-%Y") + yymmdd = self.__format_date(re_yymmdd, "%y-%m-%d") + ddmmyy = self.__format_date(re_ddmmyy, "%d-%m-%y") + yyyymm = self.__format_date(re_yyyymm, "%Y-%m") + mmyyyy = self.__format_date(re_mmyyyy, "%m-%Y") + yyyy = datetime.strptime(re_yyyy[0], "%Y").isoformat()[ + :10] if re_yyyy else None + # return in order of preference + return yyyymmdd or ddmmyyyy or yymmdd or ddmmyy or yyyymm or mmyyyy or yyyy + + def __extract_re_date(self): + date_raw = self._groups.get("date") or self._name + file_date = self.__find_date(date_raw) + return file_date + + def __extract_re_actors(self): + file_actors = [] + if self._groups.get("performers"): + if self._splitter is not None: + # re split supports multiple delimiters patterns. + actors = re.split( + self._splitter, self._groups.get("performers")) + # strip() accommodates any number of spaces before/after each delimiter... + file_actors = list(map(lambda a: a.strip(), actors)) + else: + file_actors = [self._groups.get("performers")] + return file_actors + + def __extract_re_tags(self): + file_tags = [] + if self._groups.get("tags"): + if self._splitter is not None: + file_tags = self._groups.get("tags").split(self._splitter) + else: + file_tags = [self._groups.get("tags")] + return file_tags + + def __extract_re_rating(self): + rating = round(float(self._groups.get("rating") or 0)) + if rating > 0: + return rating + return 0 + + def parse(self): + if not self._re_config_file: + return {} + log.LogDebug(f"Parsing with {self._regex}") + # Match the regex against the file name + matches = re.match(self._regex, self._name) + self._groups = matches.groupdict() if matches else {} + if not self._groups: + log.LogInfo( + f"Regex found in {self._re_config_file}, is NOT matching '{self._name}'") + file_data = { + "file": self._re_config_file, + "source": "re", + "title": self._groups.get("title"), + "director": self._groups.get("director") or self._get_default("director"), + "details": self._get_default("details"), + "studio": self._groups.get("studio") or self._get_default("studio"), + "movie": self._groups.get("movie") or self._get_default("title"), + "scene_index": self._groups.get("index") or self._get_default("scene_index"), + "date": self.__extract_re_date() or self._get_default("date"), + "actors": self.__extract_re_actors() or self._get_default("actors"), + # tags are merged with defaults + "tags": list(set(self.__extract_re_tags() + self._get_default("tags"))), + "rating": self.__extract_re_rating() or self._get_default("rating"), + "cover_image": None, + "other_image": None, + "urls": None, + } + return file_data diff --git a/plugins/nfoSceneParser/stashInterface.py b/plugins/nfoSceneParser/stashInterface.py new file mode 100644 index 00000000..c458b6bc --- /dev/null +++ b/plugins/nfoSceneParser/stashInterface.py @@ -0,0 +1,423 @@ +import sys +import json +import time +import requests +import log +import config + + +class StashInterface: + + def __init__(self, fragment): + self._start = time.time() + self._fragment = fragment + self._mode = self._fragment['args'].get("mode") or "normal" + self._fragment_server = self._fragment["server_connection"] + self._plugin_dir = self._fragment_server["PluginDir"] + hook_ctx = self._fragment["args"].get("hookContext") + if hook_ctx: + self._hook_type = hook_ctx.get("type") + self._scene_id = hook_ctx.get("id") + else: + self._scene_id = None + self._path_rewrite = self._fragment["args"].get("pathRewrite") + log.LogDebug( + f"Starting nfoSceneParser plugin for scene {self._scene_id}") + + def get_scene_id(self): + return self._scene_id + + def get_mode(self): + return self._mode + + def gql_findScene(self, scene_id): + query = """ + query FindScene($id: ID!, $checksum: String) { + findScene(id: $id, checksum: $checksum) { + ...SceneData + } + } + fragment SceneData on Scene { + id + title + details + urls + date + rating: rating100 + organized + files { + path + } + studio { + ...SlimStudioData + } + movies { + movie { + ...SlimMovieData + } + scene_index + } + tags { + ...SlimTagData + } + performers { + ...SlimPerformerData + } + stash_ids { + endpoint + stash_id + } + } + fragment SlimStudioData on Studio { + id + name + } + fragment SlimMovieData on Movie { + id + name + director + } + fragment SlimTagData on Tag { + id + name + } + fragment SlimPerformerData on Performer { + id + name + } + """ + variables = { + "id": scene_id + } + result = self.__gql_call(query, variables) + # Path rewriting used for testing only + if (self._path_rewrite is not None): + result["findScene"]["files"][0]["path"] = result["findScene"]["files"][0]["path"].replace( + self._path_rewrite[0], self._path_rewrite[1]) + return result.get("findScene") + + def gql_findScenes(self, tag_id=None): + query = """ + query FindScenes($scene_filter: SceneFilterType, $filter: FindFilterType) { + findScenes(scene_filter: $scene_filter, filter: $filter) { + count + scenes { + ...SlimSceneData + } + } + } + fragment SlimSceneData on Scene { + id + organized + tags { + id + name + } + } + """ + variables = { + "scene_filter": None, + "filter": { + "direction": "ASC", + "page": 1, + "per_page": -1, + "sort": "updated_at" + } + } + if tag_id: + variables["scene_filter"] = { + "tags": { + "value": tag_id, + "modifier": "INCLUDES" + } + } + result = self.__gql_call(query, variables) + return result.get("findScenes") + + def gql_updateScene(self, scene_id, scene_data): + query = """ + mutation sceneUpdate($input: SceneUpdateInput!) { + sceneUpdate(input: $input) { + id + } + } + """ + input_data = { + "id": scene_id, + "title": scene_data["title"], + "details": scene_data["details"], + "date": scene_data["date"], + "rating100": scene_data["rating"], + "urls": scene_data["urls"], + "code": scene_data["code"], + "performer_ids": scene_data["performer_ids"], + "tag_ids": scene_data["tag_ids"], + } + if scene_data["cover_image"] is not None: + input_data.update({"cover_image": scene_data["cover_image"]}) + # Update to "organized" according to config + if config.set_organized_nfo and scene_data["source"] == "nfo": + has_mandatory_tags = True + scene_keys = [item[0].replace( + "_id", "") if item[1] else None for item in scene_data.items()] + for mandatory_tag in config.set_organized_only_if: + if mandatory_tag not in scene_keys: + has_mandatory_tags = False + break + if has_mandatory_tags: + input_data.update({"organized": True}) + # Update movie if exists + if scene_data["movie_id"] is not None: + input_data["movies"] = { + "movie_id": scene_data["movie_id"], + "scene_index": scene_data["scene_index"], + } + variables = { + "input": input_data + } + result = self.__gql_call(query, variables) + return result.get("sceneUpdate") + + def gql_performerCreate(self, name): + query = """ + mutation performerCreate($input: PerformerCreateInput!) { + performerCreate(input: $input) { + id + } + } + """ + variables = { + "input": { + "name": name + } + } + result = self.__gql_call(query, variables) + return result.get("performerCreate") + + def gql_studioCreate(self, name): + query = """ + mutation studioCreate($input: StudioCreateInput!) { + studioCreate(input: $input) { + id + } + } + """ + variables = { + "input": { + "name": name + } + } + result = self.__gql_call(query, variables) + return result.get("studioCreate") + + def gql_tagCreate(self, name): + query = """ + mutation tagCreate($input: TagCreateInput!) { + tagCreate(input: $input) { + id + } + } + """ + variables = { + "input": { + "name": name + } + } + result = self.__gql_call(query, variables) + return result.get("tagCreate") + + def gql_movieCreate(self, file_data, studio_id, folder_data): + query = """ + mutation movieCreate($input: MovieCreateInput!) { + movieCreate(input: $input) { + id + } + } + """ + # Use folder nfo data for some movie specific attributes (ignoring scene nfo specifics) + date = folder_data.get("date") or file_data["date"] or None + bl = config.blacklist + variables = { + "input": { + "name": file_data["movie"], + "studio_id": (studio_id or None) if "studio" not in bl else None, + "date": date if "date" not in bl else None, + "director": (file_data["director"] or None) if "director" not in bl else None, + "synopsis": (folder_data.get("details") or None) if "details" not in bl else None, + "rating100": (folder_data.get("rating") or None) if "rating" not in bl else None, + # Take 1st folder NFO URL for movie + "url": (folder_data.get("urls")[0] or None) if "urls" not in bl else None, + "front_image": folder_data.get("cover_image") if "cover_image" not in bl else None, + "back_image": folder_data.get("other_image") if "cover_image" not in bl else None, + } + } + result = self.__gql_call(query, variables) + return result.get("movieCreate") + + def gql_findPerformers(self, name): + query = """ + query findPerformers($performer_filter: PerformerFilterType, $filter: FindFilterType) { + findPerformers(performer_filter: $performer_filter, filter: $filter) { + performers { + id + name + alias_list + } + } + } + """ + variables = { + "performer_filter": { + "name": { + "value": name, + "modifier": "INCLUDES" + }, + "OR": { + "aliases": { + "value": name, + "modifier": "INCLUDES" + } + } + }, + "filter": { + "per_page": -1 + }, + } + result = self.__gql_call(query, variables) + return result.get("findPerformers") + + def gql_findStudios(self, name): + query = """ + query findStudios($studio_filter: StudioFilterType, $filter: FindFilterType) { + findStudios(studio_filter: $studio_filter, filter: $filter) { + studios { + id + name + aliases + } + } + } + """ + variables = { + "studio_filter": { + "name": { + "value": name, + "modifier": "INCLUDES" + }, + "OR": { + "aliases": { + "value": name, + "modifier": "INCLUDES" + } + } + }, + "filter": { + "per_page": -1 + }, + } + result = self.__gql_call(query, variables) + return result.get("findStudios") + + def gql_findMovies(self, name): + query = """ + query findMovies($movie_filter: MovieFilterType, $filter: FindFilterType) { + findMovies(movie_filter: $movie_filter, filter: $filter) { + movies { + id + name + } + } + } + """ + variables = { + "studio_filter": { + "name": { + "value": name, + "modifier": "INCLUDES" + } + }, + "filter": { + "per_page": -1 + }, + } + result = self.__gql_call(query, variables) + return result.get("findMovies") + + def gql_findTags(self, name=None): + query = """ + query findTags($tag_filter: TagFilterType, $filter: FindFilterType) { + findTags(tag_filter: $tag_filter, filter: $filter) { + tags { + id + name + aliases + } + } + } + """ + variables = { + "filter": { + "per_page": -1 + }, + } + if name: + variables["tag_filter"] = { + "name": { + "value": name, + "modifier": "INCLUDES" + } + } + result = self.__gql_call(query, variables) + return result.get("findTags") + + def exit_plugin(self, msg=None, err=None): + if not msg and not err: + msg = "plugin ended" + log.LogDebug(f"Execution time: {round(time.time() - self._start, 3)}s") + output_json = {"output": msg, "error": err} + print(json.dumps(output_json)) + sys.exit() + + def __gql_call(self, query, variables=None): + # Session cookie for authentication (supports API key for CLI tests) + graphql_port = str(self._fragment_server["Port"]) + graphql_scheme = self._fragment_server["Scheme"] + graphql_cookies = "" if not self._fragment_server.get("SessionCookie") else { + "session": self._fragment_server["SessionCookie"]["Value"]} + graphql_headers = { + "Accept-Encoding": "gzip, deflate, br", + "Content-Type": "application/json", + "Accept": "application/json", + "Connection": "keep-alive", + "DNT": "1" + } + graphql_api_key = self._fragment_server.get("ApiKey") + if graphql_api_key is not None: + graphql_headers.update({"ApiKey": graphql_api_key}) + graphql_domain = self._fragment_server["Host"] + if graphql_domain == "0.0.0.0": + graphql_domain = "localhost" + # Stash GraphQL endpoint + graphql_url = f"{graphql_scheme}://{graphql_domain}:{graphql_port}/graphql" + + graphql_json = {"query": query} + if variables is not None: + graphql_json["variables"] = variables + try: + response = requests.post( + graphql_url, json=graphql_json, headers=graphql_headers, cookies=graphql_cookies, timeout=20) + except Exception as e: + self.exit_plugin(err=f"[FATAL] Error with the graphql request {repr(e)}") + if response.status_code == 200: + result = response.json() + if result.get("errors"): + for error in result["errors"]: + raise Exception(f"GraphQL error: {error}") + return None + if result.get("data"): + return result.get("data") + elif response.status_code == 401: + self.exit_plugin(err="HTTP Error 401, Unauthorised.") + else: + raise ConnectionError( + f"GraphQL query failed: {response.status_code} - {response.content}")