├── __pycache__ ├── newznab.cpython-310.pyc ├── plugin_download_interface.cpython-310.pyc ├── plugin_loader.cpython-310.pyc ├── plugin_search_interface.cpython-310.pyc └── sabnzbd.cpython-310.pyc ├── app.py ├── config ├── config.json ├── plugins │ ├── download │ │ ├── __pycache__ │ │ │ ├── libgendl.cpython-310.pyc │ │ │ └── ytmusicdl.cpython-310.pyc │ │ ├── libgendl.py │ │ └── ytmusicdl.py │ └── search │ │ ├── __pycache__ │ │ ├── libgen.cpython-310.pyc │ │ └── ytmusic.cpython-310.pyc │ │ ├── libgen.py │ │ └── ytmusic.py └── sabqueue.conf ├── docker-compose.yaml ├── dockerfile ├── entrypoint.sh ├── images ├── newznab1.png ├── newznab2.png ├── sabnzbd1.png └── sabnzbd2.png ├── newznab.py ├── plugin_download_interface.py ├── plugin_search_interface.py ├── readme.md ├── requirements.txt ├── sabnzbd.py └── test.bash /__pycache__/newznab.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riffsphereha/newznabarr/6701b9ca82e1fb8b769550c8c59b3b4cdb2fb8de/__pycache__/newznab.cpython-310.pyc -------------------------------------------------------------------------------- /__pycache__/plugin_download_interface.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riffsphereha/newznabarr/6701b9ca82e1fb8b769550c8c59b3b4cdb2fb8de/__pycache__/plugin_download_interface.cpython-310.pyc -------------------------------------------------------------------------------- /__pycache__/plugin_loader.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riffsphereha/newznabarr/6701b9ca82e1fb8b769550c8c59b3b4cdb2fb8de/__pycache__/plugin_loader.cpython-310.pyc -------------------------------------------------------------------------------- /__pycache__/plugin_search_interface.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riffsphereha/newznabarr/6701b9ca82e1fb8b769550c8c59b3b4cdb2fb8de/__pycache__/plugin_search_interface.cpython-310.pyc -------------------------------------------------------------------------------- /__pycache__/sabnzbd.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riffsphereha/newznabarr/6701b9ca82e1fb8b769550c8c59b3b4cdb2fb8de/__pycache__/sabnzbd.cpython-310.pyc -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | import os 2 | import importlib 3 | import sys 4 | from flask import Flask, request, Response, jsonify 5 | import requests 6 | import xml.etree.ElementTree as ET 7 | import random 8 | import string 9 | import hashlib 10 | import threading 11 | import time 12 | 13 | from plugin_search_interface import PluginSearchBase 14 | from plugin_download_interface import PluginDownloadBase 15 | from newznab import searchresults_to_response 16 | from sabnzbd import * 17 | 18 | # directory variables 19 | BASE_DIR = os.path.dirname(os.path.abspath(__file__)) 20 | CONFIG_DIR = os.environ.get('CONFIG') 21 | FLASK_PORT = os.environ.get("FLASK_RUN_PORT", "10000") 22 | FLASK_HOST = os.environ.get("FLASK_RUN_HOST", "0.0.0.0") 23 | PLUGIN_SEARCH_DIR = os.path.join(CONFIG_DIR, "plugins","search") 24 | PLUGIN_DOWNLOAD_DIR = os.path.join(CONFIG_DIR, "plugins","download") 25 | DOWNLOAD_DIR = "/data/downloads/downloadarr" 26 | SAB_API = "abcde" 27 | SAB_CATEGORIES = ["lidarr"] 28 | 29 | # array holding plugins 30 | search_plugins = [] 31 | download_plugins = [] 32 | sabqueue = [] 33 | 34 | # falsk app 35 | app = Flask(__name__) 36 | 37 | # load all the search plugins 38 | def load_search_plugins(search_plugin_directory): 39 | search_plugins = [] 40 | sys.path.insert(0, search_plugin_directory) 41 | print("Loading search plugins from:" + search_plugin_directory) 42 | 43 | for filename in os.listdir(search_plugin_directory): 44 | if filename.endswith(".py") and filename != "__init__.py": 45 | module_name = filename[:-3] 46 | try: 47 | module = importlib.import_module(module_name) 48 | for attr in dir(module): 49 | obj = getattr(module, attr) 50 | if isinstance(obj, type) and issubclass(obj, PluginSearchBase) and obj is not PluginSearchBase: 51 | search_plugins.append(obj()) 52 | except Exception as e: 53 | print(f"Failed to load plugin {module_name}: {e}") 54 | sys.path.pop(0) 55 | print("Loaded search plugins: " + str(len(search_plugins))) 56 | return search_plugins 57 | 58 | def load_download_plugins(download_plugin_directory): 59 | download_plugins = [] 60 | sys.path.insert(0, download_plugin_directory) 61 | print("Loading download plugins from:" + download_plugin_directory) 62 | 63 | for filename in os.listdir(download_plugin_directory): 64 | if filename.endswith(".py") and filename != "__init__.py": 65 | module_name = filename[:-3] 66 | try: 67 | module = importlib.import_module(module_name) 68 | for attr in dir(module): 69 | obj = getattr(module, attr) 70 | if isinstance(obj, type) and issubclass(obj, PluginDownloadBase) and obj is not PluginDownloadBase: 71 | download_plugins.append(obj()) 72 | except Exception as e: 73 | print(f"Failed to load plugin {module_name}: {e}") 74 | sys.path.pop(0) 75 | print("Loaded download plugins: " + str(len(download_plugins))) 76 | return download_plugins 77 | 78 | def run_download_queue(): 79 | print("Download queue started") 80 | global sabqueue 81 | global CONFIG_DIR 82 | while True: 83 | print("Items in queue: " + str(len(sabqueue))) 84 | for dl in sabqueue: 85 | if dl['status'] == "Queued": 86 | dl["status"] = "Downloading" 87 | sabsavequeue(CONFIG_DIR,sabqueue) 88 | for dlplugin in download_plugins: 89 | if dl["prefix"] in dlplugin.getprefix(): 90 | result = dlplugin.download(dl["url"],dl["title"],DOWNLOAD_DIR,dl["cat"]) 91 | if result == "404": 92 | dl["status"] = "Failed" 93 | sabsavequeue(CONFIG_DIR,sabqueue) 94 | else: 95 | dl["status"] = "Complete" 96 | dl["storage"] = result 97 | sabsavequeue(CONFIG_DIR,sabqueue) 98 | time.sleep(1) 99 | 100 | def read_config(config_file): 101 | try: 102 | with open(config_file, 'r') as file: 103 | config = json.load(file) # Load the JSON data from the file 104 | return config 105 | except FileNotFoundError: 106 | print(f"Error: The file '{config_file}' was not found.") 107 | return None 108 | except json.JSONDecodeError: 109 | print(f"Error: The file '{config_file}' contains invalid JSON.") 110 | return None 111 | 112 | def start(): 113 | config = read_config(os.path.join(CONFIG_DIR, "config.json")) 114 | global DOWNLOAD_DIR 115 | global SAB_API 116 | global SAB_CATEGORIES 117 | if config: 118 | DOWNLOAD_DIR = config.get("download_directory", DOWNLOAD_DIR) 119 | SAB_API = config.get("sab_api", SAB_API) 120 | SAB_CATEGORIES = config.get("sab_categories", SAB_CATEGORIES) 121 | #load search plugins 122 | global search_plugins 123 | global download_plugins 124 | global sabqueue 125 | print("Going to load search plugins") 126 | print("Going to load search plugins") 127 | search_plugins = load_search_plugins(PLUGIN_SEARCH_DIR) 128 | download_plugins = load_download_plugins(PLUGIN_DOWNLOAD_DIR) 129 | sabqueue = sabloadqueue(CONFIG_DIR) 130 | for dl in sabqueue: 131 | if dl["status"] == "Downloading": 132 | dl["status"] = "Queued" 133 | sabsavequeue(CONFIG_DIR,sabqueue) 134 | 135 | # when api with t=caps, collect all supported cats from all search plugins 136 | # and report them correctly 137 | def newznab_caps_response(): 138 | root = ET.Element("caps") 139 | 140 | server = ET.SubElement(root, "server") 141 | server.set("version", "0.1") 142 | 143 | for p in search_plugins: 144 | cat=p.getcat() 145 | for c in cat: 146 | categories = ET.SubElement(root, "categories") 147 | category = ET.SubElement(categories, "category") 148 | category.set("id", c) 149 | 150 | return ET.tostring(root, encoding="utf-8", method="xml") 151 | 152 | # flask routes and code 153 | 154 | @app.route("/") 155 | def home(): 156 | return """ 157 | 158 | 159 | Newznabarr 160 | 161 | 162 |

