diff --git a/scripts/hydrus_api/__init__.py b/scripts/hydrus_api/__init__.py new file mode 100644 index 0000000..63cdf02 --- /dev/null +++ b/scripts/hydrus_api/__init__.py @@ -0,0 +1,766 @@ +# Copyright (C) 2021 cryzed +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import enum +import json +import os +import typing as T +from collections import abc + +import requests + +__version__ = "4.0.0" + +DEFAULT_API_URL = "http://127.0.0.1:45869/" +HYDRUS_METADATA_ENCODING = "utf-8" +AUTHENTICATION_TIMEOUT_CODE = 419 + + +class HydrusAPIException(Exception): + pass + + +class ConnectionError(HydrusAPIException, requests.ConnectTimeout): + pass + + +class APIError(HydrusAPIException): + def __init__(self, response: requests.Response): + super().__init__(response.text) + self.response = response + + +class MissingParameter(APIError): + pass + + +class InsufficientAccess(APIError): + pass + + +class DatabaseLocked(APIError): + pass + + +class ServerError(APIError): + pass + + +# Customize IntEnum, so we can just do str(Enum.member) to get the string representation of its value unmodified, +# without users having to access .value explicitly +class StringableIntEnum(enum.IntEnum): + def __str__(self): + return str(self.value) + + +@enum.unique +class Permission(StringableIntEnum): + IMPORT_URLS = 0 + IMPORT_FILES = 1 + ADD_TAGS = 2 + SEARCH_FILES = 3 + MANAGE_PAGES = 4 + MANAGE_COOKIES = 5 + MANAGE_DATABASE = 6 + ADD_NOTES = 7 + + +@enum.unique +class URLType(StringableIntEnum): + POST_URL = 0 + FILE_URL = 2 + GALLERY_URL = 3 + WATCHABLE_URL = 4 + UNKNOWN_URL = 5 + + +@enum.unique +class ImportStatus(StringableIntEnum): + IMPORTABLE = 0 + SUCCESS = 1 + EXISTS = 2 + PREVIOUSLY_DELETED = 3 + FAILED = 4 + VETOED = 7 + + +@enum.unique +class TagAction(StringableIntEnum): + ADD = 0 + DELETE = 1 + PEND = 2 + RESCIND_PENDING = 3 + PETITION = 4 + RESCIND_PETITION = 5 + + +@enum.unique +class TagStatus(StringableIntEnum): + CURRENT = 0 + PENDING = 1 + DELETED = 2 + PETITIONED = 3 + + +@enum.unique +class PageType(StringableIntEnum): + GALLERY_DOWNLOADER = 1 + SIMPLE_DOWNLOADER = 2 + HARD_DRIVE_IMPORT = 3 + PETITIONS = 5 + FILE_SEARCH = 6 + URL_DOWNLOADER = 7 + DUPLICATES = 8 + THREAD_WATCHER = 9 + PAGE_OF_PAGES = 10 + + +@enum.unique +class FileSortType(StringableIntEnum): + FILE_SIZE = 0 + DURATION = 1 + IMPORT_TIME = 2 + FILE_TYPE = 3 + RANDOM = 4 + WIDTH = 5 + HEIGHT = 6 + RATIO = 7 + NUMBER_OF_PIXELS = 8 + NUMBER_OF_TAGS = 9 + NUMBER_OF_MEDIA_VIEWS = 10 + TOTAL_MEDIA_VIEWTIME = 11 + APPROXIMATE_BITRATE = 12 + HAS_AUDIO = 13 + MODIFIED_TIME = 14 + FRAMERATE = 15 + NUMBER_OF_FRAMES = 16 + + +class BinaryFileLike(T.Protocol): + def read(self): + ... + + +# The client should accept all objects that either support the iterable or mapping protocol. We must ensure that objects +# are either lists or dicts, so Python's json module can handle them +class JSONEncoder(json.JSONEncoder): + def default(self, object_: T.Any): + if isinstance(object_, abc.Mapping): + return dict(object_) + if isinstance(object_, abc.Iterable): + return list(object_) + return super().default(object_) + + +class Client: + VERSION = 31 + + # Access Management + _GET_API_VERSION_PATH = "/api_version" + _REQUEST_NEW_PERMISSIONS_PATH = "/request_new_permissions" + _GET_SESSION_KEY_PATH = "/session_key" + _VERIFY_ACCESS_KEY_PATH = "/verify_access_key" + _GET_SERVICES_PATH = "/get_services" + + # Adding Files + _ADD_FILE_PATH = "/add_files/add_file" + _DELETE_FILES_PATH = "/add_files/delete_files" + _UNDELETE_FILES_PATH = "/add_files/undelete_files" + _ARCHIVE_FILES_PATH = "/add_files/archive_files" + _UNARCHIVE_FILES_PATH = "/add_files/unarchive_files" + + # Adding Tags + _CLEAN_TAGS_PATH = "/add_tags/clean_tags" + _SEARCH_TAGS_PATH = "/add_tags/search_tags" + _ADD_TAGS_PATH = "/add_tags/add_tags" + + # Adding URLs + _GET_URL_FILES_PATH = "/add_urls/get_url_files" + _GET_URL_INFO_PATH = "/add_urls/get_url_info" + _ADD_URL_PATH = "/add_urls/add_url" + _ASSOCIATE_URL_PATH = "/add_urls/associate_url" + + # Adding Notes + _SET_NOTES_PATH = "/add_notes/set_notes" + _DELETE_NOTES_PATH = "/add_notes/delete_notes" + + # Managing Cookies and HTTP Headers + _GET_COOKIES_PATH = "/manage_cookies/get_cookies" + _SET_COOKIES_PATH = "/manage_cookies/set_cookies" + _SET_USER_AGENT_PATH = "/manage_headers/set_user_agent" + + # Managing Pages + _GET_PAGES_PATH = "/manage_pages/get_pages" + _GET_PAGE_INFO_PATH = "/manage_pages/get_page_info" + _ADD_FILES_TO_PAGE_PATH = "/manage_pages/add_files" + _FOCUS_PAGE_PATH = "/manage_pages/focus_page" + + # Searching and Fetching Files + _SEARCH_FILES_PATH = "/get_files/search_files" + _GET_FILE_METADATA_PATH = "/get_files/file_metadata" + _GET_FILE_PATH = "/get_files/file" + _GET_THUMBNAIL_PATH = "/get_files/thumbnail" + + # Managing the Database + _LOCK_DATABASE_PATH = "/manage_database/lock_on" + _UNLOCK_DATABASE_PATH = "/manage_database/lock_off" + _MR_BONES_PATH = "/manage_database/mr_bones" + + def __init__( + self, + access_key = None, + api_url: str = DEFAULT_API_URL, + session = None, + ): + """ + See https://hydrusnetwork.github.io/hydrus/help/client_api.html for documentation. + """ + + self.access_key = access_key + self.api_url = api_url.rstrip("/") + self.session = session or requests.Session() + + def _api_request(self, method: str, path: str, **kwargs: T.Any): + if self.access_key is not None: + kwargs.setdefault("headers", {}).update({"Hydrus-Client-API-Access-Key": self.access_key}) + + # Make sure we use our custom JSONEncoder that can serialize all objects that implement the iterable or mapping + # protocol + json_data = kwargs.pop("json", None) + if json_data is not None: + kwargs["data"] = json.dumps(json_data, cls=JSONEncoder) + # Since we aren't using the json keyword-argument, we have to set the Content-Type manually + kwargs["headers"]["Content-Type"] = "application/json" + + try: + response = self.session.request(method, self.api_url + path, **kwargs) + except requests.RequestException as error: + # Re-raise connection and timeout errors as hydrus.ConnectionErrors so these are more easy to handle for + # client applications + raise ConnectionError(*error.args) + + try: + response.raise_for_status() + except requests.HTTPError: + if response.status_code == requests.codes.bad_request: + raise MissingParameter(response) + elif response.status_code in { + requests.codes.unauthorized, + requests.codes.forbidden, + AUTHENTICATION_TIMEOUT_CODE, + }: + raise InsufficientAccess(response) + elif response.status_code == requests.codes.service_unavailable: + raise DatabaseLocked(response) + elif response.status_code == requests.codes.server_error: + raise ServerError(response) + raise APIError(response) + + return response + + def get_api_version(self): + response = self._api_request("GET", self._GET_API_VERSION_PATH) + return response.json() + + def request_new_permissions(self, name, permissions): + response = self._api_request( + "GET", + self._REQUEST_NEW_PERMISSIONS_PATH, + params={"name": name, "basic_permissions": json.dumps(permissions, cls=JSONEncoder)}, + ) + return response.json()["access_key"] + + def get_session_key(self): + response = self._api_request("GET", self._GET_SESSION_KEY_PATH) + return response.json()["session_key"] + + def verify_access_key(self): + response = self._api_request("GET", self._VERIFY_ACCESS_KEY_PATH) + return response.json() + + def get_services(self): + response = self._api_request("GET", self._GET_SERVICES_PATH) + return response.json() + + def add_file(self, path_or_file: T.Union[str, os.PathLike, BinaryFileLike]): + if isinstance(path_or_file, (str, os.PathLike)): + response = self._api_request("POST", self._ADD_FILE_PATH, json={"path": os.fspath(path_or_file)}) + else: + response = self._api_request( + "POST", + self._ADD_FILE_PATH, + data=path_or_file.read(), + headers={"Content-Type": "application/octet-stream"}, + ) + + return response.json() + + def delete_files( + self, + hashes = None, + file_ids = None, + file_service_name = None, + file_service_key = None, + reason = None + ): + if hashes is None and file_ids is None: + raise ValueError("At least one of hashes, file_ids is required") + if file_service_name is not None and file_service_key is not None: + raise ValueError("Exactly one of file_service_name, file_service_key is required") + + payload: dict[str, T.Any] = {} + if hashes is not None: + payload["hashes"] = hashes + if file_ids is not None: + payload["file_ids"] = file_ids + if file_service_name is not None: + payload["file_service_name"] = file_service_name + if file_service_key is not None: + payload["file_service_key"] = file_service_key + if reason is not None: + payload["reason"] = reason + + self._api_request("POST", self._DELETE_FILES_PATH, json=payload) + + def undelete_files( + self, + hashes = None, + file_ids = None, + file_service_name = None, + file_service_key = None, + ): + if hashes is None and file_ids is None: + raise ValueError("At least one of hashes, file_ids is required") + if file_service_name is not None and file_service_key is not None: + raise ValueError("Exactly one of file_service_name, file_service_key is required") + + payload: dict[str, T.Any] = {} + if hashes is not None: + payload["hashes"] = hashes + if file_ids is not None: + payload["file_ids"] = file_ids + if file_service_name is not None: + payload["file_service_name"] = file_service_name + if file_service_key is not None: + payload["file_service_key"] = file_service_key + + self._api_request("POST", self._UNDELETE_FILES_PATH, json=payload) + + def archive_files( + self, + hashes = None, + file_ids = None + ): + if hashes is None and file_ids is None: + raise ValueError("At least one of hashes, file_ids is required") + + payload: dict[str, T.Any] = {} + if hashes is not None: + payload["hashes"] = hashes + if file_ids is not None: + payload["file_ids"] = file_ids + + self._api_request("POST", self._ARCHIVE_FILES_PATH, json=payload) + + def unarchive_files( + self, + hashes = None, + file_ids = None + ): + if hashes is None and file_ids is None: + raise ValueError("At least one of hashes, file_ids is required") + + payload: dict[str, T.Any] = {} + if hashes is not None: + payload["hashes"] = hashes + if file_ids is not None: + payload["file_ids"] = file_ids + + self._api_request("POST", self._UNARCHIVE_FILES_PATH, json=payload) + + def clean_tags(self, tags ): + response = self._api_request("GET", self._CLEAN_TAGS_PATH, params={"tags": json.dumps(tags, cls=JSONEncoder)}) + return response.json()["tags"] + + def search_tags( + self, + search: str, + tag_service_key = None, + tag_service_name = None + ): + if tag_service_name is not None and tag_service_key is not None: + raise ValueError("Exactly one of tag_service_name, tag_service_key is required") + + payload: dict[str, T.Any] = {"search": search} + if tag_service_key is not None: + payload["tag_service_key"] = tag_service_key + if tag_service_name is not None: + payload["tag_service_name"] = tag_service_name + + response = self._api_request("GET", self._SEARCH_TAGS_PATH, params=payload) + return response.json()["tags"] + + def add_tags( + self, + hashes = None, + file_ids = None, + service_names_to_tags = None, + service_keys_to_tags = None, + service_names_to_actions_to_tags = None, + service_keys_to_actions_to_tags = None, + ): + if hashes is None and file_ids is None: + raise ValueError("At least one of hashes, file_ids is required") + if ( + service_names_to_tags is None + and service_keys_to_tags is None + and service_names_to_actions_to_tags is None + and service_keys_to_actions_to_tags is None + ): + raise ValueError( + "At least one of service_names_to_tags, service_keys_to_tags, service_names_to_actions_to_tags or " + "service_keys_to_actions_to_tags is required" + ) + + payload: dict[str, T.Any] = {} + if hashes is not None: + payload["hashes"] = hashes + if file_ids is not None: + payload["file_ids"] = file_ids + if service_names_to_tags is not None: + payload["service_names_to_tags"] = service_names_to_tags + if service_keys_to_tags is not None: + payload["service_keys_to_tags"] = service_keys_to_tags + if service_names_to_actions_to_tags is not None: + payload["service_names_to_actions_to_tags"] = service_names_to_actions_to_tags + if service_keys_to_actions_to_tags is not None: + payload["service_keys_to_actions_to_tags"] = service_keys_to_actions_to_tags + + self._api_request("POST", self._ADD_TAGS_PATH, json=payload) + + def get_url_files(self, url: str): + response = self._api_request("GET", self._GET_URL_FILES_PATH, params={"url": url}) + return response.json() + + def get_url_info(self, url: str): + response = self._api_request("GET", self._GET_URL_INFO_PATH, params={"url": url}) + return response.json() + + def add_url( + self, + url: str, + destination_page_key = None, + destination_page_name = None, + show_destination_page = None, + service_names_to_additional_tags = None, + service_keys_to_additional_tags = None, + filterable_tags = None, + ): + if destination_page_key is not None and destination_page_name is not None: + raise ValueError("Exactly one of destination_page_key, destination_page_name is required") + + payload: dict[str, T.Any] = {"url": url} + if destination_page_key is not None: + payload["destination_page_key"] = destination_page_key + if destination_page_name is not None: + payload["destination_page_name"] = destination_page_name + if show_destination_page is not None: + payload["show_destination_page"] = show_destination_page + if service_names_to_additional_tags is not None: + payload["service_names_to_additional_tags"] = service_names_to_additional_tags + if service_keys_to_additional_tags is not None: + payload["service_keys_to_additional_tags"] = service_keys_to_additional_tags + if filterable_tags is not None: + payload["filterable_tags"] = filterable_tags + + response = self._api_request("POST", self._ADD_URL_PATH, json=payload) + return response.json() + + def associate_url( + self, + hashes = None, + file_ids = None, + urls_to_add = None, + urls_to_delete = None, + ): + if hashes is None and file_ids is None: + raise ValueError("At least one of hashes, file_ids is required") + if urls_to_add is None and urls_to_delete is None: + raise ValueError("At least one of urls_to_add, urls_to_delete is required") + + payload: dict[str, T.Any] = {} + if hashes is not None: + payload["hashes"] = hashes + if file_ids is not None: + payload["file_ids"] = file_ids + if urls_to_add is not None: + urls_to_add = urls_to_add + payload["urls_to_add"] = urls_to_add + if urls_to_delete is not None: + urls_to_delete = urls_to_delete + payload["urls_to_delete"] = urls_to_delete + + self._api_request("POST", self._ASSOCIATE_URL_PATH, json=payload) + + def set_notes(self, notes , hash_= None, file_id = None): + if (hash_ is None and file_id is None) or (hash_ is not None and file_id is not None): + raise ValueError("Exactly one of hash_, file_id is required") + + payload: dict[str, T.Any] = {"notes": notes} + if hash_ is not None: + payload["hash"] = hash_ + if file_id is not None: + payload["file_id"] = file_id + + self._api_request("POST", self._SET_NOTES_PATH, json=payload) + + def delete_notes( + self, + note_names , + hash_ = None, + file_id = None + ): + if (hash_ is None and file_id is None) or (hash_ is not None and file_id is not None): + raise ValueError("Exactly one of hash_, file_id is required") + + payload: dict[str, T.Any] = {"note_names": note_names} + if hash_ is not None: + payload["hash"] = hash_ + if file_id is not None: + payload["file_id"] = file_id + + self._api_request("POST", self._DELETE_NOTES_PATH, json=payload) + + def get_cookies(self, domain: str): + response = self._api_request("GET", self._GET_COOKIES_PATH, params={"domain": domain}) + return response.json()["cookies"] + + def set_cookies(self, cookies ): + self._api_request("POST", self._SET_COOKIES_PATH, json={"cookies": cookies}) + + def set_user_agent(self, user_agent: str): + self._api_request("POST", self._SET_USER_AGENT_PATH, json={"user-agent": user_agent}) + + def get_pages(self): + response = self._api_request("GET", self._GET_PAGES_PATH) + return response.json()["pages"] + + def get_page_info(self, page_key: str, simple = None): + parameters = {"page_key": page_key} + if simple is not None: + parameters["simple"] = json.dumps(simple, cls=JSONEncoder) + + response = self._api_request("GET", self._GET_PAGE_INFO_PATH, params=parameters) + return response.json()["page_info"] + + def add_files_to_page( + self, + page_key: str, + file_ids = None, + hashes = None + ): + if file_ids is None and hashes is None: + raise ValueError("At least one of file_ids, hashes is required") + + payload: dict[str, T.Any] = {"page_key": page_key} + if file_ids is not None: + payload["file_ids"] = file_ids + if hashes is not None: + payload["hashes"] = hashes + + self._api_request("POST", self._ADD_FILES_TO_PAGE_PATH, json=payload) + + def focus_page(self, page_key: str): + self._api_request("POST", self._FOCUS_PAGE_PATH, json={"page_key": page_key}) + + def search_files( + self, + tags, + file_service_name = None, + file_service_key = None, + tag_service_name = None, + tag_service_key = None, + file_sort_type = None, + file_sort_asc = None, + return_hashes = None, + ): + if file_service_name is not None and file_service_key is not None: + raise ValueError("Exactly one of file_service_name, file_service_key is required") + if tag_service_name is not None and tag_service_key is not None: + raise ValueError("Exactly one of tag_service_name, tag_service_key is required") + + parameters: dict[str, T.Union[str, int]] = {"tags": json.dumps(tags, cls=JSONEncoder)} + if file_service_name is not None: + parameters["file_service_name"] = file_service_name + if file_service_key is not None: + parameters["file_service_key"] = file_service_key + + if tag_service_name is not None: + parameters["tag_service_name"] = tag_service_name + if tag_service_key is not None: + parameters["tag_service_key"] = tag_service_key + + if file_sort_type is not None: + parameters["file_sort_type"] = file_sort_type + if file_sort_asc is not None: + parameters["file_sort_asc"] = json.dumps(file_sort_asc, cls=JSONEncoder) + if return_hashes is not None: + parameters["return_hashes"] = json.dumps(return_hashes, cls=JSONEncoder) + + response = self._api_request("GET", self._SEARCH_FILES_PATH, params=parameters) + return response.json()["hashes" if return_hashes else "file_ids"] + + def get_file_metadata( + self, + hashes = None, + file_ids = None, + create_new_file_ids = None, + only_return_identifiers = None, + only_return_basic_information = None, + detailed_url_information = None, + hide_service_name_tags = None, + include_notes = None + ): + if hashes is None and file_ids is None: + raise ValueError("At least one of hashes, file_ids is required") + + parameters = {} + if hashes is not None: + parameters["hashes"] = json.dumps(hashes, cls=JSONEncoder) + if file_ids is not None: + parameters["file_ids"] = json.dumps(file_ids, cls=JSONEncoder) + + if create_new_file_ids is not None: + parameters["create_new_file_ids"] = json.dumps(create_new_file_ids, cls=JSONEncoder) + if only_return_identifiers is not None: + parameters["only_return_identifiers"] = json.dumps(only_return_identifiers, cls=JSONEncoder) + if only_return_basic_information is not None: + parameters["only_return_basic_information"] = json.dumps(only_return_basic_information, cls=JSONEncoder) + if detailed_url_information is not None: + parameters["detailed_url_information"] = json.dumps(detailed_url_information, cls=JSONEncoder) + if hide_service_name_tags is not None: + parameters["hide_service_name_tags"] = json.dumps(hide_service_name_tags, cls=JSONEncoder) + if include_notes is not None: + parameters["include_notes"] = json.dumps(include_notes, cls=JSONEncoder) + + response = self._api_request("GET", self._GET_FILE_METADATA_PATH, params=parameters) + return response.json()["metadata"] + + def get_file(self, hash_ = None, file_id = None): + if (hash_ is None and file_id is None) or (hash_ is not None and file_id is not None): + raise ValueError("Exactly one of hash_, file_id is required") + + parameters: dict[str, T.Union[str, int]] = {} + if hash_ is not None: + parameters["hash"] = hash_ + if file_id is not None: + parameters["file_id"] = file_id + + return self._api_request("GET", self._GET_FILE_PATH, params=parameters, stream=True) + + def get_thumbnail(self, hash_ = None, file_id = None): + if (hash_ is None and file_id is None) or (hash_ is not None and file_id is not None): + raise ValueError("Exactly one of hash_, file_id is required") + + parameters: dict[str, T.Union[str, int]] = {} + if hash_ is not None: + parameters["hash"] = hash_ + if file_id is not None: + parameters["file_id"] = file_id + + return self._api_request("GET", self._GET_THUMBNAIL_PATH, params=parameters, stream=True) + + def lock_database(self): + self._api_request("POST", self._LOCK_DATABASE_PATH) + + def unlock_database(self): + self._api_request("POST", self._UNLOCK_DATABASE_PATH) + + def get_mr_bones(self): + return self._api_request("GET", self._MR_BONES_PATH).json()["boned_stats"] + + def add_and_tag_files( + self, + paths_or_files, + tags , + service_names = None, + service_keys = None, + ): + """Convenience method to add and tag multiple files at the same time. + + If service_names and service_keys aren't specified, the default service name "my tags" will be used. If a file + already exists in Hydrus, it will also be tagged. + + Returns: + list[dict[str, T.Any]]: Returns results of all `Client.add_file()` calls, matching the order of the + paths_or_files iterable + """ + if service_names is None and service_keys is None: + service_names = ("my tags",) + + results = [] + hashes = set() + for path_or_file in paths_or_files: + result = self.add_file(path_or_file) + results.append(result) + if result["status"] != ImportStatus.FAILED: + hashes.add(result["hash"]) + + service_names_to_tags = {name: tags for name in service_names} if service_names is not None else None + service_keys_to_tags = {key: tags for key in service_keys} if service_keys is not None else None + # Ignore type, we know that hashes only contains strings + self.add_tags(hashes, service_names_to_tags=service_names_to_tags, service_keys_to_tags=service_keys_to_tags) # type: ignore + return results + + def get_page_list(self): + """Convenience method that returns a flattened version of the page tree from `Client.get_pages()`. + + Returns: + list[dict[str, T.Any]]: A list of every "pages" value in the page tree in pre-order (NLR) + """ + tree = self.get_pages() + pages = [] + + def walk_tree(page: dict[str, T.Any]): + pages.append(page) + # Ignore type, we know that pages is always a list + for sub_page in page.get("pages", ()): # type: ignore + # Ignore type, we know that sub_page is always a dict + walk_tree(sub_page) # type: ignore + + walk_tree(tree) + return pages + + +__all__ = [ + "__version__", + "DEFAULT_API_URL", + "HYDRUS_METADATA_ENCODING", + "HydrusAPIException", + "ConnectionError", + "APIError", + "MissingParameter", + "InsufficientAccess", + "DatabaseLocked", + "ServerError", + "Permission", + "URLType", + "ImportStatus", + "TagAction", + "TagStatus", + "PageType", + "FileSortType", + "Client", +] diff --git a/scripts/hydrus_api/utils.py b/scripts/hydrus_api/utils.py new file mode 100644 index 0000000..f6aea2a --- /dev/null +++ b/scripts/hydrus_api/utils.py @@ -0,0 +1,102 @@ +# Copyright (C) 2021 cryzed +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import collections +import os +import typing as T +from collections import abc + +from hydrus_api import DEFAULT_API_URL, HYDRUS_METADATA_ENCODING, Client, Permission + +X = T.TypeVar("X") + + +class TextFileLike(T.Protocol): + def read(self) -> str: + pass + + +def verify_permissions( + client: Client, permissions: abc.Iterable[T.Union[int, Permission]], exact: bool = False +) -> bool: + granted_permissions = set(client.verify_access_key()["basic_permissions"]) + return granted_permissions == set(permissions) if exact else granted_permissions.issuperset(permissions) + + +def cli_request_api_key( + name: str, + permissions: abc.Iterable[T.Union[int, Permission]], + verify: bool = True, + exact: bool = False, + api_url: str = DEFAULT_API_URL, +) -> str: + while True: + input( + 'Navigate to "services->review services->local->client api" in the Hydrus client and click "add->from api ' + 'request". Then press enter to continue...' + ) + access_key = Client(api_url=api_url).request_new_permissions(name, permissions) + input("Press OK and then apply in the Hydrus client dialog. Then press enter to continue...") + + client = Client(access_key, api_url) + if verify and not verify_permissions(client, permissions, exact): + granted = client.verify_access_key()["basic_permissions"] + print( + f"The granted permissions ({granted}) differ from the requested permissions ({permissions}), please " + "grant all requested permissions." + ) + continue + + return access_key + + +def parse_hydrus_metadata(text: str) -> collections.defaultdict[T.Optional[str], set[str]]: + namespaces = collections.defaultdict(set) + for line in (line.strip() for line in text.splitlines()): + if not line: + continue + + parts = line.split(":", 1) + namespace, tag = (None, line) if len(parts) == 1 else parts + namespaces[namespace].add(tag) + + # Ignore type, mypy has trouble figuring out that tag isn't optional + return namespaces # type: ignore + + +def parse_hydrus_metadata_file( + path_or_file: T.Union[str, os.PathLike, TextFileLike] +) -> collections.defaultdict[T.Optional[str], set[str]]: + if isinstance(path_or_file, (str, os.PathLike)): + with open(path_or_file, encoding=HYDRUS_METADATA_ENCODING) as file: + return parse_hydrus_metadata(file.read()) + + return parse_hydrus_metadata(path_or_file.read()) + + +# Useful for splitting up requests to get_file_metadata() +def yield_chunks(sequence: T.Sequence[X], chunk_size: int, offset: int = 0) -> T.Generator[T.Sequence[X], None, None]: + while offset < len(sequence): + yield sequence[offset : offset + chunk_size] + offset += chunk_size + + +__all__ = [ + "verify_permissions", + "cli_request_api_key", + "parse_hydrus_metadata", + "parse_hydrus_metadata_file", + "yield_chunks", +]