Compare commits

...

6 Commits

Author SHA1 Message Date
df543326c7 Updated README.md 2022-11-27 19:24:45 +01:00
f0d25549a5 Added mediatracker type 2022-10-18 19:13:29 +02:00
cc86f13c13 Added MIT license 2022-10-18 19:11:54 +02:00
66a602b06f Script now takes a config file as an optional arg 2022-07-22 15:16:04 +02:00
ffa8090399 Moved from .env to ini config format 2022-07-22 14:54:57 +02:00
4bf249a9fd Fixed MovieExistsValidator 2022-07-22 14:00:38 +02:00
12 changed files with 143 additions and 78 deletions

View File

@ -1,23 +0,0 @@
# 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
# The Radarr instance to sync to
RADARR_API_URL=
RADARR_API_KEY=
# Root folder to save the movies to
RADARR_FOLDER_PATH=
# ID of the quality profile to use
RADARR_QUALITY_PROFILE=4

3
.gitignore vendored
View File

@ -158,3 +158,6 @@ cython_debug/
# 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/
# Development
config.ini

21
LICENSE.md Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 niku
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -2,10 +2,16 @@
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
pip install git+https://git.cesium.pw/niku/al-arr-sync.git#egg=al-arr-sync
wget -O config.ini https://git.cesium.pw/niku/al-arr-sync/raw/branch/main/config.ini.example
nano config.ini
python -m al_arr_sync
```
In case you want to use a specific config:
```sh
python -m al_arr_sync ~/.config/al-arr-sync/config.ini
```

View File

@ -1,33 +1,36 @@
import os
from dotenv import load_dotenv
import sys
import typing
from al_arr_sync.anilist import AniListClient
from al_arr_sync.radarr import RadarrClient
from al_arr_sync.sonarr import SonarrClient
from al_arr_sync.types import DlAutomator
load_dotenv()
from al_arr_sync.types import DlAutomator, MediaTracker
from al_arr_sync.config import load_config
def main() -> int:
al = AniListClient()
sonarr: DlAutomator = SonarrClient.from_env()
radarr: DlAutomator = RadarrClient.from_env()
def main(args: typing.Sequence[str]) -> int:
cfg_path = args[0] if len(args) > 0 else "./config.ini"
cfg = load_config(cfg_path)
username = os.environ["ANILIST_USERNAME"]
al: MediaTracker = AniListClient()
sonarr: DlAutomator = SonarrClient.from_config(cfg)
radarr: DlAutomator = RadarrClient.from_config(cfg)
download_clients: typing.Dict[str, DlAutomator] = {
"TV": sonarr,
"MOVIE": radarr,
}
username = cfg["anilist"]["username"]
media = al.currently_watching(username)
for entry in media:
media_format = entry["media"]["format"]
show_name = entry["media"]["title"]["english"]
client: DlAutomator
if media_format == "TV":
client = sonarr
elif media_format == "MOVIE":
client = radarr
else:
client: typing.Optional[DlAutomator] = download_clients.get(
media_format)
if client is None:
continue
results = client.lookup_series(show_name)
@ -45,4 +48,4 @@ def main() -> int:
if __name__ == "__main__":
raise SystemExit(main())
raise SystemExit(main(sys.argv[1:]))

7
al_arr_sync/config.py Normal file
View File

@ -0,0 +1,7 @@
import configparser
def load_config(path: str) -> configparser.ConfigParser:
config = configparser.ConfigParser()
config.read(path)
return config

View File

@ -1,4 +1,5 @@
import os
import configparser
import typing
from urllib.parse import urljoin
@ -9,16 +10,32 @@ from al_arr_sync.types import AnyDict
class RadarrClient:
def __init__(self, radarr_url: str, api_key: str) -> None:
self.radarr_url = radarr_url
def __init__(
self, api_url: str, api_key: str, folder_path: str, quality_profile: int = 4
) -> None:
self.api_url = api_url
self.api_key = api_key
self.folder_path = folder_path
self.quality_profile = quality_profile
self.http_session = Session()
@staticmethod
def from_env() -> "RadarrClient":
return RadarrClient(
radarr_url=os.environ["RADARR_API_URL"],
api_key=os.environ["RADARR_API_KEY"],
api_url=os.environ["SONARR_API_URL"],
api_key=os.environ["SONARR_API_KEY"],
folder_path=os.environ["SONARR_FOLDER_PATH"],
quality_profile=int(os.environ["SONARR_QUALITY_PROFILE"]),
)
@staticmethod
def from_config(cfg: configparser.ConfigParser) -> "RadarrClient":
return RadarrClient(
api_url=cfg["radarr"]["api_url"],
api_key=cfg["radarr"]["api_key"],
folder_path=cfg["radarr"]["folder_path"],
quality_profile=int(cfg["radarr"]["quality_profile"]),
)
def _prepare_request(
@ -28,7 +45,7 @@ class RadarrClient:
params: AnyDict = {},
json: typing.Optional[AnyDict] = None,
) -> PreparedRequest:
url = urljoin(self.radarr_url, endpoint)
url = urljoin(self.api_url, endpoint)
headers = {"X-Api-Key": self.api_key}
req = PreparedRequest()
@ -50,8 +67,8 @@ class RadarrClient:
"ignoreEpisodesWithFiles": False,
"ignoreEpisodesWithoutFiles": False,
},
"rootFolderPath": os.environ["RADARR_FOLDER_PATH"],
"qualityProfileId": int(os.environ["RADARR_QUALITY_PROFILE"]),
"rootFolderPath": self.folder_path,
"qualityProfileId": self.quality_profile,
}
)
req = self._prepare_request("/api/v3/movie", method="POST", json=payload)
@ -60,7 +77,7 @@ class RadarrClient:
if resp.status_code != 201:
resp_data = resp.json()
error_codes = map(lambda x: x["errorCode"], resp_data)
if "SeriesExistsValidator" in error_codes:
if "MovieExistsValidator" in error_codes:
raise Exception(f"Movie already exists: {show['title']}")
raise Exception(f"Failed to add movie: {show['title']}:\n{resp_data}")

View File

@ -1,3 +1,4 @@
import configparser
import os
import typing
from urllib.parse import urljoin
@ -9,16 +10,40 @@ from al_arr_sync.types import AnyDict
class SonarrClient:
def __init__(self, sonarr_url: str, api_key: str) -> None:
self.sonarr_url = sonarr_url
def __init__(
self,
api_url: str,
api_key: str,
folder_path: str,
quality_profile: int = 4,
language_profile: int = 1,
) -> None:
self.api_url = api_url
self.api_key = api_key
self.folder_path = folder_path
self.quality_profile = quality_profile
self.language_profile = language_profile
self.http_session = Session()
@staticmethod
def from_env() -> "SonarrClient":
return SonarrClient(
sonarr_url=os.environ["SONARR_API_URL"],
api_url=os.environ["SONARR_API_URL"],
api_key=os.environ["SONARR_API_KEY"],
folder_path=os.environ["SONARR_FOLDER_PATH"],
quality_profile=int(os.environ["SONARR_QUALITY_PROFILE"]),
language_profile=int(os.environ["SONARR_LANGUAGE_PROFILE"]),
)
@staticmethod
def from_config(cfg: configparser.ConfigParser) -> "SonarrClient":
return SonarrClient(
api_url=cfg["sonarr"]["api_url"],
api_key=cfg["sonarr"]["api_key"],
folder_path=cfg["sonarr"]["folder_path"],
quality_profile=int(cfg["sonarr"]["quality_profile"]),
language_profile=int(cfg["sonarr"]["language_profile"]),
)
def _prepare_request(
@ -28,7 +53,7 @@ class SonarrClient:
params: AnyDict = {},
json: typing.Optional[AnyDict] = None,
) -> PreparedRequest:
url = urljoin(self.sonarr_url, endpoint)
url = urljoin(self.api_url, endpoint)
headers = {"X-Api-Key": self.api_key}
req = PreparedRequest()
@ -50,9 +75,9 @@ class SonarrClient:
"searchForCutoffUnmetEpisodes": False,
"searchForMissingEpisodes": False,
},
"rootFolderPath": os.environ["SONARR_FOLDER_PATH"],
"qualityProfileId": int(os.environ["SONARR_QUALITY_PROFILE"]),
"languageProfileId": int(os.environ["SONARR_LANGUAGE_PROFILE"]),
"rootFolderPath": self.folder_path,
"qualityProfileId": self.quality_profile,
"languageProfileId": self.language_profile,
}
)
req = self._prepare_request("/api/v3/series", method="POST", json=payload)

View File

@ -4,8 +4,15 @@ AnyDict = typing.Dict[typing.Any, typing.Any]
class DlAutomator(typing.Protocol):
def lookup_series(self, query: str) -> typing.List[AnyDict]:
...
def add_series(self, *series: AnyDict):
...
class MediaTracker(typing.Protocol):
def currently_watching(self, username: str) -> typing.List[AnyDict]:
...

15
config.ini.example Normal file
View File

@ -0,0 +1,15 @@
[anilist]
username=
[sonarr]
api_url=
api_key=
folder_path=
quality_profile=4
language_profile=1
[radarr]
api_url=
api_key=
folder_path=
quality_profile=4

17
poetry.lock generated
View File

@ -249,17 +249,6 @@ wcwidth = "*"
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 = "pyyaml"
version = "6.0"
@ -352,7 +341,7 @@ python-versions = "*"
[metadata]
lock-version = "1.1"
python-versions = "^3.10"
content-hash = "d220af8512855de47e706e9e80c8120f420726c45563d746fb8e8349bb111c38"
content-hash = "f9fcae0bb7bb64dcd825eaba7f83945316eaa5c7a0747271ba135d028c48e275"
[metadata.files]
atomicwrites = []
@ -414,10 +403,6 @@ 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"},
]
pyyaml = [
{file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"},
{file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"},

View File

@ -6,7 +6,6 @@ authors = ["strNophix <nvdpoel01@gmail.com>"]
[tool.poetry.dependencies]
python = "^3.10"
python-dotenv = "^0.20.0"
requests = "^2.28.1"
[tool.poetry.dev-dependencies]