reset history

pull/37/head
Junkbite 4 years ago
commit 43ec1c224a

@ -0,0 +1,10 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
# Distribution / packaging
.Python
env/
dev-config.conf
output.txt

@ -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.

@ -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.

10
.gitignore vendored

@ -0,0 +1,10 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
# Distribution / packaging
.Python
env/
dev-config.conf
output.txt

@ -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"]

@ -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.

@ -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.

@ -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

@ -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)

@ -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()

@ -0,0 +1,2 @@
requests==2.20.0
configparser==3.5.0
Loading…
Cancel
Save