From 43ec1c224a11ff5dd33bc8c179543422cf283874 Mon Sep 17 00:00:00 2001 From: Junkbite Date: Tue, 10 Mar 2020 10:35:33 -0400 Subject: [PATCH] reset history --- .dockerignore | 10 + .github/ISSUE_TEMPLATE/bug_report.md | 29 ++ .github/ISSUE_TEMPLATE/feature_request.md | 14 + .gitignore | 10 + Dockerfile | 15 + LICENSE | 21 ++ README.md | 176 +++++++++ config.conf | 12 + config.py | 439 ++++++++++++++++++++++ index.py | 389 +++++++++++++++++++ requirements.txt | 2 + 11 files changed, 1117 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 config.conf create mode 100644 config.py create mode 100644 index.py create mode 100644 requirements.txt diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..27076a0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# Distribution / packaging +.Python +env/ + +dev-config.conf +output.txt \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..66c5dfc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,29 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: Junkbite + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**Version** +Syncarr version and if using docker or config file + +**Config** +Your config file and what versions of sonarr/radarr/lidarr you are using + +**Debug logs** +Enable debugging with +```int +[general] +log_level=10 + +or for docker LOG_LEVEL=10 +``` + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..b756029 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,14 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: 'enhancement' +assignees: 'Junkbite' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..27076a0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] + +# Distribution / packaging +.Python +env/ + +dev-config.conf +output.txt \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b7a556a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.6 + +ENV IS_IN_DOCKER 1 + +# default every 5 minutes +ENV SYNC_INTERVAL_SECONDS 300 + +WORKDIR /app + +COPY requirements.txt requirements.txt +RUN pip install -r requirements.txt + +COPY . . + +CMD ["python", "/app/index.py"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c93aba8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 syncarr + +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6b02456 --- /dev/null +++ b/README.md @@ -0,0 +1,176 @@ +# Syncarr +Syncs two Radarr/Sonarr/Lidarr servers through the web API. Useful for syncing a 4k radarr/sonarr instance to a 1080p radarr/sonarr instance. + +* Supports Radarr/Sonarr version 2 and 3. +* Can sync by `profile` name or `profile_id` +* Filter what media gets synced by `profile` name or `profile_id` +* Supports Docker for multiple instances +* Can set interval for syncing +* Support two way sync (one way by default) +* Set language profiles (Sonarr v3 only) + + +## Configuration + 1. Edit the config.conf file and enter your servers URLs and API keys for each server. + 2. Add the profile name (case insensitive) and movie path for the radarr instance the movies will be synced to: + ```ini + [radarrA] + url = https://4k.example.com:443 + key = XXXXX + + [radarrB] + url = http://127.0.0.1:8080 + key = XXXXX + profile = 1080p + path = /data/Movies + ``` + 3. Or if you want to sync two sonarr instances: + ```ini + [sonarrA] + url = https://4k.example.com:443 + key = XXXXX + + [sonarrB] + url = http://127.0.0.1:8080 + key = XXXXX + profile = 1080p + path = /data/Shows + + 4. Or if you want to sync two lidarr instances: + ```ini + [lidarrA] + url = https://lossless.example.com:443 + key = XXXXX + + [lidarrB] + url = http://127.0.0.1:8080 + key = XXXXX + profile = Standard + path = /data/Music + ``` + + **Note** you cannot have a mix of radarr, lidarr, or sonarr config setups at the same time. + + 5. Optional Configuration + ```ini + [sonarrA] + url = http://127.0.0.1:8080 + key = XXXXX + profile_filter = 1080p # add a filter to only sync contents belonging to this profile (can set by profile_filter_id as well) + + [sonarrB] + url = http://127.0.0.1:8080 + key = XXXXX + profile_id = 1 # Syncarr will try to find id from name but you can specify the id directly if you want + language = Vietnamese # can set language for new content added (Sonarr v3 only) (can set by language_id as well) + path = /data/Movies + + [general] + sync_bidirectionally = 1 # sync from instance A to B **AND** instance B to A + auto_search = 0 # search is automatically started on new content - disable by setting to 0 + monitor_new_content = 0 # set to 0 to never monitor new content synced or to 1 to always monitor new content synced + ``` + + **Note** If `sync_bidirectionally` is set to `1`, then instance A will require either `profile_id` or `profile` AND `path` as well + +--- + +## Requirements + * Python 3.6 or greater + * 2x Radarr, Sonarr, or Lidarr servers + +--- + +## How to Run + 1. install the needed python modules (you'll need pip or you can install the modules manually inside the `requirements.txt` file): + ```bash + pip install -r requirements.txt + ``` + 2. run this script directly or through a Cron: + ```bash + python index.py + ``` + +--- +## Docker Compose +This script can run through a docker container with a set interval (default every 5 minutes) + +``` +syncarr: + image: syncarr/syncarr:latest + container_name: syncarr + restart: unless-stopped + environment: + RADARR_A_URL: https://4k.example.com:443 + RADARR_A_KEY: XXXXX + RADARR_B_URL: http://127.0.0.1:8080 + RADARR_B_KEY: XXXXX + RADARR_B_PROFILE: 1080p + RADARR_B_PATH: /data/Movies + SYNC_INTERVAL_SECONDS: 300 +``` + +or + +``` +syncarr: + image: syncarr/syncarr:latest + container_name: syncarr + restart: unless-stopped + environment: + SONARR_A_URL: https://4k.example.com:443 + SONARR_A_KEY: XXXXX + SONARR_B_URL: http://127.0.0.1:8080 + SONARR_B_KEY: XXXXX + SONARR_B_PROFILE: 1080p + SONARR_B_PATH: /data/Shows + SYNC_INTERVAL_SECONDS: 300 +``` + +or + +``` +syncarr: + image: syncarr/syncarr:latest + container_name: syncarr + restart: unless-stopped + environment: + LIDARR_A_URL: https://lossless.example.com:443 + LIDARR_A_KEY: XXXXX + LIDARR_B_URL: http://127.0.0.1:8080 + LIDARR_B_KEY: XXXXX + LIDARR_B_PROFILE: Standard + LIDARR_B_PATH: /data/Music + SYNC_INTERVAL_SECONDS: 300 +``` + +--- +## Docker +For just plain docker (radarr example): + +``` +docker run -it --rm --name syncarr -e RADARR_A_URL=https://4k.example.com:443 -e RADARR_A_KEY=XXXXX -e RADARR_B_URL=http://127.0.0.1:8080 -e RADARR_B_KEY=XXXXX -e RADARR_B_PROFILE=1080p -e RADARR_B_PATH=/data/Movies -e SYNC_INTERVAL_SECONDS=300 syncarr/syncarr +``` + +**Notes** +* You can also specify the `PROFILE_ID` directly through the `*ARR_A_PROFILE_ID` and `*ARR_B_PROFILE_ID` ENV variables. +To filter by profile in docker use `ARR_A_PROFILE_FILTER` or `ARR_A_PROFILE_FILTER_ID` ENV variables. (same for `*arr_B` in bidirectional sync) +* Language for new content (Sonarr v3 only) can be set by `SONARR_B_LANGUAGE` or `SONARR_B_LANGUAGE_ID` (and `SONARR_B` if bidirectional sync) +* Set bidirectional sync with `SYNCARR_BIDIRECTIONAL_SYNC=1` +* Set disable auto searching on new content with `SYNCARR_AUTO_SEARCH=0` +* Set if you wanted to monitor new content with `SYNCARR_MONITOR_NEW_CONTENT=1/0` + +--- +## Troubleshooting +If you need to troubleshoot syncarr, then you can either set the log level through the config file: + +```ini +[general] +log_level = 10 +``` + +Or in docker, set the `LOG_LEVEL` ENV variable. Default is set to `20` (info only) but you can set to `10` to get debug info as well. When pasting debug logs online, **make sure to remove any apikeys and any other data you don't want others to see.** + +--- +## Disclaimer +Back up your instances before trying this out. I am not responsible for any lost data. diff --git a/config.conf b/config.conf new file mode 100644 index 0000000..c8d85ba --- /dev/null +++ b/config.conf @@ -0,0 +1,12 @@ +# [radarrA] +# url = https://example.com:443 +# key = FCKGW-RHQQ2-YXRKT-8TG6W-2B7Q8 + +# [radarrB] +# url = http://127.0.0.1:8080 +# key = FCKGW-RHQQ2-YXRKT-8TG6W-2B7Q8 +# profile_id = 1 +# path = /data/4k_Movies + +# [general] +# sync_bidirectionally = 0 diff --git a/config.py b/config.py new file mode 100644 index 0000000..a171ab1 --- /dev/null +++ b/config.py @@ -0,0 +1,439 @@ +#!/usr/bin/env python + +import logging +import os +import sys +import time + +import configparser + + +DEV = os.environ.get('DEV') +VER = '1.7.6' +DEBUG_LINE = '-' * 20 + +V1_API_PATH = 'v1/' +V2_API_PATH = '' +V3_API_PATH = 'v3/' + +# https://github.com/lidarr/Lidarr/wiki/Artist +# https://github.com/Radarr/Radarr/wiki/API:Movie +# https://github.com/Sonarr/Sonarr/wiki/Series + +######################################################################################################################## +# get docker based ENV vars +is_in_docker = os.environ.get('IS_IN_DOCKER') +instance_sync_interval_seconds = os.environ.get('SYNC_INTERVAL_SECONDS') +if instance_sync_interval_seconds: + instance_sync_interval_seconds = int(instance_sync_interval_seconds) + +######################################################################################################################## + + +def ConfigSectionMap(section): + '''get all config options from config file''' + dict1 = {} + options = config.options(section) + for option in options: + try: + dict1[option] = config.get(section, option) + except: + print("exception on %s!" % option) + dict1[option] = None + return dict1 + + +def get_config_value(env_key, config_key, config_section): + if is_in_docker: + value = os.environ.get(env_key) + if value is not None: + return value + + try: + _config = ConfigSectionMap(config_section) + return _config.get(config_key) + except configparser.NoSectionError: + return '' + +######################################################################################################################## +# load config file + + +BASE_CONFIG = 'config.conf' +if DEV: + settingsFilename = os.path.join(os.getcwd(), 'dev-{}'.format(BASE_CONFIG)) +else: + settingsFilename = os.path.join(os.getcwd(), BASE_CONFIG) + +config = configparser.ConfigParser() +config.read(settingsFilename) + +######################################################################################################################## +# get config settings from ENV or config files for Radarr +radarrA_url = get_config_value('RADARR_A_URL', 'url', 'radarrA') +radarrA_key = get_config_value('RADARR_A_KEY', 'key', 'radarrA') +radarrA_profile = get_config_value('RADARR_A_PROFILE', 'profile', 'radarrA') +radarrA_profile_id = get_config_value( + 'RADARR_A_PROFILE_ID', 'profile_id', 'radarrA') +radarrA_profile_filter = get_config_value( + 'RADARR_A_PROFILE_FILTER', 'profile_filter', 'radarrA') +radarrA_profile_filter_id = get_config_value( + 'RADARR_A_PROFILE_FILTER_ID', 'profile_filter_id', 'radarrA') +radarrA_path = get_config_value('RADARR_A_PATH', 'path', 'radarrA') + +radarrB_url = get_config_value('RADARR_B_URL', 'url', 'radarrB') +radarrB_key = get_config_value('RADARR_B_KEY', 'key', 'radarrB') +radarrB_profile = get_config_value('RADARR_B_PROFILE', 'profile', 'radarrB') +radarrB_profile_id = get_config_value( + 'RADARR_B_PROFILE_ID', 'profile_id', 'radarrB') +radarrB_profile_filter = get_config_value( + 'RADARR_B_PROFILE_FILTER', 'profile_filter', 'radarrB') +radarrB_profile_filter_id = get_config_value( + 'RADARR_B_PROFILE_FILTER_ID', 'profile_filter_id', 'radarrB') +radarrB_path = get_config_value('RADARR_B_PATH', 'path', 'radarrB') + +# get config settings from ENV or config files for Sonarr +sonarrA_url = get_config_value('SONARR_A_URL', 'url', 'sonarrA') +sonarrA_key = get_config_value('SONARR_A_KEY', 'key', 'sonarrA') +sonarrA_path = get_config_value('SONARR_A_PATH', 'path', 'sonarrA') +sonarrA_profile = get_config_value('SONARR_A_PROFILE', 'profile', 'sonarrA') +sonarrA_profile_id = get_config_value( + 'SONARR_A_PROFILE_ID', 'profile_id', 'sonarrA') +sonarrA_profile_filter = get_config_value( + 'SONARR_A_PROFILE_FILTER', 'profile_filter', 'sonarrA') +sonarrA_profile_filter_id = get_config_value( + 'SONARR_A_PROFILE_FILTER_ID', 'profile_filter_id', 'sonarrA') +sonarrA_language = get_config_value('SONARR_A_LANGUAGE', 'language', 'sonarrA') +sonarrA_language_id = get_config_value( + 'SONARR_A_LANGUAGE_ID', 'language_id', 'sonarrA') + + +sonarrB_url = get_config_value('SONARR_B_URL', 'url', 'sonarrB') +sonarrB_key = get_config_value('SONARR_B_KEY', 'key', 'sonarrB') +sonarrB_path = get_config_value('SONARR_B_PATH', 'path', 'sonarrB') +sonarrB_profile = get_config_value('SONARR_B_PROFILE', 'profile', 'sonarrB') +sonarrB_profile_id = get_config_value( + 'SONARR_B_PROFILE_ID', 'profile_id', 'sonarrB') +sonarrB_profile_filter = get_config_value( + 'SONARR_A_PROFILE_FILTER', 'profile_filter', 'sonarrB') +sonarrB_profile_filter_id = get_config_value( + 'SONARR_A_PROFILE_FILTER_ID', 'profile_filter_id', 'sonarrB') +sonarrB_language = get_config_value('SONARR_B_LANGUAGE', 'language', 'sonarrB') +sonarrB_language_id = get_config_value( + 'SONARR_B_LANGUAGE_ID', 'language_id', 'sonarrB') + +# get config settings from ENV or config files for Lidarr +lidarrA_url = get_config_value('LIDARR_A_URL', 'url', 'lidarrA') +lidarrA_key = get_config_value('LIDARR_A_KEY', 'key', 'lidarrA') +lidarrA_profile = get_config_value('LIDARR_A_PROFILE', 'profile', 'lidarrA') +lidarrA_profile_id = get_config_value( + 'LIDARR_A_PROFILE_ID', 'profile_id', 'lidarrA') +lidarrA_profile_filter = get_config_value( + 'LIDARR_A_PROFILE_FILTER', 'profile_filter', 'lidarrA') +lidarrA_profile_filter_id = get_config_value( + 'LIDARR_A_PROFILE_FILTER_ID', 'profile_filter_id', 'lidarrA') +lidarrA_path = get_config_value('LIDARR_A_PATH', 'path', 'lidarrA') + +lidarrB_url = get_config_value('LIDARR_B_URL', 'url', 'lidarrB') +lidarrB_key = get_config_value('LIDARR_B_KEY', 'key', 'lidarrB') +lidarrB_profile = get_config_value('LIDARR_B_PROFILE', 'profile', 'lidarrB') +lidarrB_profile_id = get_config_value( + 'LIDARR_B_PROFILE_ID', 'profile_id', 'lidarrB') +lidarrB_profile_filter = get_config_value( + 'LIDARR_A_PROFILE_FILTER', 'profile_filter', 'lidarrB') +lidarrB_profile_filter_id = get_config_value( + 'LIDARR_A_PROFILE_FILTER_ID', 'profile_filter_id', 'lidarrB') +lidarrB_path = get_config_value('LIDARR_B_PATH', 'path', 'lidarrB') + +# get general conf options +sync_bidirectionally = get_config_value( + 'SYNCARR_BIDIRECTIONAL_SYNC', 'bidirectional', 'general') +if sync_bidirectionally is not None: + sync_bidirectionally = int(sync_bidirectionally) + +auto_search = get_config_value( + 'SYNCARR_AUTO_SEARCH', 'auto_search', 'general') +if auto_search is not None: + auto_search = int(auto_search) +# set to search if config not set +if auto_search is None: + auto_search = 1 + +monitor_new_content = get_config_value( + 'SYNCARR_MONITOR_NEW_CONTENT', 'monitor_new_content', 'general') +if monitor_new_content is not None: + monitor_new_content = int(monitor_new_content) + + +######################################################################################################################## +# setup logger + +# CRITICAL 50, ERROR 40, WARNING 3, INFO 20, DEBUG 10, NOTSET 0 +log_level = get_config_value('LOG_LEVEL', 'log_level', 'general') or 20 +if log_level: + log_level = int(log_level) + +logger = logging.getLogger() +logger.setLevel(log_level) + +logFormatter = logging.Formatter( + "%(asctime)s [%(threadName)-12.12s] [%(levelname)-5.5s] %(message)s") + +# log to txt file +fileHandler = logging.FileHandler("./output.txt") +fileHandler.setFormatter(logFormatter) +logger.addHandler(fileHandler) + +# log to std out +consoleHandler = logging.StreamHandler(sys.stdout) +consoleHandler.setFormatter(logFormatter) +logger.addHandler(consoleHandler) + +logger.debug('Syncarr Version {}'.format(VER)) +logger.info('log level {}'.format(log_level)) + +if DEV: + logger.info('-----------------DEVV-----------------') +######################################################################################################################## +# make sure we have radarr, lidarr, OR sonarr +if ( + (sonarrA_url and radarrA_url) or + (sonarrA_url and radarrB_url) or + (sonarrA_url and lidarrA_url) or + (sonarrA_url and lidarrB_url) or + + (radarrA_url and lidarrA_url) or + (radarrA_url and lidarrB_url) or + (radarrB_url and lidarrA_url) or + (radarrB_url and lidarrB_url) +): + logger.error( + 'cannot have more than one *arr type profile(s) setup at the same time') + sys.exit(0) + +######################################################################################################################## +# get generic instanceA/B variables +instanceA_url = '' +instanceA_key = '' +instanceA_path = '' +instanceA_profile = '' +instanceA_profile_id = '' +instanceA_profile_filter = '' +instanceA_language_id = '' +instanceA_language = '' + +instanceB_url = '' +instanceB_key = '' +instanceB_path = '' +instanceB_profile = '' +instanceB_profile_id = '' +instanceB_profile_filter = '' +instanceB_language_id = '' +instanceB_language = '' + + +api_version = '' # we are going to detect what API version we are on +tested_api_version = False # only get api version once + + +api_content_path = '' # url path to add content +api_profile_path = '' # url path to get quality profiles +api_status_path = '' # url path to check on server status +api_language_path = '' # url to get lanaguge profiles + +is_radarr = False +is_sonarr = False +is_lidarr = False + +content_id_key = '' # the unique id for a content item + +if radarrA_url and radarrB_url: + instanceA_url = radarrA_url + instanceA_key = radarrA_key + instanceA_profile = radarrA_profile + instanceA_profile_id = radarrA_profile_id + instanceA_profile_filter = radarrA_profile_filter + instanceA_profile_filter_id = radarrA_profile_filter_id + instanceA_path = radarrA_path + + instanceB_url = radarrB_url + instanceB_key = radarrB_key + instanceB_profile = radarrB_profile + instanceB_profile_id = radarrB_profile_id + instanceB_profile_filter = radarrB_profile_filter + instanceB_profile_filter_id = radarrB_profile_filter_id + instanceB_path = radarrB_path + + api_version = V2_API_PATH # radarr v2 doesnt have version in api url + api_content_path = 'movie' + api_profile_path = 'profile' + api_status_path = 'system/status' + + content_id_key = 'tmdbId' + is_radarr = True + +elif lidarrA_url and lidarrB_url: + instanceA_url = lidarrA_url + instanceA_key = lidarrA_key + instanceA_profile = lidarrA_profile + instanceA_profile_id = lidarrA_profile_id + instanceA_profile_filter = lidarrA_profile_filter + instanceA_profile_filter_id = lidarrA_profile_filter_id + instanceA_path = lidarrA_path + + instanceB_url = lidarrB_url + instanceB_key = lidarrB_key + instanceB_profile = lidarrB_profile + instanceB_profile_id = lidarrB_profile_id + instanceB_profile_filter = lidarrB_profile_filter + instanceB_profile_filter_id = lidarrB_profile_filter_id + instanceB_path = lidarrB_path + + api_version = V1_API_PATH + api_content_path = 'artist' + api_profile_path = 'qualityprofile' + api_status_path = 'system/status' + + content_id_key = 'foreignArtistId' + is_lidarr = True + +elif sonarrA_url and sonarrB_url: + instanceA_url = sonarrA_url + instanceA_key = sonarrA_key + instanceA_path = sonarrA_path + instanceA_profile = sonarrA_profile + instanceA_profile_id = sonarrA_profile_id + instanceA_profile_filter = sonarrA_profile_filter + instanceA_profile_filter_id = sonarrA_profile_filter_id + instanceA_language = sonarrA_language + instanceA_language_id = sonarrA_language_id + + instanceB_url = sonarrB_url + instanceB_key = sonarrB_key + instanceB_path = sonarrB_path + instanceB_profile = sonarrB_profile + instanceB_profile_id = sonarrB_profile_id + instanceB_profile_filter = sonarrB_profile_filter + instanceB_profile_filter_id = sonarrB_profile_filter_id + instanceB_language = sonarrB_language + instanceB_language_id = sonarrB_language_id + + api_version = V3_API_PATH # for sonarr try v3 first + api_content_path = 'series' + api_profile_path = 'qualityprofile' + api_status_path = 'system/status' + api_language_path = 'languageprofile' + + content_id_key = 'tvdbId' + is_sonarr = True + +######################################################################################################################## +# path generators + + +def get_path(instance_url, api_path, key, changed_api_version=False): + global api_version, api_profile_path + + if changed_api_version: + api_version = V3_API_PATH + + # for sonarr - we check v3 first then v2 + if is_sonarr and changed_api_version: + api_version = V2_API_PATH + api_profile_path = 'profile' + + logger.debug(DEBUG_LINE) + logger.debug({ + 'instance_url': instance_url, + 'api_path': api_path, + 'api_version': api_version, + 'is_sonarr': is_sonarr, + 'api_profile_path': api_profile_path, + 'changed_api_version': changed_api_version, + }) + + url = f"{instance_url}/api/{api_version}{api_path}?apikey={key}" + return url + + +def get_status_path(instance_url, key, changed_api_version): + url = get_path(instance_url, api_status_path, key, changed_api_version) + logger.debug('get_status_path: {}'.format(url)) + return url + + +def get_content_path(instance_url, key): + url = get_path(instance_url, api_content_path, key) + logger.debug('get_content_path: {}'.format(url)) + return url + + +def get_language_path(instance_url, key): + url = get_path(instance_url, api_language_path, key) + logger.debug('get_language_path: {}'.format(url)) + return url + + +def get_profile_path(instance_url, key): + url = get_path(instance_url, api_profile_path, key) + logger.debug('get_profile_path: {}'.format(url)) + return url + +######################################################################################################################## +# check for required fields + + +logger.debug({ + 'instanceA_url': instanceA_url, + 'instanceA_key': instanceA_key, + 'instanceA_path': instanceA_path, + 'instanceB_url': instanceB_url, + 'instanceB_key': instanceB_key, + 'instanceB_path': instanceB_path, + 'api_content_path': api_content_path, + 'api_profile_path': api_profile_path, + 'api_language_path': api_language_path, + 'is_sonarr': is_sonarr, + 'is_lidarr': is_lidarr, + 'monitor_new_content': monitor_new_content, + 'sync_bidirectionally': sync_bidirectionally, + 'auto_search': auto_search, + 'api_version': api_version, +}) + +if not instanceA_url: + logger.error('missing URL for instance A') + sys.exit(0) + +if not instanceA_key: + logger.error('missing API key for instance A') + sys.exit(0) + +if not instanceA_url: + logger.error('missing URL for instance B') + sys.exit(0) + +if not instanceB_key: + logger.error('missing API key for instance B') + sys.exit(0) + +if not api_content_path: + logger.error('missing api_content_path') + sys.exit(0) + +if not content_id_key: + logger.error('missing content_id_key') + sys.exit(0) + +# if two way sync need instance A path and profile +if sync_bidirectionally: + assert instanceA_path + if not instanceB_profile_id and not instanceB_profile: + logger.error( + 'profile_id or profile is required for *arr instance A if sync bidirectionally is enabled') + sys.exit(0) + + +if not instanceB_profile_id and not instanceB_profile: + logger.error('profile_id or profile is required for *arr instance B') + sys.exit(0) diff --git a/index.py b/index.py new file mode 100644 index 0000000..4ec8b60 --- /dev/null +++ b/index.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python + +import os +import logging +import requests +import json +import configparser +import sys +import time + +from config import ( + instanceA_url, instanceA_key, instanceA_path, instanceA_profile, + instanceA_profile_id, instanceA_profile_filter, instanceA_profile_filter_id, + instanceA_language_id, instanceA_language, + + instanceB_url, instanceB_key, instanceB_path, instanceB_profile, + instanceB_profile_id, instanceB_profile_filter, instanceB_profile_filter_id, + instanceB_language_id, instanceB_language, + + content_id_key, logger, is_sonarr, is_radarr, is_lidarr, + get_status_path, get_content_path, get_profile_path, get_language_path, + + is_in_docker, instance_sync_interval_seconds, + sync_bidirectionally, auto_search, monitor_new_content, + tested_api_version, api_version, V3_API_PATH, +) + + +def get_new_content_payload(content, instance_path, instance_profile_id, instance_url, instance_language_id=None): + global monitor_new_content, auto_search + + images = content.get('images') + for image in images: + image['url'] = '{0}{1}'.format(instance_url, image.get('url')) + + monitored = content.get('monitored') + if monitor_new_content is not None: + monitored = True if monitor_new_content else False + + payload = { + content_id_key: content.get(content_id_key), + 'qualityProfileId': instance_profile_id or content.get('qualityProfileId'), + 'monitored': monitored, + 'rootFolderPath': instance_path, + 'images': images, + } + + add_options = content.get('addOptions', {}) + search_missing = True if auto_search else False + + if is_sonarr: + payload['title'] = content.get('title') + payload['titleSlug'] = content.get('titleSlug') + payload['seasons'] = content.get('seasons') + payload['year'] = content.get('year') + payload['tvRageId'] = content.get('tvRageId') + payload['seasonFolder'] = content.get('seasonFolder') + payload['languageProfileId'] = instance_language_id if instance_language_id else content.get( + 'languageProfileId') + payload['tags'] = content.get('tags') + payload['seriesType'] = content.get('seriesType') + payload['useSceneNumbering'] = content.get('useSceneNumbering') + payload['addOptions'] = { + **add_options, + **{'searchForMissingEpisodes': search_missing} + } + + elif is_radarr: + payload['title'] = content.get('title') + payload['year'] = content.get('year') + payload['tmdbId'] = content.get('tmdbId') + payload['titleSlug'] = content.get('titleSlug') + payload['addOptions'] = { + **add_options, + **{'searchForMovie': search_missing} + } + + elif is_lidarr: + payload['artistName'] = content.get('artistName') + payload['albumFolder'] = content.get('albumFolder') + payload['metadataProfileId'] = content.get('metadataProfileId') + payload['addOptions'] = { + **add_options, + **{ + "monitored": monitored, + "searchForMissingAlbums": search_missing + } + } + + logger.debug(payload) + return payload + + +def get_profile_from_id(instance_session, instance_url, instance_key, instance_profile, instance_name=''): + instance_profile_url = get_profile_path(instance_url, instance_key) + profiles_response = instance_session.get(instance_profile_url) + if profiles_response.status_code != 200: + logger.error( + f'Could not get profile id from {instance_profile_url}') + sys.exit(0) + + instance_profiles = None + try: + instance_profiles = profiles_response.json() + except: + logger.error( + f'Could not decode profile id from {instance_profile_url}') + sys.exit(0) + + profile = next((item for item in instance_profiles if item["name"].lower() == instance_profile.lower()), False) + if not profile: + logger.error('Could not find profile_id for instance {} profile {}'.format( + instance_name, instance_profile)) + sys.exit(0) + + instance_profile_id = profile.get('id') + logger.debug( + f'found profile_id (instance{instance_name}) "{instance_profile_id}" from profile "{instance_profile}"') + + return instance_profile_id + + +def get_language_from_id(instance_session, instance_url, instance_key, instance_language, instance_name=''): + instance_language_url = get_language_path(instance_url, instance_key) + language_response = instance_session.get(instance_language_url) + if language_response.status_code != 200: + logger.error( + f'Could not get language id from (instance{instance_name}) {instance_language_url} - only works on sonarr v3') + sys.exit(0) + + instance_languages = None + try: + instance_languages = language_response.json() + except: + logger.error( + f'Could not decode language id from {instance_language_url}') + sys.exit(0) + + instance_languages = instance_languages[0]['languages'] + language = next((item for item in instance_languages + if item.get('language', {}).get('name').lower() == instance_language.lower()), False) + + logger.error(language) + if not language: + logger.error(f'Could not find language_id for instance {instance_name} and language {instance_language}') + sys.exit(0) + + instance_language_id = language.get('language', {}).get('id') + logger.debug(f'found id "{instance_language_id}" from language "{instance_language}" for instance {instance_name}') + + if instance_language_id is None: + logger.error(f'language_id is None for instance {instance_name} and language {instance_language}') + sys.exit(0) + + return instance_language_id + + +def sync_servers(instanceA_contents, instanceB_language_id, instanceB_contentIds, + instanceB_path, instanceB_profile_id, instanceB_session, + instanceB_url, profile_filter_id, instanceB_key): + search_ids = [] + + # if given instance A profile id then we want to filter out content without that id + if profile_filter_id: + logging.info(f'only filtering content with profile_filter_id {profile_filter_id}') + + # for each content id in instance A, check if it needs to be synced to instance B + for content in instanceA_contents: + if content[content_id_key] not in instanceB_contentIds: + title = content.get('title') or content.get('artistName') + + # if given this, we want to filter from instance by profile id + if profile_filter_id: + content_profile_id = content.get('qualityProfileId') + if profile_filter_id != content_profile_id: + logging.debug(f'Skipping content {title} - mismatched profile_id {content_profile_id} with filter_id {profile_filter_id}') + continue + + logging.info(f'syncing content title "{title}"') + + # get the POST payload and sync content to instance B + payload = get_new_content_payload( + content=content, + instance_path=instanceB_path, + instance_profile_id=instanceB_profile_id, + instance_url=instanceB_url, + instance_language_id=instanceB_language_id, + ) + instanceB_content_url = get_content_path(instanceB_url, instanceB_key) + sync_response = instanceB_session.post(instanceB_content_url, data=json.dumps(payload)) + + # check response and save content id for searching later on if success + if sync_response.status_code != 201 and sync_response.status_code != 200: + logger.error(f'server sync error for {title} - response: {sync_response.text}') + else: + try: + search_ids.append(int(sync_response.json()['id'])) + except: + logger.error(f'Could not decode sync response from {instanceB_content_url}') + logging.info('content title "{0}" synced successfully'.format(title)) + + logging.info(f'{len(search_ids)} contents synced successfully') + + +def get_instance_contents(instance_url, instance_key, instance_session, instance_name=''): + instance_contentIds = [] + + instance_content_url = get_content_path(instance_url, instance_key) + instance_contents = instance_session.get(instance_content_url) + + if instance_contents.status_code != 200: + logger.error('instance{} server error - response {}'.format(instance_name, instance_contents.status_code)) + sys.exit(0) + else: + try: + instance_contents = instance_contents.json() + except: + logger.error(f'Could not decode contents from {instance_content_url}') + sys.exit(0) + + for content_to_sync in instance_contents: + instance_contentIds.append(content_to_sync[content_id_key]) + + logger.debug('{} contents in instance{}'.format(len(instance_contentIds), instance_name)) + return instance_contents, instance_contentIds + + +def check_status(instance_session, instance_url, instance_key, instance_name='', changed_api_version=False): + global api_version + + instance_status_url = get_status_path( + instance_url, instance_key, changed_api_version) + error_message = f'Could not connect to instance{instance_name}: {instance_status_url}' + status_response = None + + try: + status_response = instance_session.get(instance_status_url) + + # only test again if not lidarr and we haven't tested v3 already + if status_response.status_code != 200 and not changed_api_version and not is_lidarr: + logger.debug(f'check api_version again') + status_response = check_status(instance_session, instance_url, instance_key, instance_name, True) + elif status_response.status_code != 200: + logger.error(error_message) + sys.exit(0) + + except: + if not changed_api_version and not is_lidarr: + logger.debug(f'check api_version again exception') + status_response = check_status( + instance_session, instance_url, instance_key, instance_name, True) + + if status_response is None: + logger.error(error_message) + sys.exit(0) + else: + try: + status_response = status_response.json() + except Exception as error: + if not isinstance(status_response, dict): + logger.error( + f"Could not retrieve status for {instance_status_url}: {status_response} - {error}") + sys.exit(0) + + if(status_response.get('error')): + logger.error(f"{instance_status_url} error {status_response.get('error')}") + sys.exit(0) + + logger.debug(f"{instance_status_url} version {status_response.get('version')}") + + return status_response + + +def sync_content(): + global instanceA_profile_id, instanceA_profile, instanceB_profile_id, instanceB_profile, instanceA_profile_filter, instanceA_profile_filter_id, instanceB_profile_filter, instanceB_profile_filter_id, tested_api_version, instanceA_language_id, instanceA_language, instanceB_language_id, instanceB_language + + # get sessions + instanceA_session = requests.Session() + instanceA_session.trust_env = False + instanceB_session = requests.Session() + instanceB_session.trust_env = False + + if not tested_api_version: + check_status(instanceA_session, instanceA_url, instanceA_key, instance_name='A') + check_status(instanceB_session, instanceB_url, instanceB_key, instance_name='B') + tested_api_version = True + + # if given a profile instead of a profile id then try to find the profile id + if not instanceA_profile_id and instanceA_profile: + instanceA_profile_id = get_profile_from_id( + instanceA_session, instanceA_url, instanceA_key, instanceA_profile, 'A') + if not instanceB_profile_id and instanceB_profile: + instanceB_profile_id = get_profile_from_id( + instanceB_session, instanceB_url, instanceB_key, instanceB_profile, 'B') + logger.debug({ + 'instanceA_profile_id': instanceA_profile_id, + 'instanceA_profile': instanceA_profile, + 'instanceB_profile_id': instanceB_profile_id, + 'instanceB_profile': instanceB_profile, + }) + + # do the same for profile id filters if they exist + if not instanceA_profile_filter_id and instanceA_profile_filter: + instanceA_profile_filter_id = get_profile_from_id( + instanceA_session, instanceA_url, instanceA_key, instanceA_profile_filter, 'A') + if not instanceB_profile_filter_id and instanceB_profile_filter: + instanceB_profile_filter_id = get_profile_from_id( + instanceB_session, instanceB_url, instanceB_key, instanceB_profile_filter, 'B') + logger.debug({ + 'instanceAprofile_filter_id': instanceA_profile_filter_id, + 'instanceAprofile_filter': instanceA_profile_filter, + 'instanceBprofile_filter_id': instanceB_profile_filter_id, + 'instanceBprofile_filter': instanceB_profile_filter, + }) + + # if given language instead of language id then try to find the lanaguage id + # only for sonarr v3 + if is_sonarr: + if not instanceA_language_id and instanceA_language: + instanceA_language_id = get_language_from_id( + instance_session=instanceA_session, + instance_url=instanceA_url, + instance_key=instanceA_key, + instance_language=instanceA_language, + instance_name='A' + ) + + if not instanceB_language_id and instanceB_language: + instanceB_language_id = get_language_from_id( + instance_session=instanceB_session, + instance_url=instanceB_url, + instance_key=instanceB_key, + instance_language=instanceB_language, + instance_name='B' + ) + + logger.debug({ + 'instanceA_language_id': instanceA_language_id, + 'instanceA_language': instanceA_language, + 'instanceB_language_id': instanceB_language_id, + 'instanceB_language': instanceB_language, + 'is_sonarr': is_sonarr, + 'api_version': api_version, + }) + + # get contents to compare + instanceA_contents, instanceA_contentIds = get_instance_contents(instanceA_url, instanceA_key, instanceA_session, instance_name='A') + instanceB_contents, instanceB_contentIds = get_instance_contents(instanceB_url, instanceB_key, instanceB_session, instance_name='B') + + logger.info('syncing content from instance A to instance B') + sync_servers( + instanceA_contents=instanceA_contents, + instanceB_contentIds=instanceB_contentIds, + instanceB_language_id=instanceB_language_id, + instanceB_path=instanceB_path, + instanceB_profile_id=instanceB_profile_id, + instanceB_session=instanceB_session, + instanceB_url=instanceB_url, + profile_filter_id=instanceA_profile_filter_id, + instanceB_key=instanceB_key, + ) + + # if given bidirectional flag then sync from instance B to instance A + if sync_bidirectionally: + logger.info('syncing content from instance B to instance A') + + sync_servers( + instanceA_contents=instanceB_contents, + instanceB_contentIds=instanceA_contentIds, + instanceB_language_id=instanceA_language_id, + instanceB_path=instanceA_path, + instanceB_profile_id=instanceA_profile_id, + instanceB_session=instanceA_session, + instanceB_url=instanceA_url, + profile_filter_id=instanceB_profile_filter_id, + instanceB_key=instanceA_key, + ) + +######################################################################################################################## + +if is_in_docker: + logger.info('syncing every {} seconds'.format(instance_sync_interval_seconds)) + +sync_content() + +if is_in_docker: + while True: + time.sleep(instance_sync_interval_seconds) + sync_content() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..59f5bba --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests==2.20.0 +configparser==3.5.0 \ No newline at end of file