commit 1a6c725aac84432590b3ea52cd20409a36e9cf41 Author: strNophix Date: Wed Jul 20 22:47:05 2022 +0200 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..33e87c2 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Name of user to sync from +ANILIST_USERNAME= + +# The Sonarr instance to sync to +SONARR_API_URL= +SONARR_API_KEY= +# Root folder to save the shows to +SONARR_FOLDER_PATH= + +# IDs of the quality and language profiles to use +SONARR_QUALITY_PROFILE=4 +SONARR_LANGUAGE_PROFILE=1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..68bc17f --- /dev/null +++ b/.gitignore @@ -0,0 +1,160 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a412ea --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# al-arr-sync +A small script for syncing your currently watching anime from AniList to Sonarr/Radarr. + +## Usage +Assuming you have [poetry](https://python-poetry.org/docs/#installation) installed: +```sh +cp .env.example .env +$EDITOR .env +poetry install +poetry run python3 -m al_arr_sync +``` diff --git a/al_arr_sync/__init__.py b/al_arr_sync/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/al_arr_sync/__main__.py b/al_arr_sync/__main__.py new file mode 100644 index 0000000..fef6d7e --- /dev/null +++ b/al_arr_sync/__main__.py @@ -0,0 +1,37 @@ +import os +import typing +from dotenv import load_dotenv +from al_arr_sync.anilist import AniListClient +from al_arr_sync.sonarr import SonarrClient + +load_dotenv() + + +def main() -> int: + al = AniListClient() + sonarr = SonarrClient.from_env() + + username = os.environ["ANILIST_USERNAME"] + media = al.currently_watching(username) + + series: typing.List[typing.Any] = [] + for entry in media: + media_format = entry["media"]["format"] + if media_format == "TV": + series.append(entry) + + for show in series: + show_name = show["media"]["title"]["english"] + results = sonarr.lookup_series(show_name) + try: + sonarr.add_series(results[0]) + print(f"Successfully added series {show_name}") + except Exception as e: + print(e) + continue + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/al_arr_sync/anilist.py b/al_arr_sync/anilist.py new file mode 100644 index 0000000..dda728b --- /dev/null +++ b/al_arr_sync/anilist.py @@ -0,0 +1,46 @@ +import typing +from requests import Session + +if typing.TYPE_CHECKING: + AnyDict = typing.Dict[typing.Any, typing.Any] + + +USER_WATCHING_QUERY = """ +query ($userName: String) { + MediaListCollection(userName: $userName, type:ANIME, status:CURRENT) { + lists { + entries { + media { + title { + english + } + format + } + } + } + } +} +""" + + +class AniListClient: + def __init__(self) -> None: + self.graphql_api_url = "https://graphql.anilist.co/" + self.http_session = Session() + + def currently_watching(self, username: str) -> typing.List['AnyDict']: + resp = self.http_session.post( + self.graphql_api_url, + json={"query": USER_WATCHING_QUERY, "variables": {"userName": username}}, + ) + resp_data: 'AnyDict' = resp.json() + if errors := resp_data.get("errors"): + raise Exception(errors) + + media = [ + entry + for lst in resp_data["data"]["MediaListCollection"]["lists"] + for entry in lst["entries"] + ] + + return media diff --git a/al_arr_sync/sonarr.py b/al_arr_sync/sonarr.py new file mode 100644 index 0000000..866cbfa --- /dev/null +++ b/al_arr_sync/sonarr.py @@ -0,0 +1,61 @@ +import typing +from requests import Session +from requests import PreparedRequest +import os +from urllib.parse import urljoin + +if typing.TYPE_CHECKING: + AnyDict = typing.Dict[typing.Any, typing.Any] + +class SonarrClient: + def __init__(self, sonarr_url: str, api_key: str) -> None: + self.sonarr_url = sonarr_url + self.api_key = api_key + self.http_session = Session() + + @staticmethod + def from_env() -> "SonarrClient": + return SonarrClient( + sonarr_url=os.environ["SONARR_API_URL"], + api_key=os.environ["SONARR_API_KEY"], + ) + + def _prepare_request( + self, + endpoint: str, + method: str = "GET", + params: 'AnyDict' = {}, + json: typing.Optional['AnyDict'] = None, + ) -> PreparedRequest: + url = urljoin(self.sonarr_url, endpoint) + headers = {"X-Api-Key": self.api_key} + + req = PreparedRequest() + req.prepare(method=method, url=url, headers=headers, params=params, json=json) + return req + + def lookup_series(self, title: str) -> typing.List['AnyDict']: + req = self._prepare_request("/api/v3/series/lookup", params={"term": title}) + resp = self.http_session.send(req) + return resp.json() + + def add_series(self, *series: 'AnyDict'): + for show in series: + payload: 'AnyDict' = show.copy() + payload.update( + { + "addOptions": { + "monitor": "future", + "searchForCutoffUnmetEpisodes": False, + "searchForMissingEpisodes": False, + }, + "rootFolderPath": os.environ["SONARR_FOLDER_PATH"], + "qualityProfileId": int(os.environ["SONARR_QUALITY_PROFILE"]), + "languageProfileId": int(os.environ["SONARR_LANGUAGE_PROFILE"]), + } + ) + req = self._prepare_request("/api/v3/series", method="POST", json=payload) + resp = self.http_session.send(req) + + if resp.status_code != 201: + raise Exception(f"Failed to add series {show['title']}:\n{resp.json()}") diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..46f74a8 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,230 @@ +[[package]] +name = "atomicwrites" +version = "1.4.1" +description = "Atomic file writes." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[[package]] +name = "attrs" +version = "21.4.0" +description = "Classes Without Boilerplate" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[package.extras] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"] + +[[package]] +name = "certifi" +version = "2022.6.15" +description = "Python package for providing Mozilla's CA Bundle." +category = "main" +optional = false +python-versions = ">=3.6" + +[[package]] +name = "charset-normalizer" +version = "2.1.0" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" +optional = false +python-versions = ">=3.6.0" + +[package.extras] +unicode_backport = ["unicodedata2"] + +[[package]] +name = "colorama" +version = "0.4.5" +description = "Cross-platform colored terminal text." +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "idna" +version = "3.3" +description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "more-itertools" +version = "8.13.0" +description = "More routines for operating on iterables, beyond itertools" +category = "dev" +optional = false +python-versions = ">=3.5" + +[[package]] +name = "packaging" +version = "21.3" +description = "Core utilities for Python packages" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +pyparsing = ">=2.0.2,<3.0.5 || >3.0.5" + +[[package]] +name = "pluggy" +version = "0.13.1" +description = "plugin and hook calling mechanisms for python" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + +[package.extras] +dev = ["pre-commit", "tox"] + +[[package]] +name = "py" +version = "1.11.0" +description = "library with cross-python path, ini-parsing, io, code, log facilities" +category = "dev" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + +[[package]] +name = "pyparsing" +version = "3.0.9" +description = "pyparsing module - Classes and methods to define and execute parsing grammars" +category = "dev" +optional = false +python-versions = ">=3.6.8" + +[package.extras] +diagrams = ["railroad-diagrams", "jinja2"] + +[[package]] +name = "pytest" +version = "5.4.3" +description = "pytest: simple powerful testing with Python" +category = "dev" +optional = false +python-versions = ">=3.5" + +[package.dependencies] +atomicwrites = {version = ">=1.0", markers = "sys_platform == \"win32\""} +attrs = ">=17.4.0" +colorama = {version = "*", markers = "sys_platform == \"win32\""} +more-itertools = ">=4.0.0" +packaging = "*" +pluggy = ">=0.12,<1.0" +py = ">=1.5.0" +wcwidth = "*" + +[package.extras] +checkqa-mypy = ["mypy (==v0.761)"] +testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] + +[[package]] +name = "python-dotenv" +version = "0.20.0" +description = "Read key-value pairs from a .env file and set them as environment variables" +category = "main" +optional = false +python-versions = ">=3.5" + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "requests" +version = "2.28.1" +description = "Python HTTP for Humans." +category = "main" +optional = false +python-versions = ">=3.7, <4" + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<3" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<1.27" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "urllib3" +version = "1.26.10" +description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*, <4" + +[package.extras] +brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] +secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "ipaddress"] +socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] + +[[package]] +name = "wcwidth" +version = "0.2.5" +description = "Measures the displayed width of unicode strings in a terminal" +category = "dev" +optional = false +python-versions = "*" + +[metadata] +lock-version = "1.1" +python-versions = "^3.10" +content-hash = "0bb20f685f44a2bcb9319b8f7049a0a67e23e1b0716404f29cec7fff6c0ef8ac" + +[metadata.files] +atomicwrites = [] +attrs = [ + {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"}, + {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"}, +] +certifi = [] +charset-normalizer = [] +colorama = [] +idna = [ + {file = "idna-3.3-py3-none-any.whl", hash = "sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff"}, + {file = "idna-3.3.tar.gz", hash = "sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"}, +] +more-itertools = [ + {file = "more-itertools-8.13.0.tar.gz", hash = "sha256:a42901a0a5b169d925f6f217cd5a190e32ef54360905b9c39ee7db5313bfec0f"}, + {file = "more_itertools-8.13.0-py3-none-any.whl", hash = "sha256:c5122bffc5f104d37c1626b8615b511f3427aa5389b94d61e5ef8236bfbc3ddb"}, +] +packaging = [ + {file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"}, + {file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"}, +] +pluggy = [ + {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, + {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, +] +py = [ + {file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"}, + {file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"}, +] +pyparsing = [ + {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, + {file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"}, +] +pytest = [ + {file = "pytest-5.4.3-py3-none-any.whl", hash = "sha256:5c0db86b698e8f170ba4582a492248919255fcd4c79b1ee64ace34301fb589a1"}, + {file = "pytest-5.4.3.tar.gz", hash = "sha256:7979331bfcba207414f5e1263b5a0f8f521d0f457318836a7355531ed1a4c7d8"}, +] +python-dotenv = [ + {file = "python-dotenv-0.20.0.tar.gz", hash = "sha256:b7e3b04a59693c42c36f9ab1cc2acc46fa5df8c78e178fc33a8d4cd05c8d498f"}, + {file = "python_dotenv-0.20.0-py3-none-any.whl", hash = "sha256:d92a187be61fe482e4fd675b6d52200e7be63a12b724abbf931a40ce4fa92938"}, +] +requests = [] +urllib3 = [] +wcwidth = [ + {file = "wcwidth-0.2.5-py2.py3-none-any.whl", hash = "sha256:beb4802a9cebb9144e99086eff703a642a13d6a0052920003a230f3294bbe784"}, + {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, +] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..571147c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[tool.poetry] +name = "al-arr-sync" +version = "0.1.0" +description = "" +authors = ["strNophix "] + +[tool.poetry.dependencies] +python = "^3.10" +python-dotenv = "^0.20.0" +requests = "^2.28.1" + +[tool.poetry.dev-dependencies] +pytest = "^5.2" + +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api"