You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
traktarr/traktarr.py

484 lines
21 KiB

#!/usr/bin/env python3
import time
import click
import schedule
from media.radarr import Radarr
from media.sonarr import Sonarr
from media.trakt import Trakt
from misc import helpers
from misc.config import cfg
from misc.log import logger
from notifications import Notifications
############################################################
# INIT
############################################################
# Logging
log = logger.get_logger('traktarr')
# Notifications
notify = Notifications()
# Click
@click.group(help='Add new shows & movies to Sonarr/Radarr from Trakt lists.')
@click.version_option('1.1.1', prog_name='traktarr')
def app():
pass
############################################################
# SHOWS
############################################################
@app.command(help='Add new shows to Sonarr.')
@click.option('--list-type', '-t', type=click.Choice(['anticipated', 'trending', 'popular']),
help='Trakt list to process.', required=True)
@click.option('--add-limit', '-l', default=0, help='Limit number of shows added to Sonarr.', show_default=True)
@click.option('--add-delay', '-d', default=2.5, help='Seconds between each add request to Sonarr.', show_default=True)
@click.option('--genre', '-g', default=None, help='Only add shows from this genre to Sonarr.')
@click.option('--no-search', is_flag=True, help='Disable search when adding shows to Sonarr.')
@click.option('--notifications', is_flag=True, help='Send notifications.')
def shows(list_type, add_limit=0, add_delay=2.5, genre=None, no_search=False, notifications=False):
added_shows = 0
# validate trakt api_key
trakt = Trakt(cfg.trakt.api_key)
if not trakt.validate_api_key():
log.error("Aborting due to failure to validate Trakt API Key")
if notifications:
callback_notify({'event': 'abort', 'type': 'shows', 'list_type': list_type,
'reason': 'Failure to validate Trakt API Key'})
return None
else:
log.info("Validated Trakt API Key")
# validate sonarr url & api_key
sonarr = Sonarr(cfg.sonarr.url, cfg.sonarr.api_key)
if not sonarr.validate_api_key():
log.error("Aborting due to failure to validate Sonarr URL / API Key")
if notifications:
callback_notify({'event': 'abort', 'type': 'shows', 'list_type': list_type,
'reason': 'Failure to validate Sonarr URL / API Key'})
return None
else:
log.info("Validated Sonarr URL & API Key")
# retrieve profile id for requested profile
profile_id = sonarr.get_profile_id(cfg.sonarr.profile)
if not profile_id or not profile_id > 0:
log.error("Aborting due to failure to retrieve Profile ID for: %s", cfg.sonarr.profile)
if notifications:
callback_notify({'event': 'abort', 'type': 'shows', 'list_type': list_type,
'reason': 'Failure to retrieve Sonarr Profile ID of %s' % cfg.sonarr.profile})
return None
else:
log.info("Retrieved Profile ID for %s: %d", cfg.sonarr.profile, profile_id)
# retrieve profile tags
profile_tags = sonarr.get_tags()
if profile_tags is None:
log.error("Aborting due to failure to retrieve Tag ID's")
if notifications:
callback_notify({'event': 'abort', 'type': 'shows', 'list_type': list_type,
'reason': "Failure to retrieve Sonarr Tag ID's"})
return None
else:
log.info("Retrieved %d Tag ID's", len(profile_tags))
# get sonarr series list
sonarr_series_list = sonarr.get_series()
if not sonarr_series_list:
log.error("Aborting due to failure to retrieve Sonarr shows list")
if notifications:
callback_notify({'event': 'abort', 'type': 'shows', 'list_type': list_type,
'reason': 'Failure to retrieve Sonarr shows list'})
return None
else:
log.info("Retrieved Sonarr shows list, shows found: %d", len(sonarr_series_list))
# get trakt series list
trakt_series_list = None
if list_type.lower() == 'anticipated':
trakt_series_list = trakt.get_anticipated_shows()
elif list_type.lower() == 'trending':
trakt_series_list = trakt.get_trending_shows()
elif list_type.lower() == 'popular':
trakt_series_list = trakt.get_popular_shows()
else:
log.error("Aborting due to unknown Trakt list type")
if notifications:
callback_notify({'event': 'abort', 'type': 'shows', 'list_type': list_type,
'reason': 'Failure to determine Trakt list type'})
return None
if not trakt_series_list:
log.error("Aborting due to failure to retrieve Trakt %s shows list", list_type)
if notifications:
callback_notify(
{'event': 'abort', 'type': 'shows', 'list_type': list_type,
'reason': 'Failure to retrieve Trakt %s shows list' % list_type})
return None
else:
log.info("Retrieved Trakt %s shows list, shows found: %d", list_type, len(trakt_series_list))
# build filtered series list without series that exist in sonarr
processed_series_list = helpers.sonarr_remove_existing_series(sonarr_series_list, trakt_series_list)
if processed_series_list is None:
log.error("Aborting due to failure to remove existing Sonarr shows from retrieved Trakt shows list")
if notifications:
callback_notify({'event': 'abort', 'type': 'shows', 'list_type': list_type,
'reason': 'Failure to remove existing Sonarr shows from retrieved Trakt %s shows list' % list_type
})
return None
else:
log.info("Removed existing Sonarr shows from Trakt shows list, shows left to process: %d",
len(processed_series_list))
# sort filtered series list by highest votes
sorted_series_list = sorted(processed_series_list, key=lambda k: k['show']['votes'], reverse=True)
log.info("Sorted shows list to process by highest votes")
# loop series_list
log.info("Processing list now...")
for series in sorted_series_list:
try:
# check if genre matches genre supplied via argument
if genre and genre.lower() not in series['show']['genres']:
log.debug("Skipping: %s because it was not from %s genre", series['show']['title'], genre)
continue
# check if series passes out blacklist criteria inspection
if not helpers.trakt_is_show_blacklisted(series, cfg.filters.shows):
log.info("Adding: %s | Genres: %s | Network: %s | Country: %s", series['show']['title'],
', '.join(series['show']['genres']), series['show']['network'],
series['show']['country'].upper())
# determine which tags to use when adding this series
use_tags = helpers.sonarr_series_tag_id_from_network(profile_tags, cfg.sonarr.tags,
series['show']['network'])
# add show to sonarr
if sonarr.add_series(series['show']['ids']['tvdb'], series['show']['title'],
series['show']['ids']['slug'], profile_id, cfg.sonarr.root_folder, use_tags,
not no_search):
log.info("ADDED %s (%d) with tags: %s", series['show']['title'], series['show']['year'],
helpers.sonarr_readable_tag_from_ids(profile_tags, use_tags))
if notifications:
callback_notify({'event': 'add_show', 'list_type': list_type, 'show': series['show']})
added_shows += 1
else:
log.error("FAILED adding %s (%d) with tags: %s", series['show']['title'], series['show']['year'],
helpers.sonarr_readable_tag_from_ids(profile_tags, use_tags))
# stop adding shows, if added_shows >= add_limit
if add_limit and added_shows >= add_limit:
break
# sleep before adding any more
time.sleep(add_delay)
except Exception:
log.exception("Exception while processing show %s: ", series['show']['title'])
log.info("Added %d new show(s) to Sonarr", added_shows)
# send notification
if notifications:
notify.send(message="Added %d shows from Trakt's %s list" % (added_shows, list_type))
return added_shows
############################################################
# MOVIES
############################################################
@app.command(help='Add new movies to Radarr.')
@click.option('--list-type', '-t', type=click.Choice(['anticipated', 'trending', 'popular', 'boxoffice']),
help='Trakt list to process.', required=True)
@click.option('--add-limit', '-l', default=0, help='Limit number of movies added to Radarr.', show_default=True)
@click.option('--add-delay', '-d', default=2.5, help='Seconds between each add request to Radarr.', show_default=True)
@click.option('--genre', '-g', default=None, help='Only add movies from this genre to Radarr.')
@click.option('--no-search', is_flag=True, help='Disable search when adding movies to Radarr.')
@click.option('--notifications', is_flag=True, help='Send notifications.')
def movies(list_type, add_limit=0, add_delay=2.5, genre=None, no_search=False, notifications=False):
added_movies = 0
# validate trakt api_key
trakt = Trakt(cfg.trakt.api_key)
if not trakt.validate_api_key():
log.error("Aborting due to failure to validate Trakt API Key")
if notifications:
callback_notify({'event': 'abort', 'type': 'movies', 'list_type': list_type,
'reason': 'Failure to validate Trakt API Key'})
return None
else:
log.info("Validated Trakt API Key")
# validate radarr url & api_key
radarr = Radarr(cfg.radarr.url, cfg.radarr.api_key)
if not radarr.validate_api_key():
log.error("Aborting due to failure to validate Radarr URL / API Key")
if notifications:
callback_notify(
{'event': 'abort', 'type': 'movies', 'list_type': list_type,
'reason': 'Failure to validate Radarr URL / API Key'})
return None
else:
log.info("Validated Radarr URL & API Key")
# retrieve profile id for requested profile
profile_id = radarr.get_profile_id(cfg.radarr.profile)
if not profile_id or not profile_id > 0:
log.error("Aborting due to failure to retrieve Profile ID for: %s", cfg.radarr.profile)
if notifications:
callback_notify({'event': 'abort', 'type': 'movies', 'list_type': list_type,
'reason': 'Failure to retrieve Radarr Profile ID of %s' % cfg.radarr.profile})
return None
else:
log.info("Retrieved Profile ID for %s: %d", cfg.radarr.profile, profile_id)
# get radarr movies list
radarr_movie_list = radarr.get_movies()
if not radarr_movie_list:
log.error("Aborting due to failure to retrieve Radarr movies list")
if notifications:
callback_notify({'event': 'abort', 'type': 'movies', 'list_type': list_type,
'reason': 'Failure to retrieve Radarr movies list'})
return None
else:
log.info("Retrieved Radarr movies list, movies found: %d", len(radarr_movie_list))
# get trakt movies list
trakt_movies_list = None
if list_type.lower() == 'anticipated':
trakt_movies_list = trakt.get_anticipated_movies()
elif list_type.lower() == 'trending':
trakt_movies_list = trakt.get_trending_movies()
elif list_type.lower() == 'popular':
trakt_movies_list = trakt.get_popular_movies()
elif list_type.lower() == 'boxoffice':
trakt_movies_list = trakt.get_boxoffice_movies()
else:
log.error("Aborting due to unknown Trakt list type")
if notifications:
callback_notify({'event': 'abort', 'type': 'movies', 'list_type': list_type,
'reason': 'Failure to determine Trakt list type'})
return None
if not trakt_movies_list:
log.error("Aborting due to failure to retrieve Trakt %s movies list", list_type)
if notifications:
callback_notify(
{'event': 'abort', 'type': 'movies', 'list_type': list_type,
'reason': 'Failure to retrieve Trakt %s movies list' % list_type})
return None
else:
log.info("Retrieved Trakt %s movies list, movies found: %d", list_type, len(trakt_movies_list))
# build filtered movie list without movies that exist in radarr
processed_movies_list = helpers.radarr_remove_existing_movies(radarr_movie_list, trakt_movies_list)
if processed_movies_list is None:
log.error("Aborting due to failure to remove existing Radarr movies from retrieved Trakt movies list")
if notifications:
callback_notify({'event': 'abort', 'type': 'movies', 'list_type': list_type,
'reason': 'Failure to remove existing Radarr movies from retrieved '
'Trakt %s movies list' % list_type})
return None
else:
log.info("Removed existing Radarr movies from Trakt movies list, movies left to process: %d",
len(processed_movies_list))
# sort filtered movie list by highest votes
sorted_movies_list = sorted(processed_movies_list, key=lambda k: k['movie']['votes'], reverse=True)
log.info("Sorted movie list to process by highest votes")
# loop movies
log.info("Processing list now...")
for movie in sorted_movies_list:
try:
# check if genre matches genre supplied via argument
if genre and genre.lower() not in movie['movie']['genres']:
log.debug("Skipping: %s because it was not from %s genre", movie['movie']['title'], genre)
continue
# check if movie passes out blacklist criteria inspection
if not helpers.trakt_is_movie_blacklisted(movie, cfg.filters.movies):
log.info("Adding: %s (%d) | Genres: %s | Country: %s", movie['movie']['title'], movie['movie']['year'],
', '.join(movie['movie']['genres']), movie['movie']['country'].upper())
# add movie to radarr
if radarr.add_movie(movie['movie']['ids']['tmdb'], movie['movie']['title'], movie['movie']['year'],
movie['movie']['ids']['slug'], profile_id, cfg.radarr.root_folder, not no_search):
log.info("ADDED %s (%d)", movie['movie']['title'], movie['movie']['year'])
if notifications:
callback_notify({'event': 'add_movie', 'list_type': list_type, 'movie': movie['movie']})
added_movies += 1
else:
log.error("FAILED adding %s (%d)", movie['movie']['title'], movie['movie']['year'])
# stop adding movies, if added_movies >= add_limit
if add_limit and added_movies >= add_limit:
break
# sleep before adding any more
time.sleep(add_delay)
except Exception:
log.exception("Exception while processing movie %s: ", movie['movie']['title'])
log.info("Added %d new movie(s) to Radarr", added_movies)
# send notification
if notifications:
notify.send(message="Added %d movies from Trakt's %s list" % (added_movies, list_type))
return added_movies
############################################################
# AUTOMATIC
############################################################
def callback_notify(data):
log.debug("Received callback data: %s", data)
# handle event
if data['event'] == 'add_movie':
if cfg.notifications.verbose:
notify.send(
message="Added %s movie: %s (%d)" % (data['list_type'], data['movie']['title'], data['movie']['year']))
return
elif data['event'] == 'add_show':
if cfg.notifications.verbose:
notify.send(
message="Added %s show: %s (%d)" % (data['list_type'], data['show']['title'], data['show']['year']))
return
elif data['event'] == 'abort':
notify.send(message="Aborted adding Trakt %s %s due to: %s" % (data['list_type'], data['type'], data['reason']))
return
else:
log.error("Unexpected callback: %s", data)
return
def automatic_shows(add_delay=2.5, no_search=False, notifications=False):
total_shows_added = 0
try:
log.info("Started")
for list_type, type_amount in cfg.automatic.shows.items():
if list_type.lower() == 'interval':
continue
elif type_amount <= 0:
log.info("Skipped Trakt's %s shows list", list_type)
continue
else:
log.info("Adding %d shows from Trakt's %s list", type_amount, list_type)
# run shows
added_shows = shows.callback(list_type=list_type, add_limit=type_amount,
add_delay=add_delay, no_search=no_search,
notifications=notifications)
if added_shows is None:
log.error("Failed adding shows from Trakt's %s list", list_type)
time.sleep(10)
continue
total_shows_added += added_shows
# sleep
time.sleep(10)
log.info("Finished, added %d shows total to Sonarr!", total_shows_added)
# send notification
if notifications:
notify.send(message="Added %d shows total to Sonarr!" % total_shows_added)
except Exception:
log.exception("Exception while automatically adding shows: ")
return
def automatic_movies(add_delay=2.5, no_search=False, notifications=False):
total_movies_added = 0
try:
log.info("Started")
for list_type, type_amount in cfg.automatic.movies.items():
if list_type.lower() == 'interval':
continue
elif type_amount <= 0:
log.info("Skipped Trakt's %s movies list", list_type)
continue
else:
log.info("Adding %d movies from Trakt's %s list", type_amount, list_type)
# run movies
added_movies = movies.callback(list_type=list_type, add_limit=type_amount,
add_delay=add_delay, no_search=no_search,
notifications=notifications)
if added_movies is None:
log.error("Failed adding movies from Trakt's %s list", list_type)
time.sleep(10)
continue
total_movies_added += added_movies
# sleep
time.sleep(10)
log.info("Finished, added %d movies total to Radarr!", total_movies_added)
# send notification
if notifications:
notify.send(message="Added %d movies total to Radarr!" % total_movies_added)
except Exception:
log.exception("Exception while automatically adding movies: ")
return
@app.command(help='Run in automatic mode.')
@click.option('--add-delay', '-d', default=2.5, help='Seconds between each add request to Sonarr / Radarr.',
show_default=True)
@click.option('--no-search', is_flag=True, help='Disable search when adding to Sonarr / Radarr.')
@click.option('--no-notifications', is_flag=True, help="Disable notifications.")
def run(add_delay=2.5, no_search=False, no_notifications=False):
# add tasks to repeat
schedule.every(cfg.automatic.movies.interval).hours.do(automatic_movies, add_delay, no_search,
not no_notifications)
schedule.every(cfg.automatic.shows.interval).hours.do(automatic_shows, add_delay, no_search, not no_notifications)
# run schedule
log.info("Automatic mode is now running...")
while True:
try:
schedule.run_pending()
except Exception:
log.exception("Unhandled exception occurred while processing scheduled tasks: ")
else:
time.sleep(1)
############################################################
# MISC
############################################################
def init_notifications():
try:
for notification_name, notification_config in cfg.notifications.items():
if notification_name.lower() == 'verbose':
continue
notify.load(**notification_config)
except Exception:
log.exception("Exception initializing notification agents: ")
return
############################################################
# MAIN
############################################################
if __name__ == "__main__":
init_notifications()
app()