commit 43ec1c224a11ff5dd33bc8c179543422cf283874 Author: Junkbite Date: Tue Mar 10 10:35:33 2020 -0400 reset history 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