fix merge conflict

pull/80/head 1.13.0
Junkbite 3 years ago
commit e57aa9a330

@ -1,23 +1,25 @@
# 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.
* Supports Radarr/Sonarr v3.
* 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)
* Skip content with missing files (Radarr only)
* Set language profiles (Sonarr v3 only)
* Skip content with missing files
* Set language profiles
* Filter syncing by content file quality (Radarr only)
* Filter syncing by tags (Sonarr/Radarr v3 only)
* Filter syncing by tags (Sonarr/Radarr)
* Allow for a test run using `test_run` flag (does everything but actually sync)
## 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
```ini
[radarrA]
url = https://4k.example.com:443
key = XXXXX
@ -28,7 +30,9 @@ Syncs two Radarr/Sonarr/Lidarr servers through the web API. Useful for syncing a
profile = 1080p
path = /data/Movies # if not given will use RadarrA path for each movie - may not be what you want!
```
3. Or if you want to sync two Sonarr instances:
```ini
[sonarrA]
url = https://4k.example.com:443
@ -39,8 +43,9 @@ Syncs two Radarr/Sonarr/Lidarr servers through the web API. Useful for syncing a
key = XXXXX
profile = 1080p
path = /data/Shows
4. Or if you want to sync two Lidarr instances:
5.
```ini
[lidarrA]
url = https://lossless.example.com:443
@ -55,15 +60,16 @@ Syncs two Radarr/Sonarr/Lidarr servers through the web API. Useful for syncing a
**Note** you cannot have a mix of Radarr, Lidarr, or Sonarr config setups at the same time.
5. Optional Configuration
6. Optional Configuration
```ini
[*arrA]
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)
quality_match = HD- # (Radarr only) regex match to only sync content that matches the set quality (ie if set to 1080p then only movies with matching downloaded quality of 1080p will be synced)
tag_filter = Horror # (Sonarr/Radarr v3 only) sync movies by tag name (seperate multiple tags by comma (no spaces) ie horror,comedy,action)
tag_filter_id = 2 # (Sonarr/Radarr v3 only) sync movies by tag id (seperate multiple tags by comma (no spaces) ie 2,3,4)
tag_filter = Horror # (Sonarr/Radarr) sync movies by tag name (seperate multiple tags by comma (no spaces) ie horror,comedy,action)
tag_filter_id = 2 # (Sonarr/Radarr) sync movies by tag id (seperate multiple tags by comma (no spaces) ie 2,3,4)
blacklist = movie-name-12,movie-name-43,432534,8e38819d-71be-9e7d-b41d-f1df91b01d3f # comma seperated list of content slugs OR IDs you want to never sync from A to B (no spaces)
# the slug is the part of the URL after "/movies/" (for Radarr), "/series/" (for Sonarr), or "/artist/" (for Lidarr)
@ -71,15 +77,16 @@ Syncs two Radarr/Sonarr/Lidarr servers through the web API. Useful for syncing a
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)
language = Vietnamese # can set language for new content added (Sonarr) (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
sync_bidirectionally = 1 # sync from instance A to B **AND** instance B to A (default 0)
auto_search = 0 # search is automatically started on new content - disable by setting to 0 (default 1)
skip_missing = 1 # content with missing files are skipped on sync - disable by setting to 0 (default 1) (Radarr only)
monitor_new_content = 0 # set to 0 to never monitor new content synced or to 1 to always monitor new content synced (default 1)
test_run = 1 # enable test mode - will run through sync program but will not actually sync content
test_run = 1 # enable test mode - will run through sync program but will not actually sync content (default 0)
sync_monitor = 1 # if set to 1 will sync if the content is monitored or not to instance B (default 0)
```
**Note** If `sync_bidirectionally` is set to `1`, then instance A will require either `profile_id` or `profile` AND `path` as well
@ -106,7 +113,7 @@ Syncs two Radarr/Sonarr/Lidarr servers through the web API. Useful for syncing a
## Docker Compose
This script can run through a docker container with a set interval (default every 5 minutes)
```
```bash
syncarr:
image: syncarr/syncarr:latest
container_name: syncarr
@ -123,7 +130,7 @@ syncarr:
or
```
```bash
syncarr:
image: syncarr/syncarr:latest
container_name: syncarr
@ -140,7 +147,7 @@ syncarr:
or
```
```bash
syncarr:
image: syncarr/syncarr:latest
container_name: syncarr
@ -156,17 +163,20 @@ syncarr:
```
---
## Docker
For just plain docker (radarr example):
```
```bash
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**
## 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)
* Language for new content (Sonarr) 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` (default 0)
* Set disable auto searching on new content with `SYNCARR_AUTO_SEARCH=0` (default 1)
* Set if you want to NOT monitor new content with `SYNCARR_MONITOR_NEW_CONTENT=0` (default 1)
@ -174,18 +184,23 @@ To filter by profile in docker use `*ARR_A_PROFILE_FILTER` or `*ARR_A_PROFILE_FI
* Filter by tag names or ids with `*ARR_A_TAG_FILTER` / `*ARR_B_TAG_FILTER` or `*ARR_A_TAG_FILTER_ID` / `*ARR_B_TAG_FILTER_ID`
* Enable test mode with `SYNCARR_TEST_RUN`
* add blacklist with `*ARR_A_BLACKLIST` and `**ARR_B_BLACKLIST`
* sync monitor settings with `SYNCARR_SYNC_MONITOR`
---
## 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.

@ -150,55 +150,26 @@ lidarrB_language_id = get_config_value('LIDARR_B_LANGUAGE_ID', 'language_id', 'l
lidarrB_quality_match = get_config_value('LIDARR_B_QUALITY_MATCH', 'quality_match', 'lidarrB')
lidarrB_blacklist = get_config_value('LIDARR_B_BLACKLIST', 'blacklist', 'lidarrB')
# set to search if config not set
sync_bidirectionally = get_config_value('SYNCARR_BIDIRECTIONAL_SYNC', 'bidirectional', 'general')
if sync_bidirectionally is not None:
try:
sync_bidirectionally = int(sync_bidirectionally)
except ValueError:
sync_bidirectionally = 0
else:
sync_bidirectionally = 0
# set to search if config not set
auto_search = get_config_value('SYNCARR_AUTO_SEARCH', 'auto_search', 'general')
if auto_search is not None:
try:
auto_search = int(auto_search)
except ValueError:
auto_search = 0
else:
auto_search = 1
# set to skip missing if config not set
skip_missing = get_config_value('SYNCARR_SKIP_MISSING', 'skip_missing', 'general')
if skip_missing is not None:
try:
skip_missing = int(skip_missing)
except ValueError:
skip_missing = 0
else:
skip_missing = 1
# set to monitor if config not set
monitor_new_content = get_config_value('SYNCARR_MONITOR_NEW_CONTENT', 'monitor_new_content', 'general')
if monitor_new_content is not None:
try:
monitor_new_content = int(monitor_new_content)
except ValueError:
monitor_new_content = 1
else:
monitor_new_content = 1
# enable test mode
is_test_run = get_config_value('SYNCARR_TEST_RUN', 'test_run', 'general')
if is_test_run is not None:
try:
is_test_run = int(is_test_run)
except ValueError:
is_test_run = 0
else:
is_test_run = 0
def set_general_option(env_name, config_name, default_value):
"""gets a general config value"""
config_value = get_config_value(env_name, config_name, 'general')
if config_value is not None:
try:
config_value = int(config_value)
except ValueError:
config_value = default_value
else:
config_value = default_value
return config_value
# get general options
sync_bidirectionally = set_general_option('SYNCARR_BIDIRECTIONAL_SYNC', 'bidirectional', default_value=0)
auto_search = set_general_option('SYNCARR_AUTO_SEARCH', 'auto_search', default_value=1)
skip_missing = set_general_option('SYNCARR_SKIP_MISSING', 'skip_missing', default_value=1)
monitor_new_content = set_general_option('SYNCARR_MONITOR_NEW_CONTENT', 'monitor_new_content', default_value=1)
sync_monitor = set_general_option('SYNCARR_SYNC_MONITOR', 'sync_monitor', default_value=0)
is_test_run = set_general_option('SYNCARR_TEST_RUN', 'test_run', default_value=0)
########################################################################################################################
# setup logger
@ -277,8 +248,8 @@ instanceB_tag_filter = ''
instanceB_tag_filter_id = ''
instanceB_blacklist = ''
api_version = '' # we are going to detect what API version we are on
tested_api_version = False # only get api version once
api_version = ''
api_content_path = '' # url path to add content
api_profile_path = '' # url path to get quality profiles
@ -317,9 +288,9 @@ if radarrA_url and radarrB_url:
instanceB_quality_match = radarrB_quality_match
instanceB_blacklist = radarrB_blacklist
api_version = V2_API_PATH # radarr v2 doesnt have version in api url
api_version = V3_API_PATH
api_content_path = 'movie'
api_profile_path = 'profile'
api_profile_path = 'qualityprofile'
api_status_path = 'system/status'
content_id_key = 'tmdbId'
@ -354,7 +325,7 @@ elif sonarrA_url and sonarrB_url:
instanceB_quality_match = sonarrB_quality_match
instanceB_blacklist = sonarrB_blacklist
api_version = V3_API_PATH # for sonarr try v3 first
api_version = V3_API_PATH
api_content_path = 'series'
api_profile_path = 'qualityprofile'
api_status_path = 'system/status'
@ -412,14 +383,6 @@ if instanceB_blacklist:
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,
@ -445,6 +408,11 @@ def get_content_path(instance_url, key):
logger.debug('get_content_path: {}'.format(url))
return url
def get_content_put_path(instance_url, key, content_id):
url = get_path(instance_url, f'{api_content_path}/{content_id}', key)
logger.debug('get_content_put_path: {}'.format(url))
return url
def get_language_path(instance_url, key):
url = get_path(instance_url, api_language_path, key)
@ -509,6 +477,7 @@ logger.debug({
'auto_search': auto_search,
'skip_missing': skip_missing,
'api_version': api_version,
'sync_monitor': sync_monitor,
})
if not instanceA_url:

@ -22,15 +22,16 @@ from config import (
instanceB_tag_filter_id, instanceB_tag_filter, instanceB_blacklist,
content_id_key, logger, is_sonarr, is_radarr, is_lidarr,
get_status_path, get_content_path, get_profile_path, get_language_path, get_tag_path,
get_status_path, get_content_path, get_profile_path, get_language_path, get_tag_path, get_content_put_path,
is_in_docker, instance_sync_interval_seconds,
sync_bidirectionally, auto_search, skip_missing, monitor_new_content,
tested_api_version, api_version, V3_API_PATH, is_test_run,
api_version, is_test_run, sync_monitor
)
def get_new_content_payload(content, instance_path, instance_profile_id, instance_url, instance_language_id=None):
def get_content_details(content, instance_path, instance_profile_id, instance_url, instance_language_id=None):
"""gets details of a content item"""
global monitor_new_content, auto_search
images = content.get('images')
@ -193,8 +194,8 @@ def get_language_from_id(instance_session, instance_url, instance_key, instance_
def sync_servers(instanceA_contents, instanceB_language_id, instanceB_contentIds,
instanceB_path, instanceB_profile_id, instanceA_profile_filter_id,
instanceB_session, instanceB_url, instanceB_key, instanceA_quality_match,
instanceA_tag_filter_id, instanceA_blacklist):
global is_radarr, is_sonarr, is_test_run
instanceA_tag_filter_id, instanceA_blacklist, instanceB_contents):
global is_radarr, is_sonarr, is_test_run, sync_monitor
search_ids = []
# if given instance A profile id then we want to filter out content without that id
@ -203,7 +204,9 @@ def sync_servers(instanceA_contents, instanceB_language_id, instanceB_contentIds
# 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:
content_not_synced = content[content_id_key] not in instanceB_contentIds
# only skip alrerady synced items if we arent syncing monitoring as well
if content_not_synced or sync_monitor:
title = content.get('title') or content.get('artistName')
instance_path = instanceB_path or dirname(content.get('path'))
@ -247,11 +250,10 @@ def sync_servers(instanceA_contents, instanceB_language_id, instanceB_contentIds
logging.debug(f'Skipping content {title} - blacklist ID: {content_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,
# generate content from instance A to sync into instance B
formatted_content = get_content_details(
content=dict(content),
instance_path=instance_path,
instance_profile_id=instanceB_profile_id,
instance_url=instanceB_url,
@ -261,9 +263,10 @@ def sync_servers(instanceA_contents, instanceB_language_id, instanceB_contentIds
if is_test_run:
logging.info('content title "{0}" synced successfully (test only)'.format(title))
else:
sync_response = instanceB_session.post(instanceB_content_url, data=json.dumps(payload))
elif content_not_synced:
# sync content if not synced
logging.info(f'syncing content title "{title}"')
sync_response = instanceB_session.post(instanceB_content_url, data=json.dumps(formatted_content))
# 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}')
@ -274,12 +277,33 @@ def sync_servers(instanceA_contents, instanceB_language_id, instanceB_contentIds
logger.error(f'Could not decode sync response from {instanceB_content_url}')
logging.info('content title "{0}" synced successfully'.format(title))
elif sync_monitor:
# else if is already synced and we want to sync monitoring then sync that now
# find matching content from instance B to check monitored status
matching_content_instanceB = list(filter(lambda content_instanceB: content_instanceB['titleSlug'] == content.get('titleSlug'), instanceB_contents))
if(len(matching_content_instanceB) == 1):
matching_content_instanceB = matching_content_instanceB[0]
# if we found a content match from instance B, then check monitored status - if different then sync from A to B
if matching_content_instanceB['monitored'] != content['monitored']:
matching_content_instanceB['monitored'] = content['monitored']
instanceB_content_url = get_content_put_path(instanceB_url, instanceB_key, matching_content_instanceB.get('id'))
sync_response = instanceB_session.put(instanceB_content_url, data=json.dumps(matching_content_instanceB))
# check response and save content id for searching later on if success
if sync_response.status_code != 202:
logger.error(f'server monitoring 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}" monitoring 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)
@ -296,34 +320,25 @@ def get_instance_contents(instance_url, instance_key, instance_session, instance
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))
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):
def check_status(instance_session, instance_url, instance_key, instance_name=''):
global api_version
instance_status_url = get_status_path(
instance_url, instance_key, changed_api_version)
instance_status_url = get_status_path(instance_url, instance_key)
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:
if status_response.status_code != 200:
logger.error(error_message)
exit_system()
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)
logger.error(error_message)
exit_system()
if status_response is None:
logger.error(error_message)
@ -355,12 +370,6 @@ def sync_content():
instanceB_session = requests.Session()
instanceB_session.trust_env = False
# check if we tested if we are using v2 or v3
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')
@ -433,6 +442,7 @@ def sync_content():
logger.info('syncing content from instance A to instance B')
sync_servers(
instanceA_contents=instanceA_contents,
instanceB_contents=instanceB_contents,
instanceB_contentIds=instanceB_contentIds,
instanceB_language_id=instanceB_language_id,
instanceB_path=instanceB_path,
@ -452,6 +462,7 @@ def sync_content():
sync_servers(
instanceA_contents=instanceB_contents,
instanceB_contents=instanceA_contents,
instanceB_contentIds=instanceA_contentIds,
instanceB_language_id=instanceA_language_id,
instanceB_path=instanceA_path,

Loading…
Cancel
Save