├── requirements.txt ├── README.md └── bot.py /requirements.txt: -------------------------------------------------------------------------------- 1 | odmpy==0.80 2 | python-telegram-bot==20.4 3 | Requests==2.31.0 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [@libby_ab_bot](https://t.me/libby_ab_bot) Telegram bot code 2 | 3 | This is the code for my Telegram Bot to download Libby Audiobook MP3 4 | 5 | # Usage Instruction 6 | 7 | This bot can download audiobooks that you have on loan, but you'll need to already have the Libby app set up on your phone/device. 8 | 9 | You can begin by granting it access to your Libby library. This bot provides the option to set up Libby on another device. 10 | 11 | Don't worry, this will not log you out from your current Libby device. 12 | 13 | [https://help.libbyapp.com/en-us/6070.htm](https://help.libbyapp.com/en-us/6070.htm) 14 | 15 | 16 | 1. Use `/sync XXXXXXXX` (8 digit Libby setup code) 17 | 2. `/list` (to see all your audiobooks in your loan) 18 | 3. `/download 2` (the number on the item of your list) 19 | 4. Wait a while, and it should give you a link to download the zip file. 20 | 21 | # Credit 22 | 23 | [odmpy](https://github.com/ping/odmpy) 24 | 25 | 26 | -------------------------------------------------------------------------------- /bot.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | import shutil 4 | import subprocess 5 | import threading 6 | import zipfile 7 | import logging 8 | 9 | import subprocess 10 | from telegram import Update 11 | from telegram.ext import Application, CommandHandler, ContextTypes 12 | 13 | from odmpy.libby import ( 14 | LibbyClient, 15 | ) 16 | 17 | import requests 18 | 19 | logging.basicConfig( 20 | filename="log.txt", 21 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 22 | level=logging.INFO 23 | ) 24 | logger = logging.getLogger(__name__) 25 | 26 | bot_token = '' 27 | 28 | async def sync(update: Update, context: ContextTypes.DEFAULT_TYPE): 29 | async def run(): 30 | try: 31 | logger.info(f"sync - {update.effective_message.id}!") 32 | token = context.args[0] 33 | 34 | chat_id = update.effective_chat.id 35 | 36 | # Create a unique download directory for this user based on their chat ID 37 | odm_setting = f"odm/{chat_id}" 38 | shutil.rmtree(odm_setting) 39 | 40 | if not os.path.exists(odm_setting): 41 | os.makedirs(odm_setting) 42 | 43 | libby_client = LibbyClient(settings_folder=odm_setting) 44 | libby_client.get_chip() 45 | libby_client.clone_by_code(token) 46 | 47 | # Send a message to the user 48 | await update.effective_message.reply_text("Synced, now run /list or /download id") 49 | 50 | except (IndexError, ValueError) as err: 51 | logger.error(f"sync error - {odm_setting} - {err}") 52 | await update.effective_message.reply_text(f"Sync unsuccessful, please use another libby setup code.") 53 | 54 | def thread(): 55 | loop = asyncio.new_event_loop() 56 | loop.run_until_complete(run()) 57 | threading.Thread(target=thread).start() 58 | 59 | async def list(update: Update, context: ContextTypes.DEFAULT_TYPE): 60 | async def run(): 61 | try: 62 | chat_id = update.effective_chat.id 63 | 64 | # Create a unique download directory for this user based on their chat ID 65 | odm_setting = f"odm/{chat_id}" 66 | if not os.path.exists(odm_setting): 67 | await update.effective_message.reply_text(f"No libby account found, use: /sync XXXXXXXX (8 digit libby setup code)\nTo get a Libby setup code, see https://help.libbyapp.com/en-us/6070.htm") 68 | await update.effective_message.reply_text(f"Dont worry, this will not log you out from your phone. ") 69 | 70 | # Command to run the odmpy command with the specified setting 71 | command = ['odmpy', 'libby', '--setting', odm_setting] 72 | 73 | # Create a subprocess and connect to its stdout and stderr 74 | proc = subprocess.Popen(command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True) 75 | 76 | proc.stdin.write('\n') 77 | proc.stdin.flush() 78 | 79 | # Capture the output and errors (if any) 80 | output = proc.communicate() 81 | output_string = output[0] # Access the string within the tuple 82 | formatted_output = output_string.replace('\\n', '\n') 83 | output_lines = formatted_output.split('\n') 84 | processed_output = '\n'.join(output_lines[3:-3]) 85 | 86 | if not processed_output: 87 | await update.effective_message.reply_text("Hmm, nothing to see here. Did you have something on your loan or did you successfully did /sync?") 88 | else: 89 | await update.effective_message.reply_text(processed_output) 90 | logger.info(f"list successful - {odm_setting} - {processed_output}") 91 | 92 | except (IndexError, ValueError) as err: 93 | logger.error(f"list error - {odm_setting} - {err}") 94 | await update.effective_message.reply_text("List Error") 95 | 96 | def thread(): 97 | loop = asyncio.new_event_loop() 98 | loop.run_until_complete(run()) 99 | threading.Thread(target=thread).start() 100 | 101 | 102 | async def download(update: Update, context: ContextTypes.DEFAULT_TYPE): 103 | async def run(): 104 | try: 105 | chat_id = update.effective_chat.id 106 | download_id = context.args[0] 107 | odm_setting = f"odm/{chat_id}" 108 | 109 | logger.info(f"download initiated - {odm_setting} - {chat_id} - {download_id}") 110 | await update.effective_message.reply_text("Please wait a while for download to complete") 111 | 112 | # Create a unique download directory for this user based on their chat ID 113 | #odm_setting = 'odmpy_settings' 114 | if not os.path.exists(odm_setting): 115 | await update.effective_message.reply_text("No sync token found, use: /sync token") 116 | raise Exception("No account found") 117 | 118 | download_folder = f"download/{chat_id}" 119 | if not os.path.exists(download_folder): 120 | os.makedirs(download_folder) 121 | 122 | # Command to run the odmpy command with the specified setting 123 | try: 124 | command = ['odmpy', '--retry', '3', 'libby', '-c', '-k', '-d', download_folder, '--setting', odm_setting, '--select', download_id] 125 | process = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 126 | output = process.stdout.decode('utf-8') 127 | error_output = process.stderr.decode('utf-8') 128 | if process.returncode != 0: 129 | return 130 | logger.info(f"download odmpy output - {odm_setting} - {output}") 131 | logger.error(f"download odmpy error - {odm_setting} - {error_output}") 132 | 133 | except Exception as e: 134 | logger.error(f"download error - {e}") 135 | return 136 | 137 | audiobook_files = [] 138 | 139 | for root, _, files in os.walk(download_folder): 140 | for file in files: 141 | if file.endswith(".mp3") or file.endswith(".jpg") or file.endswith(".png"): 142 | filepath = os.path.join(root, file) 143 | audiobook_files.append(filepath) 144 | 145 | if len(audiobook_files): 146 | await update.effective_message.reply_text("Download finished, please wait for file to upload.") 147 | zip_file_path = f"{download_folder}/{chat_id}.zip" 148 | 149 | with zipfile.ZipFile(zip_file_path, "w", compression=zipfile.ZIP_STORED) as zip_file: 150 | for file_path in audiobook_files: 151 | zip_file.write(file_path, os.path.basename(file_path)) 152 | 153 | url = "https://litterbox.catbox.moe/resources/internals/api.php" 154 | files = { 155 | 'reqtype': (None, 'fileupload'), 156 | 'time': (None, '72h'), 157 | 'fileToUpload': (zip_file_path, open(zip_file_path, 'rb')) 158 | } 159 | response = requests.post(url, files=files) 160 | if response.status_code == 200: 161 | uploaded_url = response.text 162 | logger.info(f"download upload successful - {odm_setting} - {uploaded_url}") 163 | await update.effective_message.reply_text(f"File uploaded successfully. URL: {uploaded_url}") 164 | await update.effective_message.reply_text(f"Link will only be valid for 72 hours.") 165 | else: 166 | url = "https://pixeldrain.com/api/file" 167 | files = {'file': (zip_file_path, open(zip_file_path, 'rb'))} 168 | response = requests.post(url, files=files) 169 | if response.status_code == 201: 170 | data = response.json() # Parse the JSON response 171 | if data.get("success") and "id" in data: 172 | file_id = data["id"] 173 | file_url = f"https://pixeldrain.com/u/{file_id}" 174 | print("File URL:", file_url) 175 | logger.info(f"download upload successful - {odm_setting} - {file_url}") 176 | await update.effective_message.reply_text(f"File uploaded successfully. URL: {file_url}") 177 | #await update.effective_message.reply_text(f"Link will only be valid for 72 hours.") 178 | else: 179 | logger.error(f"download upload fail - {odm_setting} - {response}") 180 | await update.effective_message.reply_text("Error uploading the file. Likely file hosts are down. Please try again later.") 181 | 182 | # Delete the chat ID directory and its contents from the local file system 183 | else: 184 | logger.error(f"No books to be found here") 185 | await update.effective_message.reply_text("Hmm, it doesnt seems I've downloaded any audiobook, check with /list to make sure its a valid audiobook you're downloading.") 186 | 187 | shutil.rmtree(download_folder) 188 | 189 | except (IndexError, ValueError) as err: 190 | logger.error(f"download error - {odm_setting} - {err}") 191 | await update.effective_message.reply_text("Error") 192 | 193 | def thread(): 194 | loop = asyncio.new_event_loop() 195 | loop.run_until_complete(run()) 196 | threading.Thread(target=thread).start() 197 | 198 | 199 | 200 | async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): 201 | await context.bot.send_message( 202 | chat_id=update.effective_chat.id, 203 | text=f"Libby Bot is ONLINE. I can download audiobook from your library.\nTo start, use /sync XXXXXXXX (8 digit Libby setup code).\nTo get a Libby setup code, see https://help.libbyapp.com/en-us/6070.htm\nDont worry, this will not log you out from your phone.\nIf you recently used the chatbot and successfully linked your libby account, you can move on to /list or /download function.\nThen use /list to list down audiobooks available to download, and use /download number to specify one title to be downloaded. Example: /download 1\nWait a while then a download link will be provided to you.\nDownload link is valid for 3 days, if audiobook is larger than 1GB, then upload will fail." 204 | ) 205 | 206 | async def help(update: Update, context: ContextTypes.DEFAULT_TYPE): 207 | await context.bot.send_message( 208 | chat_id=update.effective_chat.id, 209 | text=f"I can download audiobook from your library.\nTo start, use /sync XXXXXXXX (8 digit Libby setup code).\nTo get a Libby setup code, see https://help.libbyapp.com/en-us/6070.htm\nDont worry, this will not log you out from your phone.\nIf you recently used the chatbot and successfully linked your libby account, you can move on to /list or /download function.\nThen use /list to list down audiobooks available to download, and use /download number to specify one title to be downloaded. Example: /download 1\nWait a while then a download link will be provided to you.\nDownload link is valid for 3 days, if audiobook is larger than 1GB, then upload will fail." 210 | ) 211 | 212 | 213 | if __name__ == "__main__": 214 | 215 | application = Application.builder().token(bot_token).build() 216 | 217 | start_handler = CommandHandler('start', start) 218 | application.add_handler(start_handler) 219 | 220 | sync_handler = CommandHandler('sync', sync) 221 | application.add_handler(sync_handler) 222 | 223 | list_handler = CommandHandler('list', list) 224 | application.add_handler(list_handler) 225 | 226 | download_handler = CommandHandler('download', download) 227 | application.add_handler(download_handler) 228 | 229 | help_handler = CommandHandler('help', help) 230 | application.add_handler(help_handler) 231 | 232 | # block thread!! 233 | 234 | application.run_polling() 235 | --------------------------------------------------------------------------------