diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e2c9e06 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,23 @@ +# Git +.git + +# Systemd files +systemd + +# Logs +*.log* + +# Configs +*.json + +# Byte-compiled / optimized / DLL files +**/__pycache__ +*.py[cod] +*$py.class +*.pyc + +# Pyenv +**/.python-version + +# User-specific stuff: +.idea diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3034b41 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM python:3.6-alpine3.7 + +ENV \ + # App directory + APP_DIR=traktarr \ + # Branch to clone + BRANCH=master \ + # Config file + TRAKTARR_CONFIG=/config/config.json \ + # Log file + TRAKTARR_LOGFILE=/config/traktarr.log + +RUN \ + echo "** Upgrade all packages **" && \ + apk --no-cache -U upgrade && \ + echo "** Install OS dependencies **" && \ + apk --no-cache -U add git && \ + echo "** Get Traktarr **" && \ + git clone --depth 1 --branch ${BRANCH} https://github.com/l3uddz/traktarr.git /${APP_DIR} && \ + echo "** Install PIP dependencies **" && \ + pip install --no-cache-dir --upgrade pip setuptools && \ + pip install --no-cache-dir --upgrade -r /${APP_DIR}/requirements.txt + +# Change directory +WORKDIR /${APP_DIR} + +# Config volume +VOLUME /config + +# Entrypoint +ENTRYPOINT ["python", "traktarr.py"] diff --git a/README.md b/README.md index 1be76c8..1d28ba4 100644 --- a/README.md +++ b/README.md @@ -138,14 +138,14 @@ To have Traktarr get Movies and Shows for you automatically, on set interval. "api_key": "", "profile": "HD-1080p", "root_folder": "/movies/", - "url": "http://localhost:7878" + "url": "http://localhost:7878/" }, "sonarr": { "api_key": "", "profile": "HD-1080p", "root_folder": "/tv/", "tags": {}, - "url": "http://localhost:8989" + "url": "http://localhost:8989/" }, "trakt": { "api_key": "" diff --git a/media/__init__.py b/media/__init__.py index cac681c..e69de29 100644 --- a/media/__init__.py +++ b/media/__init__.py @@ -1 +0,0 @@ -from media import trakt, sonarr, radarr \ No newline at end of file diff --git a/media/radarr.py b/media/radarr.py index 353b35e..bc0c3af 100644 --- a/media/radarr.py +++ b/media/radarr.py @@ -1,7 +1,6 @@ -from urllib.parse import urljoin - import backoff import requests +import os.path from misc.log import logger from misc import helpers @@ -27,7 +26,11 @@ class Radarr: def validate_api_key(self): try: # request system status to validate api_key - req = requests.get(urljoin(self.server_url, 'api/system/status'), headers=self.headers, timeout=30) + req = requests.get( + os.path.join(self.server_url, 'api/system/status'), + headers=self.headers, + timeout=60 + ) log.debug("Request Response: %d", req.status_code) if req.status_code == 200 and 'version' in req.json(): @@ -41,7 +44,11 @@ class Radarr: def get_movies(self): try: # make request - req = requests.get(urljoin(self.server_url, 'api/movie'), headers=self.headers, timeout=30) + req = requests.get( + os.path.join(self.server_url, 'api/movie'), + headers=self.headers, + timeout=60 + ) log.debug("Request URL: %s", req.url) log.debug("Request Response: %d", req.status_code) @@ -59,7 +66,11 @@ class Radarr: def get_profile_id(self, profile_name): try: # make request - req = requests.get(urljoin(self.server_url, 'api/profile'), headers=self.headers, timeout=30) + req = requests.get( + os.path.join(self.server_url, 'api/profile'), + headers=self.headers, + timeout=60 + ) log.debug("Request URL: %s", req.url) log.debug("Request Response: %d", req.status_code) @@ -82,16 +93,29 @@ class Radarr: try: # generate payload payload = { - 'tmdbId': movie_tmdbid, 'title': movie_title, 'year': movie_year, - 'qualityProfileId': profile_id, 'images': [], - 'monitored': True, 'rootFolderPath': root_folder, - 'minimumAvailability': 'released', 'titleSlug': movie_title_slug, - 'addOptions': {'ignoreEpisodesWithFiles': False, 'ignoreEpisodesWithoutFiles': False, - 'searchForMovie': search_missing} + 'tmdbId': movie_tmdbid, + 'title': movie_title, + 'year': movie_year, + 'qualityProfileId': profile_id, + 'images': [], + 'monitored': True, + 'rootFolderPath': root_folder, + 'minimumAvailability': 'released', + 'titleSlug': movie_title_slug, + 'addOptions': { + 'ignoreEpisodesWithFiles': False, + 'ignoreEpisodesWithoutFiles': False, + 'searchForMovie': search_missing + } } # make request - req = requests.post(urljoin(self.server_url, 'api/movie'), json=payload, headers=self.headers, timeout=30) + req = requests.post( + os.path.join(self.server_url, 'api/movie'), + headers=self.headers, + json=payload, + timeout=60 + ) log.debug("Request URL: %s", req.url) log.debug("Request Payload: %s", payload) log.debug("Request Response Code: %d", req.status_code) diff --git a/media/sonarr.py b/media/sonarr.py index 8447efb..9315c62 100644 --- a/media/sonarr.py +++ b/media/sonarr.py @@ -1,7 +1,6 @@ -from urllib.parse import urljoin - import backoff import requests +import os.path from misc import helpers from misc.log import logger @@ -20,14 +19,13 @@ class Sonarr: self.server_url = server_url self.api_key = api_key self.headers = { - 'Content-Type': 'application/json', 'X-Api-Key': self.api_key, } def validate_api_key(self): try: # request system status to validate api_key - req = requests.get(urljoin(self.server_url, 'api/system/status'), headers=self.headers, timeout=30) + req = requests.get(os.path.join(self.server_url, 'api/system/status'), headers=self.headers, timeout=60) log.debug("Request Response: %d", req.status_code) if req.status_code == 200 and 'version' in req.json(): @@ -41,7 +39,11 @@ class Sonarr: def get_series(self): try: # make request - req = requests.get(urljoin(self.server_url, 'api/series'), headers=self.headers, timeout=30) + req = requests.get( + os.path.join(self.server_url, 'api/series'), + headers=self.headers, + timeout=60 + ) log.debug("Request URL: %s", req.url) log.debug("Request Response: %d", req.status_code) @@ -59,7 +61,11 @@ class Sonarr: def get_profile_id(self, profile_name): try: # make request - req = requests.get(urljoin(self.server_url, 'api/profile'), headers=self.headers, timeout=30) + req = requests.get( + os.path.join(self.server_url, 'api/profile'), + headers=self.headers, + timeout=60 + ) log.debug("Request URL: %s", req.url) log.debug("Request Response: %d", req.status_code) @@ -81,7 +87,11 @@ class Sonarr: def get_tag_id(self, tag_name): try: # make request - req = requests.get(urljoin(self.server_url, 'api/tag'), headers=self.headers, timeout=30) + req = requests.get( + os.path.join(self.server_url, 'api/tag'), + headers=self.headers, + timeout=60 + ) log.debug("Request URL: %s", req.url) log.debug("Request Response: %d", req.status_code) @@ -104,7 +114,11 @@ class Sonarr: tags = {} try: # make request - req = requests.get(urljoin(self.server_url, 'api/tag'), headers=self.headers, timeout=30) + req = requests.get( + os.path.join(self.server_url, 'api/tag'), + headers=self.headers, + timeout=60 + ) log.debug("Request URL: %s", req.url) log.debug("Request Response: %d", req.status_code) @@ -126,18 +140,30 @@ class Sonarr: try: # generate payload payload = { - 'tvdbId': series_tvdbid, 'title': series_title, 'titleSlug': series_title_slug, - 'qualityProfileId': profile_id, 'tags': [] if not tag_ids or not isinstance(tag_ids, list) else tag_ids, + 'tvdbId': series_tvdbid, + 'title': series_title, + 'titleSlug': series_title_slug, + 'qualityProfileId': profile_id, + 'tags': [] if not tag_ids or not isinstance(tag_ids, list) else tag_ids, 'images': [], - 'seasons': [], 'seasonFolder': True, - 'monitored': True, 'rootFolderPath': root_folder, - 'addOptions': {'ignoreEpisodesWithFiles': False, - 'ignoreEpisodesWithoutFiles': False, - 'searchForMissingEpisodes': search_missing} + 'seasons': [], + 'seasonFolder': True, + 'monitored': True, + 'rootFolderPath': root_folder, + 'addOptions': { + 'ignoreEpisodesWithFiles': False, + 'ignoreEpisodesWithoutFiles': False, + 'searchForMissingEpisodes': search_missing + } } # make request - req = requests.post(urljoin(self.server_url, 'api/series'), json=payload, headers=self.headers, timeout=30) + req = requests.post( + os.path.join(self.server_url, 'api/series'), + headers=self.headers, + json=payload, + timeout=60 + ) log.debug("Request URL: %s", req.url) log.debug("Request Payload: %s", payload) log.debug("Request Response Code: %d", req.status_code) diff --git a/media/trakt.py b/media/trakt.py index 539aa95..b2cf3c4 100644 --- a/media/trakt.py +++ b/media/trakt.py @@ -29,8 +29,12 @@ class Trakt: payload = {'extended': 'full', 'limit': 1000} # make request - req = requests.get('https://api.trakt.tv/shows/anticipated', params=payload, headers=self.headers, - timeout=30) + req = requests.get( + 'https://api.trakt.tv/shows/anticipated', + headers=self.headers, + params=payload, + timeout=30 + ) log.debug("Request Response: %d", req.status_code) if req.status_code == 200: @@ -59,8 +63,12 @@ class Trakt: # make request while True: - req = requests.get('https://api.trakt.tv/shows/anticipated', params=payload, headers=self.headers, - timeout=30) + req = requests.get( + 'https://api.trakt.tv/shows/anticipated', + headers=self.headers, + params=payload, + timeout=30 + ) log.debug("Request URL: %s", req.url) log.debug("Request Payload: %s", payload) log.debug("Response Code: %d", req.status_code) @@ -115,8 +123,12 @@ class Trakt: # make request while True: - req = requests.get('https://api.trakt.tv/shows/trending', params=payload, headers=self.headers, - timeout=30) + req = requests.get( + 'https://api.trakt.tv/shows/trending', + headers=self.headers, + params=payload, + timeout=30 + ) log.debug("Request URL: %s", req.url) log.debug("Request Payload: %s", payload) log.debug("Response Code: %d", req.status_code) @@ -171,8 +183,12 @@ class Trakt: # make request while True: - req = requests.get('https://api.trakt.tv/shows/popular', params=payload, headers=self.headers, - timeout=30) + req = requests.get( + 'https://api.trakt.tv/shows/popular', + headers=self.headers, + params=payload, + timeout=30 + ) log.debug("Request URL: %s", req.url) log.debug("Request Payload: %s", payload) log.debug("Response Code: %d", req.status_code) @@ -232,8 +248,12 @@ class Trakt: # make request while True: - req = requests.get('https://api.trakt.tv/movies/anticipated', params=payload, headers=self.headers, - timeout=30) + req = requests.get( + 'https://api.trakt.tv/movies/anticipated', + headers=self.headers, + params=payload, + timeout=30 + ) log.debug("Request URL: %s", req.url) log.debug("Request Payload: %s", payload) log.debug("Response Code: %d", req.status_code) @@ -288,8 +308,12 @@ class Trakt: # make request while True: - req = requests.get('https://api.trakt.tv/movies/trending', params=payload, headers=self.headers, - timeout=30) + req = requests.get( + 'https://api.trakt.tv/movies/trending', + headers=self.headers, + params=payload, + timeout=30 + ) log.debug("Request URL: %s", req.url) log.debug("Request Payload: %s", payload) log.debug("Response Code: %d", req.status_code) @@ -344,8 +368,12 @@ class Trakt: # make request while True: - req = requests.get('https://api.trakt.tv/movies/popular', params=payload, headers=self.headers, - timeout=30) + req = requests.get( + 'https://api.trakt.tv/movies/popular', + headers=self.headers, + params=payload, + timeout=30 + ) log.debug("Request URL: %s", req.url) log.debug("Request Payload: %s", payload) log.debug("Response Code: %d", req.status_code) @@ -401,8 +429,12 @@ class Trakt: # make request while True: - req = requests.get('https://api.trakt.tv/movies/boxoffice', params=payload, headers=self.headers, - timeout=30) + req = requests.get( + 'https://api.trakt.tv/movies/boxoffice', + headers=self.headers, + params=payload, + timeout=30 + ) log.debug("Request URL: %s", req.url) log.debug("Request Payload: %s", payload) log.debug("Response Code: %d", req.status_code) diff --git a/misc/__init__.py b/misc/__init__.py index 537ee21..e69de29 100644 --- a/misc/__init__.py +++ b/misc/__init__.py @@ -1,2 +0,0 @@ -from misc import config, str, helpers -from misc.log import logger diff --git a/misc/config.py b/misc/config.py index 536a98f..37d00ca 100644 --- a/misc/config.py +++ b/misc/config.py @@ -4,68 +4,15 @@ import sys from attrdict import AttrDict -config_path = os.path.join(os.path.dirname(os.path.realpath(sys.argv[0])), 'config.json') -base_config = { - 'core': { - 'debug': False - }, - 'trakt': { - 'api_key': '' - }, - 'sonarr': { - 'url': 'http://localhost:8989', - 'api_key': '', - 'profile': 'HD-1080p', - 'root_folder': '/tv/', - 'tags': { - } - }, - 'radarr': { - 'url': 'http://localhost:7878', - 'api_key': '', - 'profile': 'HD-1080p', - 'root_folder': '/movies/' - }, - 'filters': { - 'shows': { - 'blacklisted_genres': [], - 'blacklisted_networks': [], - 'allowed_countries': [], - 'blacklisted_min_runtime': 15, - 'blacklisted_min_year': 2000, - 'blacklisted_max_year': 2019, - 'blacklisted_tvdb_ids': [], - }, - 'movies': { - 'blacklisted_genres': [], - 'blacklisted_min_runtime': 60, - 'blacklisted_min_year': 2000, - 'blacklisted_max_year': 2019, - 'blacklist_title_keywords': [], - 'blacklisted_tmdb_ids': [], - 'allowed_countries': [] - } - }, - 'automatic': { - 'movies': { - 'interval': 20, - 'anticipated': 3, - 'trending': 3, - 'popular': 3, - 'boxoffice': 10 - }, - 'shows': { - 'interval': 48, - 'anticipated': 10, - 'trending': 1, - 'popular': 1 - } - }, - 'notifications': { - 'verbose': True - } -} -cfg = None + +class Singleton(type): + _instances = {} + + def __call__(cls, *args, **kwargs): + if cls not in cls._instances: + cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) + + return cls._instances[cls] class AttrConfig(AttrDict): @@ -85,77 +32,154 @@ class AttrConfig(AttrDict): return None -def build_config(): - if not os.path.exists(config_path): - print("Dumping default config to: %s" % config_path) - with open(config_path, 'w') as fp: - json.dump(base_config, fp, sort_keys=True, indent=2) - return True - else: - return False - - -def dump_config(): - if os.path.exists(config_path): - with open(config_path, 'w') as fp: - json.dump(cfg, fp, sort_keys=True, indent=2) - return True - else: - return False - - -def load_config(): - with open(config_path, 'r') as fp: - return AttrConfig(json.load(fp)) - - -def upgrade_settings(defaults, currents): - upgraded = False - - def inner_upgrade(default, current, key=None): - sub_upgraded = False - merged = current.copy() - if isinstance(default, dict): - for k, v in default.items(): - # missing k - if k not in current: - merged[k] = v - sub_upgraded = True - if not key: - print("Added %r config option: %s" % (str(k), str(v))) - else: - print("Added %r to config option %r: %s" % (str(k), str(key), str(v))) - continue - # iterate children - if isinstance(v, dict) or isinstance(v, list): - did_upgrade, merged[k] = inner_upgrade(default[k], current[k], key=k) - sub_upgraded = did_upgrade if did_upgrade else sub_upgraded - - elif isinstance(default, list) and key: - for v in default: - if v not in current: - merged.append(v) - sub_upgraded = True - print("Added to config option %r: %s" % (str(key), str(v))) - continue - return sub_upgraded, merged - - upgraded, upgraded_settings = inner_upgrade(defaults, currents) - return upgraded, AttrConfig(upgraded_settings) - - -############################################################ -# LOAD CFG -############################################################ - -# dump/load config -if build_config(): - print("Please edit the default configuration before running again!") - sys.exit(0) -else: - tmp = load_config() - upgraded, cfg = upgrade_settings(base_config, tmp) - if upgraded: - dump_config() - print("New config options were added, adjust and restart!") - sys.exit(0) +class Config(object, metaclass=Singleton): + + base_config = { + 'core': { + 'debug': False + }, + 'trakt': { + 'api_key': '' + }, + 'sonarr': { + 'url': 'http://localhost:8989/', + 'api_key': '', + 'profile': 'HD-1080p', + 'root_folder': '/tv/', + 'tags': { + } + }, + 'radarr': { + 'url': 'http://localhost:7878/', + 'api_key': '', + 'profile': 'HD-1080p', + 'root_folder': '/movies/' + }, + 'filters': { + 'shows': { + 'blacklisted_genres': [], + 'blacklisted_networks': [], + 'allowed_countries': [], + 'blacklisted_min_runtime': 15, + 'blacklisted_min_year': 2000, + 'blacklisted_max_year': 2019, + 'blacklisted_tvdb_ids': [], + }, + 'movies': { + 'blacklisted_genres': [], + 'blacklisted_min_runtime': 60, + 'blacklisted_min_year': 2000, + 'blacklisted_max_year': 2019, + 'blacklist_title_keywords': [], + 'blacklisted_tmdb_ids': [], + 'allowed_countries': [] + } + }, + 'automatic': { + 'movies': { + 'interval': 20, + 'anticipated': 3, + 'trending': 3, + 'popular': 3, + 'boxoffice': 10 + }, + 'shows': { + 'interval': 48, + 'anticipated': 10, + 'trending': 1, + 'popular': 1 + } + }, + 'notifications': { + 'verbose': True + } + } + + def __init__(self, config_path, logfile): + """Initializes config""" + self.conf = None + + self.config_path = config_path + self.log_path = logfile + + @property + def cfg(self): + # Return existing loaded config + if self.conf: + return self.conf + + # Built initial config if it doesn't exist + if self.build_config(): + print("Please edit the default configuration before running again!") + sys.exit(0) + # Load config, upgrade if necessary + else: + tmp = self.load_config() + self.conf, upgraded = self.upgrade_settings(tmp) + + # Save config if upgraded + if upgraded: + self.dump_config() + print("New config options were added, adjust and restart!") + sys.exit(0) + + return self.conf + + @property + def logfile(self): + return self.log_path + + def build_config(self): + if not os.path.exists(self.config_path): + print("Dumping default config to: %s" % self.config_path) + with open(self.config_path, 'w') as fp: + json.dump(self.base_config, fp, sort_keys=True, indent=2) + return True + else: + return False + + def dump_config(self): + if os.path.exists(self.config_path): + with open(self.config_path, 'w') as fp: + json.dump(self.conf, fp, sort_keys=True, indent=2) + return True + else: + return False + + def load_config(self): + with open(self.config_path, 'r') as fp: + return AttrConfig(json.load(fp)) + + def upgrade_settings(self, currents): + upgraded = False + + def inner_upgrade(default, current, key=None): + sub_upgraded = False + merged = current.copy() + if isinstance(default, dict): + for k, v in default.items(): + # missing k + if k not in current: + merged[k] = v + sub_upgraded = True + if not key: + print("Added %r config option: %s" % (str(k), str(v))) + else: + print("Added %r to config option %r: %s" % (str(k), str(key), str(v))) + continue + # iterate children + if isinstance(v, dict) or isinstance(v, list): + merged[k], did_upgrade = inner_upgrade(default[k], current[k], key=k) + sub_upgraded = did_upgrade if did_upgrade else sub_upgraded + + elif isinstance(default, list) and key: + for v in default: + if v not in current: + merged.append(v) + sub_upgraded = True + print("Added to config option %r: %s" % (str(key), str(v))) + continue + return merged, sub_upgraded + + upgraded_settings, upgraded = inner_upgrade(self.base_config, currents) + return AttrConfig(upgraded_settings), upgraded diff --git a/misc/log.py b/misc/log.py index 204c448..befde96 100644 --- a/misc/log.py +++ b/misc/log.py @@ -3,7 +3,7 @@ import os import sys from logging.handlers import RotatingFileHandler -from misc.config import cfg +from misc.config import Config class Logger: @@ -49,4 +49,5 @@ class Logger: return self.root_logger.getChild(name) -logger = Logger('activity.log', logging.DEBUG if cfg.core.debug else logging.INFO) +# Default logger +logger = Logger(Config().logfile, logging.DEBUG if Config().cfg.core.debug else logging.INFO) diff --git a/requirements.txt b/requirements.txt index 27f2206..b865c62 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ -backoff==1.4.3 -schedule==0.4.3 +backoff==1.5.0 +schedule==0.5.0 attrdict==2.0.0 click==6.7 -requests==2.18.4 +requests~=2.18.4 diff --git a/traktarr.py b/traktarr.py index 0da7ab1..9641e89 100755 --- a/traktarr.py +++ b/traktarr.py @@ -1,33 +1,55 @@ #!/usr/bin/env python3 +import os.path 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() +cfg = None +log = None +notify = None # Click @click.group(help='Add new shows & movies to Sonarr/Radarr from Trakt lists.') @click.version_option('1.1.2', prog_name='traktarr') -def app(): - pass +@click.option( + '--config', + envvar='TRAKTARR_CONFIG', + type=click.Path(file_okay=True, dir_okay=False), + help='Configuration file', + show_default=True, + default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.json") +) +@click.option( + '--logfile', + envvar='TRAKTARR_LOGFILE', + type=click.Path(file_okay=True, dir_okay=False), + help='Log file', + show_default=True, + default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "activity.log") +) +def app(config, logfile): + # Setup global variables + global cfg, log, notify + + # Load config + from misc.config import Config + cfg = Config(config_path=config, logfile=logfile).cfg + + # Load logger + from misc.log import logger + log = logger.get_logger('traktarr') + + # Load notifications + from notifications import Notifications + notify = Notifications() + + # Notifications + init_notifications() ############################################################ @@ -44,6 +66,10 @@ def app(): @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, folder=None, no_search=False, notifications=False): + from media.sonarr import Sonarr + from media.trakt import Trakt + from misc import helpers + added_shows = 0 # remove genre from shows blacklisted_genres if supplied @@ -214,6 +240,10 @@ def shows(list_type, add_limit=0, add_delay=2.5, genre=None, folder=None, no_sea @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, folder=None, no_search=False, notifications=False): + from media.radarr import Radarr + from media.trakt import Trakt + from misc import helpers + added_movies = 0 # remove genre from movies blacklisted_genres if supplied @@ -500,5 +530,4 @@ def init_notifications(): ############################################################ if __name__ == "__main__": - init_notifications() app()