From 8d51b6a7c671c3d4c5b57fb71b35e680b6bc5eb6 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 11 Oct 2022 12:22:20 -0400 Subject: [PATCH] feat(books): add Readarr support * Support Readarr bot using `/book` command * Add en-us translations * Update docs * Use metadata profile --- README.md | 7 +- lang/en-us.yml | 7 + log.py | 2 +- radarr.py | 2 +- readarr.py | 257 +++++++++++++++++++++++++++++++++++ requirements.txt | 1 + searcharr.py | 331 +++++++++++++++++++++++++++++++++++++++++++-- settings-sample.py | 17 ++- sonarr.py | 2 +- 9 files changed, 606 insertions(+), 20 deletions(-) create mode 100644 readarr.py diff --git a/README.md b/README.md index 5d3e159..872f294 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ ### By Todd Roberts /~https://github.com/toddrob99/searcharr -This bot allows users to add movies to Radarr and series to Sonarr via Telegram messaging app. +This bot allows users to add movies to Radarr, series to Sonarr, and books to Readarr via Telegram messaging app. ## Setup & Run @@ -20,6 +20,7 @@ You are required to update the following settings, at minimum: * Telegram Bot > Token (see [Telegram Bot Setup Instructions](https://core.telegram.org/bots#6-botfather)) * Sonarr > URL, API Key, Quality Profile ID * Radarr > URL, API Key, Quality Profile ID +* Readarr > URL, API Key, Quality Profile ID, Metadata Profile ID ### Docker & Docker-Compose @@ -45,9 +46,9 @@ Send a private message to your bot saying `/start ` where `` **Double Caution**: Do not authenticate as an admin in a group chat. Always use a private message with your bot. -### Search & Add a Series to Sonarr or a Movie to Radarr +### Search & Add a Series to Sonarr, a Movie to Radarr, or a Book to Readarr -Send the bot a (private or group) message saying `/series ` or `/movie <title>` (replace with custom command aliases, as configured in `settings.py`). The bot will reply with information about the first result, along with buttons to move forward and back within the search results, pop out to tvdb, TMDB, or IMDb, add the current series/movie to Sonarr/Radarr, or cancel the search. When you click the button to add the series/movie to Sonarr/Radarr, the bot will ask what root folder to put the series/movie in, then what quality profile to use--unless you have only one root folder or quality profile enabled in Searcharr settings, in which case it will skip those steps and add the series/movie straight away. +Send the bot a (private or group) message saying `/series <title>`, `/movie <title>`, or `/book <title>` (replace with custom command aliases, as configured in `settings.py`). The bot will reply with information about the first result, along with buttons to move forward and back within the search results, pop out to tvdb, TMDB, or IMDb, or Goodreads for books, add the current series/movie/book to Sonarr/Radarr/Readarr, or cancel the search. When you click the button to add the series/movie/book to Sonarr/Radarr/Readarr, the bot will ask what root folder to put the series/movie/book in, then what quality profile to use--unless you have only one root folder or quality profile enabled in Searcharr settings, in which case it will skip those steps and add the series/movie straight away. ### Manage Users diff --git a/lang/en-us.yml b/lang/en-us.yml index 02b269c..998aca9 100644 --- a/lang/en-us.yml +++ b/lang/en-us.yml @@ -4,6 +4,7 @@ language_label: English movie: movie series: series season: season +book: book title: title title_here: title here password: password @@ -57,3 +58,9 @@ help_sonarr: Use {series_commands} to add a series to Sonarr. help_radarr: Use {movie_commands} to add a movie to Radarr. no_features: Sorry, but all of my features are currently disabled. admin_help: Since you are an admin, you can also use {commands} to manage users. +readarr_disabled: Sorry, but book support is disabled. +include_book_title_in_cmd: Please include the book title in the command, e.g. {commands} +no_matching_books: Sorry, but I didn't find any matching books. +help_readarr: Use {book_commands} to add a book to Readarr. +no_metadata_profiles: "Error adding {kind}: no metadata profiles enabled for {app}! Please check your Searcharr configuration and try again." +add_metadata_button: "Add Metadata: {metadata}" \ No newline at end of file diff --git a/log.py b/log.py index bdabcc9..0f14ae1 100644 --- a/log.py +++ b/log.py @@ -1,6 +1,6 @@ """ Searcharr -Sonarr & Radarr Telegram Bot +Sonarr, Radarr & Readarr Telegram Bot Log Helper By Todd Roberts /~https://github.com/toddrob99/searcharr diff --git a/radarr.py b/radarr.py index c1c0431..35df10c 100644 --- a/radarr.py +++ b/radarr.py @@ -1,6 +1,6 @@ """ Searcharr -Sonarr & Radarr Telegram Bot +Sonarr, Radarr & Readarr Telegram Bot Radarr API Wrapper By Todd Roberts /~https://github.com/toddrob99/searcharr diff --git a/readarr.py b/readarr.py new file mode 100644 index 0000000..040726d --- /dev/null +++ b/readarr.py @@ -0,0 +1,257 @@ +""" +Searcharr +Sonarr, Radarr & Readarr Telegram Bot +Readarr API Wrapper +By Ayman Bagabas +/~https://github.com/toddrob99/searcharr +""" +import requests +from urllib.parse import quote + +from log import set_up_logger + + +class Readarr(object): + def __init__(self, api_url, api_key, verbose=False): + self.logger = set_up_logger("searcharr.readarr", verbose, False) + self.logger.debug("Logging started!") + if api_url[-1] == "/": + api_url = api_url[:-1] + if api_url[:4] != "http": + self.logger.error( + "Invalid Readarr URL detected. Please update your settings to include http:// or https:// on the beginning of the URL." + ) + self.readarr_version = self.discover_version(api_url, api_key) + if not self.readarr_version.startswith("0."): + self.api_url = api_url + "/api/v1/{endpoint}?apikey=" + api_key + self._quality_profiles = self.get_all_quality_profiles() + self._metadata_profiles = self.get_all_metadata_profiles() + self._root_folders = self.get_root_folders() + + def discover_version(self, api_url, api_key): + try: + self.api_url = api_url + "/api/v1/{endpoint}?apikey=" + api_key + readarrInfo = self._api_get("system/status") + self.logger.debug( + f"Discovered Readarr version {readarrInfo.get('version')}. Using v1 api." + ) + return readarrInfo.get("version") + except requests.exceptions.HTTPError as e: + self.logger.debug(f"Readarr v1 API threw exception: {e}") + + try: + self.api_url = api_url + "/api/{endpoint}?apikey=" + api_key + readarrInfo = self._api_get("system/status") + self.logger.warning( + f"Discovered Readarr version {readarrInfo.get('version')}. Using legacy API. Consider upgrading to the latest version of Readarr for the best experience." + ) + return readarrInfo.get("version") + except requests.exceptions.HTTPError as e: + self.logger.debug(f"Readarr legacy API threw exception: {e}") + + self.logger.debug("Failed to discover Readarr version") + return None + + def lookup_book(self, title): + r = self._api_get( + "search", {"term": quote(title)} + ) + if not r: + return [] + + return [ + { + "title": x.get("book").get("title"), + "authorId": x.get("book").get("authorId"), + "authorTitle": x.get("book").get("authorTitle"), + "seriesTitle": x.get("book").get("seriesTitle"), + "disambiguation": x.get("book").get("disambiguation"), + "overview": x.get("book").get("overview", "No overview available."), + "remotePoster": x.get("book").get( + "remoteCover", + "https://artworks.thetvdb.com/banners/images/missing/movie.jpg", + ), + "releaseDate": x.get("book").get("releaseDate"), + "foreignBookId": x.get("book").get("foreignBookId"), + "id": x.get("book").get("id"), + "pageCount": x.get("book").get("pageCount"), + "titleSlug": x.get("book").get("titleSlug"), + "images": x.get("book").get("images"), + "links": x.get("book").get("links"), + "author": x.get("book").get("author"), + "editions": x.get("book").get("editions"), + } + for x in r if x.get("book") + ] + + def add_book( + self, + book_info=None, + search=True, + monitored=True, + additional_data={}, + ): + if not book_info: + return False + + if not book_info: + book_info = self.lookup_book(book_info['title']) + if len(book_info): + book_info = book_info[0] + else: + return False + + self.logger.debug(f"Additional data: {additional_data}") + + path = additional_data["p"] + quality = int(additional_data["q"]) + metadata = int(additional_data["m"]) + tags = additional_data.get("t", "") + if len(tags): + tag_ids = [int(x) for x in tags.split(",")] + else: + tag_ids = [] + + params = { + "title": book_info["title"], + "releaseDate": book_info["releaseDate"], + "foreignBookId": book_info["foreignBookId"], + "titleSlug": book_info["titleSlug"], + "monitored": monitored, + "anyEditionOk": True, + "addOptions": {"searchForNewBook": search}, + "editions": book_info["editions"], + "author": { + "qualityProfileId": quality, + "metadataProfileId": metadata, + "foreignAuthorId": book_info["author"]["foreignAuthorId"], + "rootFolderPath": path, + "tags": tag_ids, + } + } + + return self._api_post("book", params) + + def get_root_folders(self): + r = self._api_get("rootfolder", {}) + if not r: + return [] + + return [ + { + "path": x.get("path"), + "freeSpace": x.get("freeSpace"), + "totalSpace": x.get("totalSpace"), + "id": x.get("id"), + } + for x in r + ] + + def _api_get(self, endpoint, params={}): + url = self.api_url.format(endpoint=endpoint) + for k, v in params.items(): + url += f"&{k}={v}" + self.logger.debug(f"Submitting GET request: [{url}]") + r = requests.get(url) + if r.status_code not in [200, 201, 202, 204]: + r.raise_for_status() + return None + else: + return r.json() + + def get_all_tags(self): + r = self._api_get("tag", {}) + self.logger.debug(f"Result of API call to get all tags: {r}") + return [] if not r else r + + def get_filtered_tags(self, allowed_tags): + r = self.get_all_tags() + if not r: + return [] + elif allowed_tags == []: + return [x for x in r if not x["label"].startswith("searcharr-")] + else: + return [ + x + for x in r + if not x["label"].startswith("searcharr-") + and (x["label"] in allowed_tags or x["id"] in allowed_tags) + ] + + def add_tag(self, tag): + params = { + "label": tag, + } + t = self._api_post("tag", params) + self.logger.debug(f"Result of API call to add tag: {t}") + return t + + def get_tag_id(self, tag): + if i := next( + iter( + [ + x.get("id") + for x in self.get_all_tags() + if x.get("label").lower() == tag.lower() + ] + ), + None, + ): + self.logger.debug(f"Found tag id [{i}] for tag [{tag}]") + return i + else: + self.logger.debug(f"No tag id found for [{tag}]; adding...") + t = self.add_tag(tag) + if not isinstance(t, dict): + self.logger.error( + f"Wrong data type returned from Readarr API when attempting to add tag [{tag}]. Expected dict, got {type(t)}." + ) + return None + else: + self.logger.debug( + f"Created tag id for tag [{tag}]: {t['id']}" + if t.get("id") + else f"Could not add tag [{tag}]" + ) + return t.get("id", None) + + def lookup_quality_profile(self, v): + # Look up quality profile from a profile name or id + return next( + (x for x in self._quality_profiles if str(v) in [x["name"], str(x["id"])]), + None, + ) + + def get_all_quality_profiles(self): + return ( + self._api_get("qualityProfile", {}) + ) or None + + def lookup_metadata_profile(self, v): + # Look up metadata profile from a profile name or id + return next( + (x for x in self._metadata_profiles if str(v) in [x["name"], str(x["id"])]), + None, + ) + + def get_all_metadata_profiles(self): + return ( + self._api_get("metadataprofile", {}) + ) or None + + def lookup_root_folder(self, v): + # Look up root folder from a path or id + return next( + (x for x in self._root_folders if str(v) in [x["path"], str(x["id"])]), + None, + ) + + def _api_post(self, endpoint, params={}): + url = self.api_url.format(endpoint=endpoint) + self.logger.debug(f"Submitting POST request: [{url}]; params: [{params}]") + r = requests.post(url, json=params) + if r.status_code not in [200, 201, 202, 204]: + r.raise_for_status() + return None + else: + return r.json() diff --git a/requirements.txt b/requirements.txt index a4c09bc..eb64d0d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,3 +2,4 @@ argparse requests python-telegram-bot pyyaml +arrow diff --git a/searcharr.py b/searcharr.py index 76ce0c5..f0aa53b 100644 --- a/searcharr.py +++ b/searcharr.py @@ -1,6 +1,6 @@ """ Searcharr -Sonarr & Radarr Telegram Bot +Sonarr, Radarr & Readarr Telegram Bot By Todd Roberts /~https://github.com/toddrob99/searcharr """ @@ -12,6 +12,7 @@ from threading import Lock from urllib.parse import parse_qsl import uuid +import arrow from telegram import InlineKeyboardButton, InlineKeyboardMarkup, InputMediaPhoto from telegram.error import BadRequest @@ -20,6 +21,7 @@ from log import set_up_logger import radarr import sonarr +import readarr import settings __version__ = "2.2" @@ -257,6 +259,123 @@ def __init__(self, token): for t in settings.radarr_forced_tags: if t_id := self.radarr.get_tag_id(t): logger.debug(f"Tag id [{t_id}] for forced Radarr tag [{t}]") + self.readarr = ( + readarr.Readarr(settings.readarr_url, settings.readarr_api_key, args.verbose) + if settings.readarr_enabled + else None + ) + if self.readarr: + quality_profiles = [] + if not isinstance(settings.readarr_quality_profile_id, list): + settings.readarr_quality_profile_id = [ + settings.readarr_quality_profile_id + ] + for i in settings.readarr_quality_profile_id: + logger.debug( + f"Looking up/validating readarr quality profile id for [{i}]..." + ) + foundProfile = self.readarr.lookup_quality_profile(i) + if not foundProfile: + logger.error(f"readarr quality profile id/name [{i}] is invalid!") + else: + logger.debug( + f"Found readarr quality profile for [{i}]: [{foundProfile}]" + ) + quality_profiles.append(foundProfile) + if not len(quality_profiles): + logger.warning( + f"No valid readarr quality profile(s) provided! Using all of the quality profiles I found in readarr: {self.readarr._quality_profiles}" + ) + else: + logger.debug( + f"Using the following readarr quality profile(s): {[(x['id'], x['name']) for x in quality_profiles]}" + ) + self.readarr._quality_profiles = quality_profiles + metadata_profiles = [] + if not isinstance(settings.readarr_metadata_profile_id, list): + settings.readarr_metadata_profile_id = [ + settings.readarr_metadata_profile_id + ] + for i in settings.readarr_metadata_profile_id: + logger.debug( + f"Looking up/validating readarr metadata profile id for [{i}]..." + ) + foundProfile = self.readarr.lookup_metadata_profile(i) + if not foundProfile: + logger.error(f"readarr metadata profile id/name [{i}] is invalid!") + else: + logger.debug( + f"Found readarr metadata profile for [{i}]: [{foundProfile}]" + ) + metadata_profiles.append(foundProfile) + if not len(metadata_profiles): + logger.warning( + f"No valid readarr metadata profile(s) provided! Using all of the metadata profiles I found in readarr: {self.readarr._metadata_profiles}" + ) + else: + logger.debug( + f"Using the following readarr metadata profile(s): {[(x['id'], x['name']) for x in metadata_profiles]}" + ) + self.readarr._metadata_profiles = metadata_profiles + + root_folders = [] + if not hasattr(settings, "readarr_book_paths"): + settings.readarr_book_paths = [] + logger.warning( + 'No readarr_movie_paths setting detected. Please set one in settings.py (readarr_movie_paths=["/path/1", "/path/2"]). Proceeding with all root folders configured in readarr.' + ) + if not isinstance(settings.readarr_book_paths, list): + settings.readarr_book_paths = [settings.readarr_book_paths] + for i in settings.readarr_book_paths: + logger.debug(f"Looking up/validating readarr root folder for [{i}]...") + foundPath = self.readarr.lookup_root_folder(i) + if not foundPath: + logger.error(f"readarr root folder path/id [{i}] is invalid!") + else: + logger.debug(f"Found readarr root folder for [{i}]: [{foundPath}]") + root_folders.append(foundPath) + if not len(root_folders): + logger.warning( + f"No valid readarr root folder(s) provided! Using all of the root folders I found in readarr: {self.readarr._root_folders}" + ) + else: + logger.debug( + f"Using the following readarr root folder(s): {[(x['id'], x['path']) for x in root_folders]}" + ) + self.readarr._root_folders = root_folders + if not hasattr(settings, "readarr_tag_with_username"): + settings.readarr_tag_with_username = True + logger.warning( + "No readarr_tag_with_username setting found. Please add readarr_tag_with_username to settings.py (readarr_tag_with_username=True or readarr_tag_with_username=False). Defaulting to True." + ) + if not hasattr(settings, "readarr_movie_command_aliases"): + settings.readarr_book_command_aliases = ["book"] + logger.warning( + 'No readarr_book_command_aliases setting found. Please add readarr_movie_command_aliases to settings.py (e.g. readarr_book_command_aliases=["book", "bk"]. Defaulting to ["book"].' + ) + if not hasattr(settings, "readarr_forced_tags"): + settings.readarr_forced_tags = [] + logger.warning( + 'No readarr_forced_tags setting found. Please add readarr_forced_tags to settings.py (e.g. readarr_forced_tags=["tag-1", "tag-2"]) if you want specific tags added to each movie. Defaulting to empty list ([]).' + ) + if not hasattr(settings, "readarr_allow_user_to_select_tags"): + settings.readarr_allow_user_to_select_tags = True + logger.warning( + "No readarr_allow_user_to_select_tags setting found. Please add readarr_allow_user_to_select_tags to settings.py (e.g. readarr_allow_user_to_select_tags=False) if you do not want users to be able to select tags when adding a movie. Defaulting to True." + ) + if not hasattr(settings, "readarr_user_selectable_tags"): + settings.readarr_user_selectable_tags = [] + logger.warning( + 'No readarr_user_selectable_tags setting found. Please add readarr_user_selectable_tags to settings.py (e.g. readarr_user_selectable_tags=["tag-1", "tag-2"]) if you want to limit the tags a user can select. Defaulting to empty list ([]), which will present the user with all tags.' + ) + for t in settings.readarr_user_selectable_tags: + if t_id := self.readarr.get_tag_id(t): + logger.debug( + f"Tag id [{t_id}] for user-selectable readarr tag [{t}]" + ) + for t in settings.readarr_forced_tags: + if t_id := self.readarr.get_tag_id(t): + logger.debug(f"Tag id [{t_id}] for forced readarr tag [{t}]") self.conversations = {} if not hasattr(settings, "searcharr_admin_password"): @@ -326,6 +445,77 @@ def cmd_start(self, update, context): else: update.message.reply_text(self._xlate("incorrect_pw")) + def cmd_book(self, update, context): + logger.debug(f"Received book cmd from [{update.message.from_user.username}]") + if not self._authenticated(update.message.from_user.id): + update.message.reply_text( + self._xlate( + "auth_required", + commands=" OR ".join( + [ + f"`/{c} <{self._xlate('password')}>`" + for c in settings.searcharr_start_command_aliases + ] + ), + ) + ) + return + if not settings.radarr_enabled: + update.message.reply_text(self._xlate("readarr_disabled")) + return + title = self._strip_entities(update.message) + if not len(title): + x_title = self._xlate("title").title() + update.message.reply_text( + self._xlate( + "include_book_title_in_cmd", + commands=" OR ".join( + [ + f"`/{c} {x_title}`" + for c in settings.readarr_book_command_aliases + ] + ), + ) + ) + return + results = self.readarr.lookup_book(title) + cid = self._generate_cid() + # self.conversations.update({cid: {"cid": cid, "type": "book", "results": results}}) + self._create_conversation( + id=cid, + username=str(update.message.from_user.username), + kind="book", + results=results, + ) + + if not len(results): + update.message.reply_text(self._xlate("no_matching_books")) + else: + r = results[0] + reply_message, reply_markup = self._prepare_response( + "book", r, cid, 0, len(results) + ) + try: + context.bot.sendPhoto( + chat_id=update.message.chat.id, + photo=r["remotePoster"], + caption=reply_message, + reply_markup=reply_markup, + ) + except BadRequest as e: + if str(e) in self._bad_request_poster_error_messages: + logger.error( + f"Error sending photo [{r['remotePoster']}]: BadRequest: {e}. Attempting to send with default poster..." + ) + context.bot.sendPhoto( + chat_id=update.message.chat.id, + photo="https://artworks.thetvdb.com/banners/images/missing/movie.jpg", + caption=reply_message, + reply_markup=reply_markup, + ) + else: + raise + def cmd_movie(self, update, context): logger.debug(f"Received movie cmd from [{update.message.from_user.username}]") if not self._authenticated(update.message.from_user.id): @@ -579,7 +769,7 @@ def callback(self, update, context): # self.conversations.pop(cid) query.message.delete() elif op == "prev": - if convo["type"] in ["series", "movie"]: + if convo["type"] in ["series", "movie", "book"]: if i <= 0: query.answer() return @@ -628,7 +818,7 @@ def callback(self, update, context): reply_markup=reply_markup, ) elif op == "next": - if convo["type"] in ["series", "movie"]: + if convo["type"] in ["series", "movie", "book"]: if i >= len(convo["results"]): query.answer() return @@ -687,6 +877,8 @@ def callback(self, update, context): if convo["type"] == "series" else self.radarr._root_folders if convo["type"] == "movie" + else self.readarr._root_folders + if convo["type"] == "book" else [] ) if not additional_data.get("p"): @@ -737,7 +929,7 @@ def callback(self, update, context): self._xlate( "no_root_folders", kind=self._xlate(convo["type"]), - app="Sonarr" if convo["type"] == "series" else "Radarr", + app="Sonarr" if convo["type"] == "series" else "Radarr" if convo['type'] == 'movie' else 'Readarr', ) ) query.message.delete() @@ -770,6 +962,8 @@ def callback(self, update, context): self.sonarr._quality_profiles if convo["type"] == "series" else self.radarr._quality_profiles + if convo["type"] == "movie" + else self.readarr._quality_profiles ) if len(quality_profiles) > 1: # prepare response to prompt user to select quality profile, and return @@ -819,7 +1013,64 @@ def callback(self, update, context): self._xlate( "no_quality_profiles", kind=self._xlate(convo["type"]), - app="Sonarr" if convo["type"] == "series" else "Radarr", + app="Sonarr" if convo["type"] == "series" else "Radarr" if convo['type'] == 'movie' else 'Readarr', + ) + ) + query.message.delete() + query.answer() + return + + if convo['type'] == 'book' and not additional_data.get("m"): + metadata_profiles = self.readarr._metadata_profiles + if len(metadata_profiles) > 1: + # prepare response to prompt user to select quality profile, and return + reply_message, reply_markup = self._prepare_response( + convo["type"], + r, + cid, + i, + len(convo["results"]), + add=True, + metadata_profiles=metadata_profiles, + ) + try: + query.message.edit_media( + media=InputMediaPhoto(r["remotePoster"]), + reply_markup=reply_markup, + ) + except BadRequest as e: + if str(e) in self._bad_request_poster_error_messages: + logger.error( + f"Error sending photo [{r['remotePoster']}]: BadRequest: {e}. Attempting to send with default poster..." + ) + query.message.edit_media( + media=InputMediaPhoto( + "https://artworks.thetvdb.com/banners/images/missing/movie.jpg" + ), + reply_markup=reply_markup, + ) + else: + raise + query.bot.edit_message_caption( + chat_id=query.message.chat_id, + message_id=query.message.message_id, + caption=reply_message, + reply_markup=reply_markup, + ) + query.answer() + return + elif len(metadata_profiles) == 1: + logger.debug( + f"Only one metadata profile enabled. Adding/Updating additional data for cid=[{cid}], key=[m], value=[{metadata_profiles[0]['id']}]..." + ) + self._update_add_data(cid, "m", metadata_profiles[0]["id"]) + else: + self._delete_conversation(cid) + query.message.reply_text( + self._xlate( + "no_metadata_profiles", + kind=self._xlate(convo["type"]), + app="Sonarr" if convo["type"] == "series" else "Radarr" if convo['type'] == 'movie' else 'Readarr', ) ) query.message.delete() @@ -886,10 +1137,16 @@ def callback(self, update, context): ) allow_user_to_select_tags = settings.radarr_allow_user_to_select_tags forced_tags = settings.radarr_forced_tags + elif convo["type"] == "book": + all_tags = self.readarr.get_filtered_tags( + settings.readarr_user_selectable_tags + ) + allow_user_to_select_tags = settings.readarr_allow_user_to_select_tags + forced_tags = settings.readarr_forced_tags if allow_user_to_select_tags and not additional_data.get("td"): if not len(all_tags): logger.warning( - f"User tagging is enabled, but no tags found. Make sure there are tags in {'Sonarr' if convo['type'] == 'series' else 'Radarr'} matching your Searcharr configuration." + f"User tagging is enabled, but no tags found. Make sure there are tags in {'Sonarr' if convo['type'] == 'series' else 'Radarr' if convo['type'] == 'movie' else 'Readarr'} matching your Searcharr configuration." ) elif not additional_data.get("tt"): reply_message, reply_markup = self._prepare_response( @@ -950,6 +1207,9 @@ def callback(self, update, context): elif convo["type"] == "movie": get_tag_id = self.radarr.get_tag_id tag_with_username = settings.radarr_tag_with_username + elif convo["type"] == "book": + get_tag_id = self.readarr.get_tag_id + tag_with_username = settings.readarr_tag_with_username if tag_with_username: tag = f"searcharr-{query.from_user.username if query.from_user.username else query.from_user.id}" if tag_id := get_tag_id(tag): @@ -984,6 +1244,13 @@ def callback(self, update, context): min_avail=settings.radarr_min_availability, additional_data=self._get_add_data(cid), ) + elif convo["type"] == "book": + added = self.readarr.add_book( + book_info=r, + monitored=settings.readarr_add_monitored, + search=settings.readarr_search_on_add, + additional_data=self._get_add_data(cid), + ) else: added = False except Exception as e: @@ -1151,6 +1418,7 @@ def _prepare_response( add=False, paths=None, quality_profiles=None, + metadata_profiles=None, monitor_options=None, tags=None, ): @@ -1174,12 +1442,20 @@ def _prepare_response( "TMDB", url=f"https://www.themoviedb.org/movie/{r['tmdbId']}" ) ) - if r["imdbId"]: - keyboardNavRow.append( - InlineKeyboardButton( - "IMDb", url=f"https://imdb.com/title/{r['imdbId']}" + elif kind == "book" and r["links"]: + for link in r["links"]: + keyboardNavRow.append( + InlineKeyboardButton( + link["name"], url=link["url"] + ) + ) + if kind == "series" or kind == "movie": + if r["imdbId"]: + keyboardNavRow.append( + InlineKeyboardButton( + "IMDb", url=f"https://imdb.com/title/{r['imdbId']}" + ) ) - ) if total_results > 1 and i < total_results - 1: keyboardNavRow.append( InlineKeyboardButton( @@ -1227,6 +1503,16 @@ def _prepare_response( ) ], ) + elif metadata_profiles: + for m in metadata_profiles: + keyboard.append( + [ + InlineKeyboardButton( + self._xlate("add_metadata_button", metadata=m["name"]), + callback_data=f"{cid}^^^{i}^^^add^^m={m['id']}", + ) + ], + ) elif paths: for p in paths: keyboard.append( @@ -1282,6 +1568,11 @@ def _prepare_response( reply_message = f"{r['title']}{' (' + str(r['year']) + ')' if r['year'] and str(r['year']) not in r['title'] else ''}{' - ' + str(r['runtime']) + ' min' if r['runtime'] else ''} - {r['status'].title()}\n\n{r['overview']}"[ 0:1024 ] + elif kind == "book": + release = arrow.get(r["releaseDate"]) + reply_message = f"{r['title']}{' - ' + r['disambiguation'] if r['disambiguation'] else ''}{' - ' + r['seriesTitle'] if r['seriesTitle'] else ''} ({release.format('MMMM DD, YYYY')})\n\n{r['overview']}"[ + 0:1024 + ] else: reply_message = self._xlate("unexpected_error") @@ -1378,12 +1669,23 @@ def cmd_help(self, update, context): ] ), ) - if settings.sonarr_enabled and settings.radarr_enabled: - resp = f"{sonarr_help} {radarr_help}" + readarr_help = self._xlate( + "help_readarr", + book_commands=" OR ".join( + [ + f"`/{c} {self._xlate('title').title()}`" + for c in settings.readarr_book_command_aliases + ] + ), + ) + if settings.sonarr_enabled and settings.radarr_enabled and settings.readarr_enabled: + resp = f"{sonarr_help} {radarr_help} {readarr_help}" elif settings.sonarr_enabled: resp = sonarr_help elif settings.radarr_enabled: resp = radarr_help + elif settings.readarr_enabled: + resp = readarr_help else: resp = self._xlate("no_features") @@ -1417,6 +1719,9 @@ def run(self): for c in settings.searcharr_start_command_aliases: logger.debug(f"Registering [/{c}] as a start command") updater.dispatcher.add_handler(CommandHandler(c, self.cmd_start)) + for c in settings.readarr_book_command_aliases: + logger.debug(f"Registering [/{c}] as a book command") + updater.dispatcher.add_handler(CommandHandler(c, self.cmd_book)) for c in settings.radarr_movie_command_aliases: logger.debug(f"Registering [/{c}] as a movie command") updater.dispatcher.add_handler(CommandHandler(c, self.cmd_movie)) diff --git a/settings-sample.py b/settings-sample.py index 5efa933..39c573a 100644 --- a/settings-sample.py +++ b/settings-sample.py @@ -1,6 +1,6 @@ """ Searcharr -Sonarr & Radarr Telegram Bot +Sonarr, Radarr & Readarr Telegram Bot By Todd Roberts /~https://github.com/toddrob99/searcharr """ @@ -45,3 +45,18 @@ radarr_min_availability = "released" # options: "announced", "inCinemas", "released" radarr_movie_command_aliases = ["movie"] # e.g. ["movie", "mv", "m"] radarr_movie_paths = [] # e.g. ["/movies", "/other-movies"] - can be full path or id value - leave empty to enable all + +# Readarr +readarr_enabled = True +readarr_url = "" # http://192.168.0.100:8787 +readarr_api_key = "" +readarr_quality_profile_id = ["eBook", "Spoken"] # can be name or id value - include multiple to allow the user to choose +readarr_metadata_profile_id = ["Standard"] # can be name or id value - include multiple to allow the user to choose +readarr_add_monitored = True +readarr_search_on_add = True +readarr_tag_with_username = True +readarr_forced_tags = [] # e.g. ["searcharr", "friends-and-family"] - leave empty for none +readarr_allow_user_to_select_tags = True +readarr_user_selectable_tags = [] # e.g. ["custom-tag-1", "custom-tag-2"] - leave empty to let user choose from all tags in Readarr +readarr_book_command_aliases = ["book"] # e.g. ["book", "bk", "b"] +readarr_book_paths = [] # e.g. ["/books", "/other-books"] - can be full path or id value - leave empty to enable all diff --git a/sonarr.py b/sonarr.py index 909d245..801cc8e 100644 --- a/sonarr.py +++ b/sonarr.py @@ -1,6 +1,6 @@ """ Searcharr -Sonarr & Radarr Telegram Bot +Sonarr, Radarr & Readarr Telegram Bot Sonarr API Wrapper By Todd Roberts /~https://github.com/toddrob99/searcharr