| | |
| | from uvloop import install |
| |
|
| | install() |
| |
|
| | from asyncio import sleep |
| | from urllib.parse import urlparse |
| | from contextlib import asynccontextmanager |
| | from logging import INFO, WARNING, FileHandler, StreamHandler, basicConfig, getLogger |
| |
|
| | from aioaria2 import Aria2HttpClient |
| | from aiohttp.client_exceptions import ClientError |
| | from aioqbt.client import create_client |
| | from fastapi import FastAPI, Request, HTTPException |
| | from fastapi.responses import HTMLResponse, JSONResponse |
| | from fastapi.templating import Jinja2Templates |
| | from sabnzbdapi import SabnzbdClient |
| | from aioaria2 import Aria2HttpClient |
| | from aioqbt.client import create_client |
| | from aiohttp.client_exceptions import ClientError |
| | from aioqbt.exc import AQError |
| |
|
| | from web.nodes import extract_file_ids, make_tree |
| | from aiohttp import ClientSession |
| |
|
| | getLogger("httpx").setLevel(WARNING) |
| | getLogger("aiohttp").setLevel(WARNING) |
| |
|
| | aria2 = None |
| | qbittorrent = None |
| | sabnzbd_client = SabnzbdClient( |
| | host="http://localhost", |
| | api_key="admin", |
| | port="8070", |
| | ) |
| | SERVICES = { |
| | "nzb": {"url": "http://localhost:8070/"}, |
| | "qbit": {"url": "http://localhost:8090", "password": "wzmlx"}, |
| | } |
| |
|
| |
|
| | @asynccontextmanager |
| | async def lifespan(app: FastAPI): |
| | global aria2, qbittorrent |
| | aria2 = Aria2HttpClient("http://localhost:6800/jsonrpc") |
| | qbittorrent = await create_client("http://localhost:8090/api/v2/") |
| | yield |
| | await aria2.close() |
| | await qbittorrent.close() |
| |
|
| |
|
| | app = FastAPI(lifespan=lifespan) |
| |
|
| |
|
| | templates = Jinja2Templates(directory="web/templates/") |
| |
|
| | basicConfig( |
| | format="[%(asctime)s] [%(levelname)s] - %(message)s", |
| | datefmt="%d-%b-%y %I:%M:%S %p", |
| | handlers=[FileHandler("log.txt"), StreamHandler()], |
| | level=INFO, |
| | ) |
| |
|
| | LOGGER = getLogger(__name__) |
| |
|
| |
|
| | async def re_verify(paused, resumed, hash_id): |
| | k = 0 |
| | while True: |
| | res = await qbittorrent.torrents.files(hash_id) |
| | verify = True |
| | for i in res: |
| | if i.index in paused and i.priority != 0: |
| | verify = False |
| | break |
| | if i.index in resumed and i.priority == 0: |
| | verify = False |
| | break |
| | if verify: |
| | break |
| | LOGGER.info("Reverification Failed! Correcting stuff...") |
| | await sleep(0.5) |
| | if paused: |
| | try: |
| | await qbittorrent.torrents.file_prio( |
| | hash=hash_id, id=paused, priority=0 |
| | ) |
| | except (ClientError, TimeoutError, Exception, AQError) as e: |
| | LOGGER.error(f"{e} Errored in reverification paused!") |
| | if resumed: |
| | try: |
| | await qbittorrent.torrents.file_prio( |
| | hash=hash_id, id=resumed, priority=1 |
| | ) |
| | except (ClientError, TimeoutError, Exception, AQError) as e: |
| | LOGGER.error(f"{e} Errored in reverification resumed!") |
| | k += 1 |
| | if k > 5: |
| | return False |
| | LOGGER.info(f"Verified! Hash: {hash_id}") |
| | return True |
| |
|
| |
|
| | @app.get("/app/files", response_class=HTMLResponse) |
| | async def files(request: Request): |
| | return templates.TemplateResponse("page.html", {"request": request}) |
| |
|
| |
|
| | @app.api_route( |
| | "/app/files/torrent", methods=["GET", "POST"], response_class=HTMLResponse |
| | ) |
| | async def handle_torrent(request: Request): |
| | params = request.query_params |
| |
|
| | if not (gid := params.get("gid")): |
| | return JSONResponse( |
| | { |
| | "files": [], |
| | "engine": "", |
| | "error": "GID is missing", |
| | "message": "GID not specified", |
| | } |
| | ) |
| |
|
| | if not (pin := params.get("pin")): |
| | return JSONResponse( |
| | { |
| | "files": [], |
| | "engine": "", |
| | "error": "Pin is missing", |
| | "message": "PIN not specified", |
| | } |
| | ) |
| |
|
| | code = "".join([nbr for nbr in gid if nbr.isdigit()][:4]) |
| | if code != pin: |
| | return JSONResponse( |
| | { |
| | "files": [], |
| | "engine": "", |
| | "error": "Invalid pin", |
| | "message": "The PIN you entered is incorrect", |
| | } |
| | ) |
| |
|
| | if request.method == "POST": |
| | if not (mode := params.get("mode")): |
| | return JSONResponse( |
| | { |
| | "files": [], |
| | "engine": "", |
| | "error": "Mode is not specified", |
| | "message": "Mode is not specified", |
| | } |
| | ) |
| | data = await request.json() |
| | if mode == "rename": |
| | if len(gid) > 20: |
| | await handle_rename(gid, data) |
| | content = { |
| | "files": [], |
| | "engine": "", |
| | "error": "", |
| | "message": "Rename successfully.", |
| | } |
| | else: |
| | content = { |
| | "files": [], |
| | "engine": "", |
| | "error": "Rename failed.", |
| | "message": "Cannot rename aria2c torrent file", |
| | } |
| | else: |
| | selected_files, unselected_files = extract_file_ids(data) |
| | if gid.startswith("SABnzbd_nzo"): |
| | await set_sabnzbd(gid, unselected_files) |
| | elif len(gid) > 20: |
| | await set_qbittorrent(gid, selected_files, unselected_files) |
| | else: |
| | selected_files = ",".join(selected_files) |
| | await set_aria2(gid, selected_files) |
| | content = { |
| | "files": [], |
| | "engine": "", |
| | "error": "", |
| | "message": "Your selection has been submitted successfully.", |
| | } |
| | else: |
| | try: |
| | if gid.startswith("SABnzbd_nzo"): |
| | res = await sabnzbd_client.get_files(gid) |
| | content = make_tree(res, "sabnzbd") |
| | elif len(gid) > 20: |
| | res = await qbittorrent.torrents.files(gid) |
| | content = make_tree(res, "qbittorrent") |
| | else: |
| | res = await aria2.getFiles(gid) |
| | op = await aria2.getOption(gid) |
| | fpath = f"{op['dir']}/" |
| | content = make_tree(res, "aria2", fpath) |
| | except (ClientError, TimeoutError, Exception, AQError) as e: |
| | LOGGER.error(str(e)) |
| | content = { |
| | "files": [], |
| | "engine": "", |
| | "error": "Error getting files", |
| | "message": str(e), |
| | } |
| | return JSONResponse(content) |
| |
|
| |
|
| | async def handle_rename(gid, data): |
| | try: |
| | _type = data["type"] |
| | del data["type"] |
| | if _type == "file": |
| | await qbittorrent.torrents.rename_file(hash=gid, **data) |
| | else: |
| | await qbittorrent.torrents.rename_folder(hash=gid, **data) |
| | except (ClientError, TimeoutError, Exception, AQError) as e: |
| | LOGGER.error(f"{e} Errored in renaming") |
| |
|
| |
|
| | async def set_sabnzbd(gid, unselected_files): |
| | await sabnzbd_client.remove_file(gid, unselected_files) |
| | LOGGER.info(f"Verified! nzo_id: {gid}") |
| |
|
| |
|
| | async def set_qbittorrent(gid, selected_files, unselected_files): |
| | if unselected_files: |
| | try: |
| | await qbittorrent.torrents.file_prio( |
| | hash=gid, id=unselected_files, priority=0 |
| | ) |
| | except (ClientError, TimeoutError, Exception, AQError) as e: |
| | LOGGER.error(f"{e} Errored in paused") |
| | if selected_files: |
| | try: |
| | await qbittorrent.torrents.file_prio( |
| | hash=gid, id=selected_files, priority=1 |
| | ) |
| | except (ClientError, TimeoutError, Exception, AQError) as e: |
| | LOGGER.error(f"{e} Errored in resumed") |
| | await sleep(0.5) |
| | if not await re_verify(unselected_files, selected_files, gid): |
| | LOGGER.error(f"Verification Failed! Hash: {gid}") |
| |
|
| |
|
| | async def set_aria2(gid, selected_files): |
| | res = await aria2.changeOption(gid, {"select-file": selected_files}) |
| | if res == "OK": |
| | LOGGER.info(f"Verified! Gid: {gid}") |
| | else: |
| | LOGGER.info(f"Verification Failed! Report! Gid: {gid}") |
| |
|
| |
|
| | @app.get("/", response_class=HTMLResponse) |
| | async def homepage(request: Request): |
| | return templates.TemplateResponse("landing.html", {"request": request}) |
| |
|
| |
|
| | def rewrite_location(location: str, proxy_prefix: str) -> str: |
| | parsed = urlparse(location) |
| | if not parsed.netloc: |
| | return proxy_prefix + location |
| | if parsed.hostname in ["localhost", "127.0.0.1"]: |
| | return proxy_prefix + parsed.path |
| | return location |
| |
|
| |
|
| | async def proxy_fetch( |
| | method: str, url: str, headers: dict, params: dict, body: bytes, proxy_prefix: str |
| | ): |
| | async with ClientSession(auto_decompress=True) as session: |
| | async with session.request( |
| | method, |
| | url, |
| | headers=headers, |
| | params=params, |
| | data=body, |
| | allow_redirects=False, |
| | ) as upstream: |
| | if upstream.status in (301, 302, 303, 307, 308) and upstream.headers.get( |
| | "Location" |
| | ): |
| | loc = upstream.headers["Location"] |
| | new_loc = rewrite_location(loc, proxy_prefix) |
| | return HTMLResponse( |
| | status_code=upstream.status, headers={"Location": new_loc} |
| | ) |
| | content = await upstream.read() |
| | media_type = upstream.headers.get("Content-Type", "text/html") |
| | resp_headers = { |
| | k: v |
| | for k, v in upstream.headers.items() |
| | if k.lower() not in ["content-length", "content-encoding"] |
| | } |
| | return HTMLResponse( |
| | content=content, |
| | status_code=upstream.status, |
| | headers=resp_headers, |
| | media_type=media_type, |
| | ) |
| |
|
| |
|
| | async def protected_proxy( |
| | service: str, path: str, request: Request, password: str = None |
| | ): |
| | service_info = SERVICES.get(service) |
| | if not service_info: |
| | raise HTTPException(status_code=404, detail="Service not found") |
| | if "password" in service_info and password != service_info["password"]: |
| | raise HTTPException(status_code=403, detail="Unauthorized access") |
| | base = service_info["url"] |
| | url = f"{base}/{path}" if path else base |
| | headers = {k: v for k, v in request.headers.items() if k.lower() != "host"} |
| | body = await request.body() |
| | return await proxy_fetch( |
| | request.method, url, headers, dict(request.query_params), body, f"/{service}" |
| | ) |
| |
|
| |
|
| | @app.api_route("/nzb/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) |
| | async def sabnzbd_proxy(path: str = "", request: Request = None): |
| | return await protected_proxy("nzb", path, request) |
| |
|
| |
|
| | @app.api_route("/qbit/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"]) |
| | async def qbittorrent_proxy(path: str = "", request: Request = None): |
| | password = request.query_params.get("pass") or request.cookies.get("qbit_pass") |
| | if not password: |
| | raise HTTPException(status_code=403, detail="Missing password") |
| | response = await protected_proxy("qbit", path, request, password) |
| | if "pass" in request.query_params: |
| | response.set_cookie("qbit_pass", password) |
| | return response |
| |
|
| |
|
| | @app.exception_handler(Exception) |
| | async def page_not_found(_, exc): |
| | return HTMLResponse( |
| | f"<h1>404: Task not found! Mostly wrong input. <br><br>Error: {exc}</h1>", |
| | status_code=404, |
| | ) |
| |
|