Newznabarr is running!

163 | View SAB Queue 164 |

Configure in *arr apps:

165 | 171 | 172 | 173 | """ 174 | 175 | @app.route("/queue") 176 | def queue(): 177 | queue_html = """ 178 | 179 | 180 | SAB Queue 181 | 182 | 183 |

SAB Queue

184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | """ 192 | # Loop through sabqueue and append rows 193 | for item in sabqueue: 194 | queue_html += f""" 195 | 196 | 197 | 198 | 199 | 200 | 201 | """ 202 | queue_html += """ 203 |
TitleCategoryPluginStatus
{item['title']}{item['cat']}{item['prefix']}{item['status']}
204 | 205 | 206 | """ 207 | return queue_html 208 | 209 | @app.route("/api", methods=["GET", "POST"]) 210 | def api(): 211 | global sabqueue 212 | # return all cats supported by all our search plugins 213 | if request.args.get("t") == "caps": 214 | xml_response = newznab_caps_response() 215 | return Response(xml_response, mimetype="application/xml") 216 | 217 | # readarr uses t=book to check if the indexer works 218 | # pretty sure this is for rss, so will have to implement rss later, fine for now 219 | elif request.args.get("t") == "book" : 220 | request_cats=(request.args.get("cat").split(",")) 221 | for plugin in search_plugins: 222 | for cat in plugin.getcat(): 223 | if cat in request_cats: 224 | query = plugin.gettestquery() 225 | results = plugin.search(query, cat) 226 | xml_response = searchresults_to_response(request.host_url, results) 227 | return Response(xml_response, mimetype="application/xml") 228 | return "No search provider found" 229 | 230 | # lidarr uses t=music 231 | # if there is artist and album provided, it's a search 232 | # else it's probably rss feed 233 | elif request.args.get("t") == "music" : 234 | if "artist" in request.args: 235 | request_cats=(request.args.get("cat").split(",")) 236 | for plugin in search_plugins: 237 | for cat in plugin.getcat(): 238 | if cat in request_cats: 239 | query = request.args.get("artist") + " - " + request.args.get("album") 240 | results = plugin.search(query, cat) 241 | xml_response = searchresults_to_response(request.host_url, results) 242 | return Response(xml_response, mimetype="application/xml") 243 | return "No search provider found" 244 | else: 245 | request_cats=(request.args.get("cat").split(",")) 246 | for plugin in search_plugins: 247 | for cat in plugin.getcat(): 248 | if cat in request_cats: 249 | query = plugin.gettestquery() 250 | results = plugin.search(query, cat) 251 | xml_response = searchresults_to_response(request.host_url, results) 252 | return Response(xml_response, mimetype="application/xml") 253 | return "No search provider found" 254 | 255 | # t=search is the normal search function 256 | elif request.args.get("t") == "search" : 257 | request_cats=(request.args.get("cat").split(",")) 258 | for plugin in search_plugins: 259 | for cat in plugin.getcat(): 260 | if cat in request_cats: 261 | query = request.args.get("q") 262 | results = plugin.search(query, cat) 263 | xml_response = searchresults_to_response(request.host_url, results) 264 | return Response(xml_response, mimetype="application/xml") 265 | return "No search provider found" 266 | 267 | 268 | # starr app downloads the nzb 269 | elif request.args.get("download") == "nzb": 270 | 271 | 272 | # Create the root element with the required namespace 273 | nzb = ET.Element('nzb', xmlns="http://www.newzbin.com/DTD/2003/nzb") 274 | 275 | # Add a section to store the hidden URL in plain text 276 | meta = ET.SubElement(nzb, 'meta') 277 | url_element = ET.SubElement(meta, 'prefix') 278 | url_element.text = request.args.get("prefix") 279 | url_element = ET.SubElement(meta, 'url') 280 | url_element.text = request.args.get("url") 281 | url_element = ET.SubElement(meta, 'size') 282 | url_element.text = request.args.get("size") 283 | url_element = ET.SubElement(meta, 'title') 284 | url_element.text = request.args.get("title") 285 | 286 | # Create a element with required attributes 287 | file_elem = ET.SubElement(nzb, 'file', poster="none", subject="none") 288 | 289 | # Add a section within the element 290 | groups = ET.SubElement(file_elem, 'groups') 291 | group = ET.SubElement(groups, 'group') 292 | 293 | # Add a section with provided segments information 294 | segments = ET.SubElement(file_elem, 'segments') 295 | segment = ET.SubElement(segments, 'segment', bytes=request.args.get("size"), number="1") 296 | 297 | # Convert the XML tree to a string 298 | return ET.tostring(nzb, encoding='utf-8', xml_declaration=True).decode() 299 | 300 | # sabnzbd functions 301 | elif request.args.get("mode") == "version": 302 | return sabversion() 303 | 304 | elif request.args.get("mode") == 'get_config': 305 | if SAB_API == request.args.get("apikey"): 306 | sabconfig = sabgetconfig(SAB_CATEGORIES) 307 | sabconfig["config"]["misc"]["complete_dir"] = DOWNLOAD_DIR 308 | sabconfig["config"]["misc"]["api_key"] = SAB_API 309 | return sabconfig 310 | return jsonify({"error": "Access Denied"}), 403 311 | 312 | elif request.args.get("mode") == "addfile": 313 | if SAB_API == request.args.get("apikey"): 314 | uploaded_file=request.files["name"] 315 | file_text = uploaded_file.read() 316 | root = ET.fromstring(file_text) 317 | namespace = {'nzb': 'http://www.newzbin.com/DTD/2003/nzb'} 318 | url_element = root.find('.//nzb:meta/nzb:url', namespace) 319 | url = url_element.text if url_element is not None else None 320 | prefix_element = root.find('.//nzb:meta/nzb:prefix', namespace) 321 | prefix = prefix_element.text if prefix_element is not None else None 322 | size_element = root.find('.//nzb:meta/nzb:size', namespace) 323 | size = size_element.text if size_element is not None else None 324 | title_element = root.find('.//nzb:meta/nzb:title', namespace) 325 | title = title_element.text if title_element is not None else None 326 | 327 | nzo=hashlib.md5(url.encode()).hexdigest() 328 | print(nzo) 329 | sabqueue.append({ 330 | "prefix": prefix, 331 | "size": size, 332 | "url": url, 333 | "nzo": nzo, 334 | "title": title, 335 | "status": "Queued", 336 | "cat": request.args.get("cat") 337 | }) 338 | result=json.loads("""{"status":true,"nzo_ids":["SABnzbd_nzo_cqz8nwn8"]}""") 339 | result["nzo_ids"]=[f"SABnzbd_nzo_{nzo}"] 340 | sabsavequeue(CONFIG_DIR,sabqueue) 341 | print(sabqueue) 342 | return(result), 200 343 | return jsonify({"error": "Access Denied"}), 403 344 | 345 | elif request.args.get("mode") == 'queue': 346 | if SAB_API == request.args.get("apikey"): 347 | if "name" in request.args: 348 | if request.args.get("name") == "delete": 349 | sabqueue = sabdeletefromqueue(CONFIG_DIR,sabqueue,request.args.get("value")) 350 | return "ok" 351 | else: 352 | return sabgetqueue(sabqueue) 353 | return jsonify({"error": "Access Denied"}), 403 354 | 355 | elif request.args.get("mode") == 'history': 356 | if SAB_API == request.args.get("apikey"): 357 | if "name" in request.args: 358 | if request.args.get("name") == "delete": 359 | sabqueue = sabdeletefromqueue(CONFIG_DIR,sabqueue,request.args.get("value")) 360 | return "ok" 361 | else: 362 | return sabgethistory(sabqueue) 363 | return jsonify({"error": "Access Denied"}), 403 364 | 365 | download_thread = threading.Thread(target=run_download_queue) 366 | download_thread.daemon = True # Ensures the thread stops when the program ends 367 | download_thread.start() 368 | 369 | if __name__ == "__main__": 370 | # load configs and plugins 371 | start() 372 | # start flask 373 | app.run(host=FLASK_HOST, port=FLASK_PORT) -------------------------------------------------------------------------------- /config/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "download_directory": "/data/downloads/downloadarr", 3 | "sab_api": "abcde", 4 | "sab_categories": ["readarr", "lidarr", "sonarr", "lidarr_audiobook"] 5 | } -------------------------------------------------------------------------------- /config/plugins/download/__pycache__/libgendl.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riffsphereha/newznabarr/6701b9ca82e1fb8b769550c8c59b3b4cdb2fb8de/config/plugins/download/__pycache__/libgendl.cpython-310.pyc -------------------------------------------------------------------------------- /config/plugins/download/__pycache__/ytmusicdl.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riffsphereha/newznabarr/6701b9ca82e1fb8b769550c8c59b3b4cdb2fb8de/config/plugins/download/__pycache__/ytmusicdl.cpython-310.pyc -------------------------------------------------------------------------------- /config/plugins/download/libgendl.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from bs4 import BeautifulSoup 3 | import os 4 | import re 5 | 6 | from plugin_download_interface import PluginDownloadBase 7 | 8 | class LibGenDownload(PluginDownloadBase): 9 | def getprefix(self): 10 | return ["libgen"] 11 | 12 | def download(self, url, title, download_dir, cat): 13 | full_download_dir = os.path.join(download_dir, cat, title) 14 | os.makedirs(full_download_dir, exist_ok=True) 15 | print(full_download_dir) 16 | full_filename = os.path.join(full_download_dir, title) 17 | 18 | response = requests.get(url, timeout=120) 19 | 20 | if response.status_code == 200: 21 | soup = BeautifulSoup(response.text, "html.parser") 22 | download_div = soup.find("div", id="download") 23 | 24 | if download_div: 25 | download_link = download_div.find("a") 26 | if download_link: 27 | link_url = download_link.get("href") 28 | else: 29 | return "404" 30 | else: 31 | elements_with_get = soup.find_all(string=lambda text: "GET" in text) 32 | 33 | for element_text in elements_with_get: 34 | parent_element = element_text.parent 35 | download_link = parent_element.find("a") if parent_element else None 36 | if download_link: 37 | link_url = download_link.get("href") 38 | break 39 | else: 40 | return "404" 41 | 42 | dl_resp = requests.get(link_url, stream=True) 43 | 44 | if dl_resp.status_code == 200: 45 | file_type = os.path.splitext(link_url)[1] 46 | valid_book_extensions = [".pdf", ".epub", ".mobi", ".azw", ".djvu", ".azw3"] 47 | if file_type not in valid_book_extensions: 48 | return "404" 49 | 50 | # Download file 51 | full_filename += file_type 52 | with open(full_filename, "wb") as f: 53 | for chunk in dl_resp.iter_content(chunk_size=1024): 54 | f.write(chunk) 55 | return full_filename 56 | else: 57 | return "404" 58 | else: 59 | return "404" -------------------------------------------------------------------------------- /config/plugins/download/ytmusicdl.py: -------------------------------------------------------------------------------- 1 | from ytmusicapi import YTMusic 2 | import os 3 | import yt_dlp 4 | import eyed3 5 | 6 | from plugin_download_interface import PluginDownloadBase 7 | 8 | def get_youtube_track_links(browse_id): 9 | ytmusic = YTMusic() 10 | trackcounter=0 11 | 12 | # Get the album tracks 13 | album_info = ytmusic.get_album(browse_id) 14 | 15 | if album_info and 'tracks' in album_info: 16 | tracks = album_info['tracks'] 17 | youtube_links = [] 18 | for track in tracks: 19 | trackcounter = trackcounter + 1 20 | title = track['title'] 21 | video_id = track['videoId'] 22 | youtube_link = f"https://www.youtube.com/watch?v={video_id}" 23 | youtube_links.append({'title': title, 'youtube_link': youtube_link, 'track': trackcounter}) 24 | 25 | return youtube_links 26 | else: 27 | print("Album tracks not found.") 28 | return None 29 | 30 | 31 | class YTMusicDownload(PluginDownloadBase): 32 | def getprefix(self): 33 | return ["ytmusic"] 34 | 35 | def download(self, url, title, download_dir, cat): 36 | full_download_dir = os.path.join(download_dir, cat, title) 37 | os.makedirs(full_download_dir, exist_ok=True) 38 | full_filename = full_download_dir 39 | 40 | ytmusic = YTMusic() 41 | trackcounter=0 42 | album_info = ytmusic.get_album(url) 43 | if album_info and 'tracks' in album_info: 44 | tracks = album_info['tracks'] 45 | youtube_links = [] 46 | for track in tracks: 47 | trackcounter = track['trackNumber'] 48 | title = track['title'] 49 | artist = track['artists'][0]["name"] 50 | album = track["album"] 51 | video_id = track['videoId'] 52 | youtube_link = f"https://www.youtube.com/watch?v={video_id}" 53 | youtube_links.append({'title': title, 'youtube_link': youtube_link, 'track': trackcounter, "artist": artist, "album":album}) 54 | for item in youtube_links: 55 | full_filename = os.path.join(full_download_dir , str(item["track"]) + " - " + item["title"]) 56 | try: 57 | ydl_opts = { 58 | 'quiet': True, 59 | 'noprogress': True, 60 | 'format': 'bestaudio/best', 61 | 'outtmpl': full_filename, 62 | 'postprocessors': [{ 63 | 'key': 'FFmpegExtractAudio', # Extract audio using FFmpeg 64 | 'preferredcodec': 'mp3', # Save as MP3 65 | 'preferredquality': '128', # Set quality 66 | }], 67 | } 68 | 69 | with yt_dlp.YoutubeDL(ydl_opts) as ydl: 70 | ydl.download(item["youtube_link"]) 71 | except Exception as e: 72 | print(f"Error: {str(e)}") 73 | return 404 74 | audio = eyed3.load(full_filename + ".mp3") 75 | audio.tag.artist=item["artist"] 76 | audio.tag.title=item["title"] 77 | audio.tag.album=item["album"] 78 | audio.tag.track_num=item["track"] 79 | 80 | audio.tag.save() 81 | return full_download_dir 82 | -------------------------------------------------------------------------------- /config/plugins/search/__pycache__/libgen.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riffsphereha/newznabarr/6701b9ca82e1fb8b769550c8c59b3b4cdb2fb8de/config/plugins/search/__pycache__/libgen.cpython-310.pyc -------------------------------------------------------------------------------- /config/plugins/search/__pycache__/ytmusic.cpython-310.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riffsphereha/newznabarr/6701b9ca82e1fb8b769550c8c59b3b4cdb2fb8de/config/plugins/search/__pycache__/ytmusic.cpython-310.pyc -------------------------------------------------------------------------------- /config/plugins/search/libgen.py: -------------------------------------------------------------------------------- 1 | import re 2 | from bs4 import BeautifulSoup 3 | import requests 4 | 5 | from plugin_search_interface import PluginSearchBase 6 | 7 | def getmyprefix(): 8 | return "libgen" 9 | 10 | def convert_size_to_bytes(size_str): 11 | size_str = size_str.lower() 12 | if 'kb' in size_str: 13 | size_in_bytes = float(size_str.replace('kb', '').strip()) * 1024 14 | elif 'mb' in size_str: 15 | size_in_bytes = float(size_str.replace('mb', '').strip()) * 1024 * 1024 16 | elif 'gb' in size_str: 17 | size_in_bytes = float(size_str.replace('gb', '').strip()) * 1024 * 1024 * 1024 18 | else: 19 | size_in_bytes = float(size_str) # Assume bytes if no unit is specified 20 | return str(int(size_in_bytes)) 21 | 22 | def convert_results(books, cat): 23 | results = [] 24 | for book in books: 25 | for link in book["links"]: 26 | results.append({ 27 | "link": link, 28 | "title": book["author"] + " - " + book["title"] + " (retail) (" + book["format_type"].lower() + ")", 29 | "description": f"{book['author']} - {book['title']} (retail) ({book['format_type'].lower()})", 30 | "guid": link, 31 | "comments": link, 32 | "files": "1", 33 | "size": book["size"], 34 | "category": cat, 35 | "grabs": "100", 36 | "prefix": getmyprefix() 37 | }) 38 | return results 39 | 40 | def search_libgen(book): 41 | results = [] 42 | try: 43 | print("Searching for " + book) 44 | item = book 45 | found_links = [] 46 | non_standard_chars_pattern = r"[^a-zA-Z0-9\s.]" 47 | item = item.replace("ø", "o") 48 | cleaned_string = re.sub(non_standard_chars_pattern, "", item) 49 | search_item = cleaned_string.replace(" ", "+") 50 | url = "http://libgen.is/fiction/?q=" + search_item 51 | response = requests.get(url, timeout=120) 52 | if response.status_code == 200: 53 | soup = BeautifulSoup(response.text, "html.parser") 54 | rows = soup.find_all('tr') 55 | for row in rows: 56 | try: 57 | # Extract author 58 | author = row.select_one('ul.catalog_authors li a').text.strip() 59 | 60 | # Extract title and link 61 | title_tag = row.select_one('td p a') 62 | title = title_tag.text.strip() 63 | title_link = title_tag['href'] 64 | 65 | # Extract ASIN 66 | asin = row.select_one('p.catalog_identifier').text.replace("ASIN: ", "").strip() 67 | 68 | # Extract language 69 | language = row.find_all('td')[3].text.strip() 70 | 71 | format_size = row.find_all('td')[4].text.strip() 72 | format_type = format_size.split(" / ")[0].strip() # e.g., "EPUB" 73 | size = convert_size_to_bytes(format_size.split(" / ")[1].strip()) # e.g., "374 Kb" 74 | 75 | # Extract mirror links 76 | links = [a['href'] for a in row.select('ul.record_mirrors_compact li a')] 77 | links.append("https://ligben.is" + title_link) 78 | 79 | # Store result in a dictionary 80 | results.append({ 81 | 'author': reverse_author_name(author), 82 | 'title': title, 83 | 'asin': asin, 84 | 'language': language, 85 | 'links': links, 86 | 'format_type': format_type, 87 | 'size': size 88 | }) 89 | 90 | except AttributeError: 91 | # Skip rows that don't match the expected structure 92 | continue 93 | 94 | else: 95 | ret = {"Status": "Error", "Code": "Libgen Connection Error"} 96 | print("Libgen Connection Error: " + str(response.status_code) + "Data: " + response.text) 97 | 98 | except Exception as e: 99 | print(str(e)) 100 | raise Exception("Error Searching libgen: " + str(e)) 101 | 102 | finally: 103 | return results 104 | 105 | 106 | def reverse_author_name(name): 107 | # Split the name by the comma (if it's in the "Last, First" format) 108 | if ',' in name: 109 | last_name, first_names = name.split(',', 1) 110 | last_name = last_name.strip().capitalize() # Capitalize the last name 111 | first_names = first_names.strip() 112 | formatted_first_names = " ".join([first.capitalize() for first in first_names.split()]) 113 | return f"{formatted_first_names} {last_name}" 114 | else: 115 | # If no comma, it's just a single word name (e.g., "King") 116 | return name.capitalize() 117 | 118 | class LibGenSearch(PluginSearchBase): 119 | def getcat(self): 120 | return ["7020"] 121 | 122 | def gettestquery(self): 123 | return "sample" 124 | 125 | def getprefix(self): 126 | return getmyprefix() 127 | 128 | def search(self, query, cat): 129 | books = search_libgen(query) 130 | results = convert_results(books, cat) 131 | return results 132 | 133 | 134 | 135 | -------------------------------------------------------------------------------- /config/plugins/search/ytmusic.py: -------------------------------------------------------------------------------- 1 | from ytmusicapi import YTMusic 2 | 3 | from plugin_search_interface import PluginSearchBase 4 | 5 | class YTMusicSearch(PluginSearchBase): 6 | def getcat(self): 7 | return ["3010"] 8 | 9 | def gettestquery(self): 10 | return "joe sample - sample this" 11 | 12 | def getprefix(self): 13 | return "ytmusic" 14 | 15 | def search(self, query, cat): 16 | result = [] 17 | ytmusic = YTMusic() 18 | try: 19 | # Search for the album 20 | search_results = ytmusic.search(query, filter='albums') 21 | for item in search_results: 22 | artists = "" 23 | for artist in item['artists']: 24 | artists = artists + artist['name'] + " " 25 | albumtitle = item['title'] 26 | year = item['year'] 27 | link = item['browseId'] 28 | title = artists + "- " + albumtitle + " (" + year +") (mp3) (128kbps)" 29 | GoodResult = True 30 | for word in query.split(" "): 31 | if not (word.lower() in title.lower()): 32 | GoodResult = False 33 | if GoodResult: 34 | result.append({ 35 | "link": link, 36 | "title": title, 37 | "description": title, 38 | "guid": link, 39 | "comments": link, 40 | "files": "1", 41 | "size": "10000", 42 | "category": cat, 43 | "grabs": "100", 44 | "prefix": self.getprefix() 45 | }) 46 | 47 | except Exception as e: 48 | print(f"An error occurred: {e}") 49 | result = [] 50 | if len(result) == 0: 51 | result.append({ 52 | "link": "", 53 | "title": "", 54 | "description": "", 55 | "guid": "", 56 | "comments": "", 57 | "files": "0", 58 | "size": "0", 59 | "category": cat, 60 | "grabs": "100" 61 | }) 62 | return result -------------------------------------------------------------------------------- /config/sabqueue.conf: -------------------------------------------------------------------------------- 1 | [] -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | newznabarr: 5 | image: riffsphereha/newznabarr:latest 6 | container_name: newznabarr 7 | environment: 8 | - FLASK_RUN_PORT=10000 9 | - PUID=1000 10 | - PGID=1000 11 | - CONFIG=/config 12 | ports: 13 | - "10000:10000" 14 | volumes: 15 | - /path/to/config:/config 16 | - /path/to/download:/data/downloads/downloadarr 17 | restart: unless-stopped -------------------------------------------------------------------------------- /dockerfile: -------------------------------------------------------------------------------- 1 | # Use an official Python runtime as a parent image 2 | FROM python:3.9-slim 3 | 4 | # Set the working directory in the container 5 | WORKDIR /app 6 | 7 | # Set environment variables 8 | ENV CONFIG=/config 9 | ENV FLASK_RUN_PORT=10000 10 | ENV FLASK_RUN_HOST=0.0.0.0 11 | ENV FLASK_APP=app.py 12 | ENV PUID=1000 13 | ENV PGID=1000 14 | 15 | # Install gosu to run commands as the specified user 16 | RUN apt-get update && \ 17 | apt-get install -y gosu ffmpeg && \ 18 | rm -rf /var/lib/apt/lists/* 19 | 20 | # Copy the entire app directory into the container 21 | COPY . /app 22 | 23 | # Copy the default configuration files to a temporary location 24 | COPY config/ /default_config/ 25 | 26 | # Install dependencies from requirements.txt 27 | RUN pip install --no-cache-dir -r requirements.txt 28 | 29 | # Expose the Flask app's port 30 | EXPOSE 10000 31 | 32 | # Add the entrypoint script to check and copy config files 33 | COPY entrypoint.sh /entrypoint.sh 34 | RUN chmod +x /entrypoint.sh 35 | 36 | # Set the entrypoint to run the startup script 37 | ENTRYPOINT ["/entrypoint.sh"] 38 | 39 | # The command to run the Flask app when the container starts 40 | CMD ["python", "app.py"] -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Ensure the $CONFIG directory exists 4 | mkdir -p "$CONFIG" 5 | 6 | # Set file ownership using PUID and PGID environment variables 7 | echo "Setting file ownership to PUID=${PUID} and PGID=${PGID}" 8 | 9 | # Set the ownership of the directories and files to the specified PUID and PGID 10 | chown -R ${PUID}:${PGID} /app /default_config "$CONFIG" 11 | 12 | # If the $CONFIG directory is empty, copy the default configuration files 13 | if [ -z "$(ls -A "$CONFIG")" ]; then 14 | echo "Copying default configuration files to $CONFIG..." 15 | cp -r /default_config/* "$CONFIG" 16 | chown -R ${PUID}:${PGID} "$CONFIG" 17 | fi 18 | 19 | # Copy default plugins to the config directory, overwriting existing plugins 20 | cp -r /default_config/plugins/* "$CONFIG/plugins/" 21 | 22 | # Execute the command passed to the container as a non-root user (no user creation, just chown) 23 | exec gosu ${PUID}:${PGID} "$@" -------------------------------------------------------------------------------- /images/newznab1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riffsphereha/newznabarr/6701b9ca82e1fb8b769550c8c59b3b4cdb2fb8de/images/newznab1.png -------------------------------------------------------------------------------- /images/newznab2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riffsphereha/newznabarr/6701b9ca82e1fb8b769550c8c59b3b4cdb2fb8de/images/newznab2.png -------------------------------------------------------------------------------- /images/sabnzbd1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riffsphereha/newznabarr/6701b9ca82e1fb8b769550c8c59b3b4cdb2fb8de/images/sabnzbd1.png -------------------------------------------------------------------------------- /images/sabnzbd2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riffsphereha/newznabarr/6701b9ca82e1fb8b769550c8c59b3b4cdb2fb8de/images/sabnzbd2.png -------------------------------------------------------------------------------- /newznab.py: -------------------------------------------------------------------------------- 1 | import email.utils 2 | import time 3 | import xml.etree.ElementTree as ET 4 | 5 | def searchresults_to_response(server, results): 6 | root = ET.Element("rss", version="2.0", attrib={ 7 | "xmlns:atom": "http://www.w3.org/2005/Atom", 8 | "xmlns:newznab": "http://www.newznab.com/DTD/2010/feeds/attributes/" 9 | }) 10 | channel = ET.SubElement(root, "channel") 11 | 12 | ET.SubElement(channel, "title").text = "NewzNabArr" 13 | ET.SubElement(channel, "description").text = "Multiple newznab proxies for starr apps" 14 | ET.SubElement(channel, "link").text = server 15 | 16 | pub_date = email.utils.formatdate(time.time()) 17 | ET.SubElement(channel, "pubDate").text = pub_date 18 | 19 | for result in results: 20 | prefix = result["prefix"] 21 | link2 = f"{server}api?download=nzb&prefix={prefix}&url={result['link']}&size={result['size']}&title={result['title']}" 22 | item = ET.SubElement(channel, "item") 23 | ET.SubElement(item, "title").text = result["title"] 24 | ET.SubElement(item, "description").text = result["description"] 25 | ET.SubElement(item, "guid").text = result["guid"] 26 | ET.SubElement(item, "comments").text = result["comments"] 27 | item_pub_date = email.utils.formatdate(time.time()) 28 | ET.SubElement(item, "pubDate").text = item_pub_date 29 | ET.SubElement(item, "size").text = result["size"] 30 | ET.SubElement(item, "link").text = link2 31 | ET.SubElement(item, "category").text = result["category"] 32 | enclosure = ET.SubElement(item, "enclosure") 33 | enclosure.set("url", link2) 34 | enclosure.set("length", result["size"]) # Replace with actual file size if available 35 | enclosure.set("type", "application/x-nzb") # or "application/epub" based on book format 36 | 37 | newznab_attrs = ET.SubElement(item, "newznab:attr") 38 | newznab_attrs.set("name", "category") 39 | newznab_attrs.set("value", result["category"]) 40 | newznab_attrs = ET.SubElement(item, "newznab:attr") 41 | newznab_attrs.set("name", "files") 42 | newznab_attrs.set("value", "1") 43 | newznab_attrs = ET.SubElement(item, "newznab:attr") 44 | newznab_attrs.set("name", "grabs") 45 | newznab_attrs.set("value", "100") 46 | xml_str = ET.tostring(root, encoding="utf-8", method="xml") 47 | return xml_str 48 | -------------------------------------------------------------------------------- /plugin_download_interface.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | class PluginDownloadBase(ABC): 4 | 5 | @abstractmethod 6 | def getprefix(self): 7 | """The prefix for the the downloader supports""" 8 | pass 9 | 10 | @abstractmethod 11 | def download(self, url, title, download_dir, cat): 12 | """The download function""" 13 | pass -------------------------------------------------------------------------------- /plugin_search_interface.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | class PluginSearchBase(ABC): 4 | @abstractmethod 5 | def getcat(self): 6 | """Return all cats supported by plugin.""" 7 | pass 8 | 9 | @abstractmethod 10 | def gettestquery(self): 11 | """Return a test query supported by the plugin to do a test search""" 12 | pass 13 | 14 | @abstractmethod 15 | def getprefix(self): 16 | """The prefix for the search link, to identify a compatible downloader""" 17 | pass 18 | 19 | @abstractmethod 20 | def search(self, query, cat): 21 | """Perform a search""" 22 | # Must include: 23 | # link: some info on where to download from 24 | # title: used by starr apps to compare the result 25 | # description: can be anything 26 | # guid: unique identifier, not really used but there in case 27 | # comments: anything 28 | # size: file size 29 | # files: number of files 30 | # category: the exact category 31 | # grabs: how many downloads there are 32 | pass -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Newznabarr - Usenet Plugin Framework for *Arr Apps 2 | 3 | After using the *arr apps for years, I realized that while Usenet and torrents are great, they aren't always the best or only sources for content. Unfortunately, the *arr apps currently only support these two options. That’s why I created **Newznabarr** — a Usenet plugin framework for the *arr ecosystem designed to fill that gap. 4 | 5 | ## What is Newznabarr? 6 | 7 | **Newznabarr** presents itself as a Newznab indexer and SABnzbd client, making it compatible with the *arr apps you’re already using. However, the magic lies under the hood: all searches are handled by plugins, allowing for maximum flexibility and expandability. This means you can use Newznabarr to tap into other content sources beyond traditional Usenet and torrents. 8 | 9 | ### Current Features: 10 | - Plugin-based search and download functionality for easy expandability. 11 | - A plugin to integrate **Readarr** with a popular book site. 12 | - A plugin to integrate **Lidarr** with **YouTube Music** (128kbps mp3) 🎶 13 | - Designed to fit seamlessly into your existing *arr workflow. 14 | 15 | ### Roadmap: 16 | - RSS feed integration for the book site in **Readarr** 17 | - **Music Streaming Sites Integration** 🎧 18 | - **Video Streaming Sites Integration** 📺 19 | 20 | ### Contribute and Extend: 21 | - **Make Your Own Plugins**: One of the core ideas behind Newznabarr is expandability. You can create and add your own plugins to enhance functionality or integrate with other content sources. If you have an idea for a plugin, feel free to fork the repo and start building! 22 | - **Name Suggestion**: If you think there’s a better name for this project, feel free to suggest one! We’re open to ideas. **Expandarr** is a good option for now! (thanks u/waterloonies) 23 | - **Icon Design**: If you're a designer or just have a creative idea, help us out with a unique icon for Newznabarr! 24 | - I'm a bad programmer. I got this working, but if you think you can make it better in any way, please do! 25 | 26 | ### How to Get Started: 27 | - **Docker Hub**: `riffsphereha/newznabarr` 28 | - **GitHub**: `riffsphereha/newznabarr` 29 | 30 | ⚠️ **Note**: Newznabarr is in a very early alpha stage, so expect some bugs and rough edges. Feedback, suggestions, and contributions are welcome! 31 | 32 | Let me know what you think, and if you have any ideas for additional plugins, a new name, or an icon, I’d love to hear them! 🌟 33 | 34 | --- 35 | 36 | ## Installation 37 | 38 | ### Install using Docker 39 | 40 | - **Default Port**: `10000`. You can change this using the `FLASK_RUN_PORT` environment variable. 41 | - **Configuration Directory**: Mount `/config` to your appdata folder. This will contain: 42 | - `config.json` files 43 | - SAB queue 44 | - Plugins 45 | - **User and Group IDs**: 46 | - Use `PUID` and `PGID` to set the user and group IDs for permissions. 47 | - For Unraid, set `PUID` to `99` and `PGID` to `100`. 48 | - **Download Folder**: 49 | - Add a download folder that matches your setup and update the `config.json` file to reflect this folder. 50 | - **Note**: The default path is still using the old name `/data/downloads/downloadarr` (this will be updated in future versions). 51 | 52 | --- 53 | 54 | ## How to Use 55 | 56 | ### 1. Add a SABnzbd Client to Your *Arr App 57 | 58 | - Configure as you normally would with the following settings: 59 | - **Name** 60 | - **Enable** 61 | - **Host** 62 | - **Port** 63 | - **API Key** 64 | 65 | - **Notes**: 66 | - Currently, plugins are available only for **Readarr** and **Lidarr**, so other *Arr apps are untested. 67 | - The API key can be set in the `config.json` file. 68 | - Enable advanced settings and set the **Client Priority** to `50`. Since this doesn't support real NZBs, it should only be used for supported plugins. 69 | - **Important**: Lower the SABnzbd client priority (set to a lower value than other clients) to ensure it doesn't interfere with real NZB downloads. 70 | 71 | ### 2. Add a Newznab Indexer 72 | 73 | - Configure as you normally would with the following settings: 74 | - **Name** 75 | - **Enable Search** (RSS support is not yet available) 76 | - **URL** (use the `http://:` format). 77 | 78 | - **Notes**: 79 | - No API key is required. 80 | - Enable advanced settings and set the **Download Client** to the SABnzbd client you just added. 81 | 82 | --- 83 | 84 | ## Additional Information 85 | 86 | - For more advanced configurations, or if you need to modify the default behavior, check the `config.json` file. 87 | - Stay tuned for upcoming features, including more plugin support and enhancements! -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | beautifulsoup4==4.12.3 2 | eyed3==0.9.7 3 | Flask==3.1.0 4 | Requests==2.32.3 5 | yt_dlp==2024.11.18 6 | ytmusicapi==1.8.2 7 | -------------------------------------------------------------------------------- /sabnzbd.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | def sabversion(): 5 | return json.loads("""{"version":"4.3.1"}""") 6 | 7 | def sabgetconfig(sab_categories): 8 | config= """ 9 | { 10 | "config": { 11 | "misc": { 12 | "helpful_warnings": 1, 13 | "queue_complete": "", 14 | "queue_complete_pers": 0, 15 | "bandwidth_perc": 100, 16 | "refresh_rate": 1, 17 | "interface_settings": "", 18 | "queue_limit": 20, 19 | "config_lock": 0, 20 | "notified_new_skin": 0, 21 | "check_new_rel": 1, 22 | "auto_browser": 1, 23 | "language": "en", 24 | "enable_https_verification": 1, 25 | "host": "::", 26 | "port": 10000, 27 | "https_port": "", 28 | "username": "", 29 | "password": "", 30 | "bandwidth_max": "", 31 | "cache_limit": "1G", 32 | "web_dir": "Glitter", 33 | "web_color": "Auto", 34 | "https_cert": "server.cert", 35 | "https_key": "server.key", 36 | "https_chain": "", 37 | "enable_https": 0, 38 | "inet_exposure": 0, 39 | "api_key": "(removed)", 40 | "nzb_key": "(removed)", 41 | "socks5_proxy_url": "", 42 | "permissions": "", 43 | "download_dir": "Downloads/incomplete", 44 | "download_free": "", 45 | "complete_dir": "Downloads/complete", 46 | "complete_free": "", 47 | "fulldisk_autoresume": 0, 48 | "script_dir": "", 49 | "nzb_backup_dir": "", 50 | "admin_dir": "admin", 51 | "backup_dir": "", 52 | "dirscan_dir": "", 53 | "dirscan_speed": 5, 54 | "password_file": "", 55 | "log_dir": "logs", 56 | "max_art_tries": 3, 57 | "top_only": 0, 58 | "sfv_check": 1, 59 | "script_can_fail": 0, 60 | "enable_recursive": 1, 61 | "flat_unpack": 0, 62 | "par_option": "", 63 | "pre_check": 0, 64 | "nice": "", 65 | "win_process_prio": 3, 66 | "ionice": "", 67 | "fail_hopeless_jobs": 1, 68 | "fast_fail": 1, 69 | "auto_disconnect": 1, 70 | "pre_script": "None", 71 | "end_queue_script": "None", 72 | "no_dupes": 0, 73 | "no_series_dupes": 0, 74 | "no_smart_dupes": 0, 75 | "dupes_propercheck": 1, 76 | "pause_on_pwrar": 1, 77 | "ignore_samples": 0, 78 | "deobfuscate_final_filenames": 1, 79 | "auto_sort": "", 80 | "direct_unpack": 0, 81 | "propagation_delay": 0, 82 | "folder_rename": 1, 83 | "replace_spaces": 0, 84 | "replace_underscores": 0, 85 | "replace_dots": 0, 86 | "safe_postproc": 1, 87 | "pause_on_post_processing": 0, 88 | "enable_all_par": 0, 89 | "sanitize_safe": 0, 90 | "cleanup_list": [], 91 | "unwanted_extensions": [], 92 | "action_on_unwanted_extensions": 0, 93 | "unwanted_extensions_mode": 0, 94 | "new_nzb_on_failure": 0, 95 | "history_retention": "", 96 | "history_retention_option": "all", 97 | "history_retention_number": 0, 98 | "quota_size": "", 99 | "quota_day": "", 100 | "quota_resume": 0, 101 | "quota_period": "m", 102 | "schedlines": [], 103 | "rss_rate": 60, 104 | "ampm": 0, 105 | "start_paused": 0, 106 | "preserve_paused_state": 0, 107 | "enable_par_cleanup": 1, 108 | "process_unpacked_par2": 1, 109 | "enable_multipar": 1, 110 | "enable_unrar": 1, 111 | "enable_7zip": 1, 112 | "enable_filejoin": 1, 113 | "enable_tsjoin": 1, 114 | "overwrite_files": 0, 115 | "ignore_unrar_dates": 0, 116 | "backup_for_duplicates": 0, 117 | "empty_postproc": 0, 118 | "wait_for_dfolder": 0, 119 | "rss_filenames": 0, 120 | "api_logging": 1, 121 | "html_login": 1, 122 | "warn_dupl_jobs": 0, 123 | "keep_awake": 1, 124 | "tray_icon": 1, 125 | "allow_incomplete_nzb": 0, 126 | "enable_broadcast": 1, 127 | "ipv6_hosting": 0, 128 | "ipv6_staging": 0, 129 | "api_warnings": 1, 130 | "no_penalties": 0, 131 | "x_frame_options": 1, 132 | "allow_old_ssl_tls": 0, 133 | "enable_season_sorting": 1, 134 | "verify_xff_header": 0, 135 | "rss_odd_titles": ["nzbindex.nl/", "nzbindex.com/", "nzbclub.com/"], 136 | "quick_check_ext_ignore": ["nfo", "sfv", "srr"], 137 | "req_completion_rate": 100.2, 138 | "selftest_host": "self-test.sabnzbd.org", 139 | "movie_rename_limit": "100M", 140 | "episode_rename_limit": "20M", 141 | "size_limit": 0, 142 | "direct_unpack_threads": 3, 143 | "history_limit": 10, 144 | "wait_ext_drive": 5, 145 | "max_foldername_length": 246, 146 | "nomedia_marker": "", 147 | "ipv6_servers": 1, 148 | "url_base": "/sabnzbd", 149 | "host_whitelist": ["fab0220616e4"], 150 | "local_ranges": [], 151 | "max_url_retries": 10, 152 | "downloader_sleep_time": 10, 153 | "receive_threads": 2, 154 | "switchinterval": 0.005, 155 | "ssdp_broadcast_interval": 15, 156 | "ext_rename_ignore": [], 157 | "email_server": "", 158 | "email_to": [], 159 | "email_from": "", 160 | "email_account": "", 161 | "email_pwd": "", 162 | "email_endjob": 0, 163 | "email_full": 0, 164 | "email_dir": "", 165 | "email_rss": 0, 166 | "email_cats": ["*"] 167 | }, 168 | "logging": { 169 | "log_level": 1, 170 | "max_log_size": 5242880, 171 | "log_backups": 5 172 | }, 173 | "categories": [ 174 | {"name": "readarr", "order": 1, "pp": "", "script": "Default", "dir": "", "newzbin": "", "priority": -100} 175 | ] 176 | } 177 | }""" 178 | result = json.loads(config) 179 | order=0 180 | for category in sab_categories: 181 | order = order + 1 182 | result["config"]["categories"].append({ 183 | "name": category, 184 | "order": order, 185 | "pp": "", 186 | "script": "Default", 187 | "dir": "", 188 | "newzbin": "", 189 | "priority": -100 190 | }) 191 | return result 192 | 193 | def sabgetqueue(downloads): 194 | slots = [] 195 | index=0 196 | for download in downloads: 197 | if download["status"] == "Queued": 198 | slots.append({ 199 | "index": index, 200 | "nzo_id": f"SABnzbd_nzo_{download['nzo']}", 201 | "status": "Queued", 202 | "filename": download['title'], 203 | "cat": download['cat'] 204 | }) 205 | index = index + 1 206 | if download["status"] == "Downloading": 207 | slots.append({ 208 | "index": index, 209 | "nzo_id": f"SABnzbd_nzo_{download['nzo']}", 210 | "status": "Downloading", 211 | "filename": download['title'], 212 | "cat": download['cat'] 213 | }) 214 | index = index + 1 215 | if download["status"] == "Failed": 216 | slots.append({ 217 | "index": index, 218 | "nzo_id": f"SABnzbd_nzo_{download['nzo']}", 219 | "status": "Failed", 220 | "filename": download['title'], 221 | "cat": download['cat'] 222 | }) 223 | index = index + 1 224 | queue="""{"queue":{"version":"4.3.1","paused":false,"pause_int":"0","paused_all":false,"slots":""" 225 | queue=queue+str(slots)+"}}" 226 | return queue 227 | 228 | def sabgethistory(downloads): 229 | slots = [] 230 | index=0 231 | for download in downloads: 232 | if download["status"] == "Complete": 233 | slots.append({ 234 | "completed": download['size'], 235 | "name": download["title"], 236 | "category": download["cat"], 237 | "status": "Completed", 238 | "nzo_id": f"SABnzbd_nzo_{download['nzo']}", 239 | "storage": download["storage"], 240 | "path": download["storage"] 241 | }) 242 | index = index + 1 243 | 244 | history="""{"history":{"slots":""" + str(slots) + ""","version":"4.3.1"}}""" 245 | return history 246 | 247 | def sabsavequeue(config_dir, downloadqueue): 248 | print("ping") 249 | print("saved queue: " + str(len(downloadqueue))) 250 | with open(os.path.join(config_dir, "sabqueue.conf"),"w") as file: 251 | json.dump(downloadqueue, file, indent=4) 252 | 253 | def sabloadqueue(config_dir): 254 | if os.path.exists(os.path.join(config_dir, "sabqueue.conf")): 255 | with open(os.path.join(config_dir, "sabqueue.conf"),"r") as file: 256 | downloadqueue = json.load(file) 257 | else: 258 | downloadqueue = [] 259 | return downloadqueue 260 | 261 | def sabdeletefromqueue(config_dir, downloadqueue, item): 262 | for download in downloadqueue: 263 | if item == f"SABnzbd_nzo_{download['nzo']}": 264 | downloadqueue.remove(download) 265 | 266 | with open(os.path.join(config_dir, "sabqueue.conf"),"w") as file: 267 | json.dump(downloadqueue, file, indent=4) 268 | return downloadqueue 269 | -------------------------------------------------------------------------------- /test.bash: -------------------------------------------------------------------------------- 1 | export CONFIG=/code/newznabarr/config 2 | 3 | git status 4 | git add . 5 | git commit -m "Comment" 6 | git push 7 | 8 | 9 | pip install pipreqs 10 | pipreqs . --force --------------------------------------------------------------------------------