diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd3d491 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +config.conf +*.jpg +*~ +#* +bartonfink.png \ No newline at end of file diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..436f9df --- /dev/null +++ b/bot.py @@ -0,0 +1,215 @@ +import asyncio +import logging +import slixmpp +import requests +import json +import datetime +import time +import configparser +import os +import threading +from jellyfin_apiclient_python import JellyfinClient +import uuid + +def json_to_key_value_string(json_data): + # Parse JSON data + data = json.loads(json_data) + + # Initialize an empty list to store key-value pairs + result_lines = [] + + # Recursively process the JSON data + def process_item(item, prefix=''): + if isinstance(item, dict): + for key, value in item.items(): + if isinstance(value, (dict, list)): + process_item(value, f"{prefix}{key}.") + else: + result_lines.append(f"{prefix}{key}: {value}") + elif isinstance(item, list): + for i, value in enumerate(item): + process_item(value, f"{prefix}[{i}].") + + process_item(data) + + return "\n".join(result_lines) + +class QBBot(slixmpp.ClientXMPP): + def __init__(self, jid, password, room, nick, api_url, api_username, api_password): + slixmpp.ClientXMPP.__init__(self, jid, password) + + self.room = room + self.nick = nick + + + # Initialize Jellyfin caller + self.jf_api = None + self.jf_api_url = api_url + self.jf_api_username = api_username + self.jf_api_password = api_password + self.jf_api_credentials = None + self.jf_api_init() + self.jf_api_login() + + + + self.add_event_handler("session_start", self.session_start) + self.add_event_handler("message", self.message) + # The groupchat_message event is triggered whenever a message + # stanza is received from any chat room. If you also also + # register a handler for the 'message' event, MUC messages + # will be processed by both handlers. + self.add_event_handler("groupchat_message", self.muc_message) + + # If you wanted more functionality, here's how to register plugins: + # self.register_plugin('xep_0030') # Service Discovery + # self.register_plugin('xep_0199') # XMPP Ping + + # Here's how to access plugins once you've registered them: + # self['xep_0030'].add_feature('echo_demo') + + def jf_api_init(self): + if self.jf_api is not None: + print("Warning: API already initialized, doing nothing") + return + + self.jf_api = JellyfinClient() + self.jf_api.config.app('xmpp-jellyfin-bot', '0.0.1', 'xmpp-jellyfin-bot', uuid.getnode()) + self.jf_api.config.data["auth.ssl"] = False + + def jf_api_login(self): + if self.jf_api_credentials is None: + self.jf_api.auth.connect_to_address(self.jf_api_url) + self.jf_api.auth.login(self.jf_api_url, self.jf_api_username, self.jf_api_password) + self.jf_api_credentials = self.jf_api.auth.credentials.get_credentials() + + else: + self.jf_api.authenticate({"Servers": [self.jf_api_credentials]}, discover=False) + + + + + async def session_start(self, event): + await self.get_roster() + self.send_presence() + self.plugin['xep_0045'].join_muc(self.room, self.nick) + avatar_file = "./bartonfink.png" + try: + avatar_file = open(avatar_file, 'rb') + except IOError: + logging.error("Could not find avatar file") + return self.disconnect() + + avatar = avatar_file.read() + avatar_type = 'image/png' + avatar_id = self['xep_0084'].generate_id(avatar) + avatar_bytes = len(avatar) + avatar_file.close() + + used_xep84 = False + + logging.info('Publish XEP-0084 avatar data') + result = await self['xep_0084'].publish_avatar(avatar) + if isinstance(result, slixmpp.exceptions.XMPPError): + logging.warning('Could not publish XEP-0084 avatar') + else: + used_xep84 = True + + logging.info('Update vCard with avatar') + result = await self['xep_0153'].set_avatar(avatar=avatar, mtype=avatar_type) + if isinstance(result, slixmpp.exceptions.XMPPError): + print('Could not set vCard avatar') + + if used_xep84: + logging.info('Advertise XEP-0084 avatar metadata') + result = await self['xep_0084'].publish_avatar_metadata([ + {'id': avatar_id, + 'type': avatar_type, + 'bytes': avatar_bytes} + # We could advertise multiple avatars to provide + # options in image type, source (HTTP vs pubsub), + # size, etc. + # {'id': ....} + ]) + if isinstance(result, slixmpp.exceptions.XMPPError): + logging.warning('Could not publish XEP-0084 metadata') + + logging.info('Wait for presence updates to propagate...') + #self.schedule('end', 5, self.disconnect, kwargs={'wait': True}) + + # Most get_*/set_* methods from plugins use Iq stanzas, which + # are sent asynchronously. You can almost always provide a + # callback that will be executed when the reply is received. + + def message(self, msg): + if msg['type'] in ('chat', 'normal'): + msg.reply("I was not designed to take direct messages, please use me in a MUC").send() + + def muc_message(self, msg): + # TODO + if msg['mucnick'] != self.nick and msg['body'].startswith(self.nick): + message = "```\n" + tokens = msg['body'].split() + match tokens[1]: + case "help": + message += "XMPP bot to query a Jellyfin server for content\n" + message += "Please use this bot first if you want to download something but are not sure if it's on the server\n" + message += "Commands\n" + message += "help: Displays this help\n" + message += "search CATEGORY SEARCH_QUERY: Search CATEGORY for SEARCH_QUERY on the Jellyfin server\n" + case "search": + category = tokens[2] + category = category.lower() + if category not in ["movie", "tv", "music"]: + message += "Error: invalid category " + tokens[2] + else: + search_query = " ".join(tokens[3:]) + query = self.jf_api.jellyfin.search_media_items(term=search_query, media=category) + items = query['Items'] + items = [i for i in items if i['MediaType'] != "Unknown"] + if len(items) == 0: + message += f"{search_query} not found in category {category}" + match category: + case "movie": + for i in items: + message += f"{i['Name']}, {i['ProductionYear']}, {i['OfficialRating'] if 'OfficialRating' in i else "unknown rating"}, {i['Container']}\n" + + message += "```" + + self.send_message(mto=msg['from'].bare, + mbody=message, + mtype='groupchat') + + return 0 + + +if __name__ == '__main__': + # Ideally use optparse or argparse to get JID, + # password, and log level. + + logging.basicConfig(level=logging.INFO, + format='%(levelname)-8s %(message)s') + + config = configparser.ConfigParser() + if not os.path.exists("./config.conf"): + logging.error("Could not find ./config.conf, please ensure it exists") + exit + + config.read('config.conf') + config_default = config['DEFAULT'] + xmpp = QBBot(config_default['jf_jid'].strip('\"'), + config_default['jf_pass'].strip('\"'), + config_default['jf_muc'].strip('\"'), + config_default['jf_nick'].strip('\"'), + config_default['api_url'].strip('\"'), + config_default['api_user'].strip('\"'), + config_default['api_pass'].strip('\"')) + + + xmpp.register_plugin('xep_0030') # Service Discovery + xmpp.register_plugin('xep_0045') # Multi-User Chat + xmpp.register_plugin('xep_0199') # XMPP Ping + xmpp.register_plugin('xep_0084') # vCard avatar + xmpp.register_plugin('xep_0153') # Something required for vCard + xmpp.connect() + xmpp.process() diff --git a/config.conf.template b/config.conf.template new file mode 100644 index 0000000..25f01a2 --- /dev/null +++ b/config.conf.template @@ -0,0 +1,8 @@ +[DEFAULT] +qb_jid = # JID for the bot +qb_pass = # Password for the bot +qb_muc = # MUC the bot should connect to +qb_nick = # Nickname for the bot in the MUC +api_url = # URL for the qbittorrent api we are connecting to +api_user = # Username for the qbittorrent api +api_pass = # Password for the qbittorent api \ No newline at end of file diff --git a/jellyfin_logo.png b/jellyfin_logo.png new file mode 100644 index 0000000..c637272 Binary files /dev/null and b/jellyfin_logo.png differ