From 88c8bb39f8c5005fc00633f5db04075aee8fb7ba Mon Sep 17 00:00:00 2001 From: Matteo Benedetto Date: Mon, 14 Apr 2025 20:34:19 +0200 Subject: [PATCH] first commit --- .env-example | 12 +++ .gitignore | 39 +++++++++ LICENSE.md | 21 +++++ README.md | 53 ++++++++++++ main.py | 101 +++++++++++++++++++++++ requirements.txt | 6 ++ tools.py | 210 +++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 442 insertions(+) create mode 100644 .env-example create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 tools.py diff --git a/.env-example b/.env-example new file mode 100644 index 0000000..ec7a80e --- /dev/null +++ b/.env-example @@ -0,0 +1,12 @@ +# qBittorrent API Configuration +QBIT_HOST=http://localhost:8080 +QBIT_USERNAME=admin +QBIT_PASSWORD=password + +# OpenAI API Key (required for the LangChain agent) +OPENAI_API_KEY=sk-proj-Rs93xxxxxxxxxxxxxxxxxxxxxUnStmeSHj_gUiEfbGzaFeZf0rgdaQzllQmvcMy6o-SywA + +# DuckDuckGo Search Configuration +DUCKDUCKGO_ENABLED=true +DUCKDUCKGO_MAX_RESULTS=5 +OMDB_API_KEY=3b6bc268 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d79ad56 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Environment variables +.env + +# Python bytecode +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +dist/ +build/ +*.egg-info/ + +# Virtual environments +venv/ +env/ +ENV/ +.venv/ + +# IDE files +.idea/ +.vscode/ +*.swp +*.swo + +# OS files +.DS_Store +Thumbs.db + +# Logs +*.log + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# Jupyter Notebooks +.ipynb_checkpoints \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..f6d7bd4 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 qBittorrent AI Agent + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..cdebb68 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# qBittorrent AI Agent + +An AI-powered assistant for qBittorrent that allows natural language interaction with your torrent client. + +## Features + +- **Natural Language Interface**: Interact with qBittorrent using natural language commands +- **Search Torrents**: Search for torrents directly through the AI interface +- **Download Management**: View active downloads and add new torrents +- **Web Interface**: Built with Gradio for easy access through your browser +- **Command Line Interface**: Optional CLI mode for terminal-based interactions + +## Requirements + +- Python 3.8+ +- qBittorrent with WebUI enabled +- OpenAI API key + +## Installation + +1. Clone this repository +2. Install dependencies: + ``` + pip install -r requirements.txt + ``` +3. Create a `.env` file with your configuration: + ``` + OPENAI_API_KEY=your_openai_api_key + QBIT_HOST=http://localhost:8080 + QBIT_USERNAME=admin + QBIT_PASSWORD=adminadmin + ``` + +## Usage + +Run the web interface: +``` +python main.py +``` + +Or use the CLI interface by uncommenting the `cli_main()` line in `main.py`. + +## Tools + +The agent includes several tools: +- `get_downloads_list`: Get information about current downloads +- `qbittorrent_search`: Search for torrents using qBittorrent's search functionality +- `download_torrent`: Add a torrent to the download queue +- `ForcedDuckDuckGoSearch`: Search for information about media content + +## License + +MIT \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..4ef2e2f --- /dev/null +++ b/main.py @@ -0,0 +1,101 @@ +import os +from langchain.agents import Tool, initialize_agent, AgentType +from dotenv import load_dotenv +from tools import DownloadListTool, QBitSearchTool, DownloadTorrentTool +from langchain_community.tools import DuckDuckGoSearchRun +from langchain.memory import ConversationBufferMemory +from langchain.chat_models import init_chat_model +import gradio as gr +# Load environment variables +load_dotenv() + +def create_agent(): + # Initialize the language model + + + llm = init_chat_model("gpt-4o-mini", model_provider="openai") + + # Initialize memory + memory = ConversationBufferMemory(memory_key="chat_history", return_messages=True) + + # Initialize search tool + search_tool = DuckDuckGoSearchRun() + + # Function to force DuckDuckGo for specific search types + def forced_duckduckgo_search(query: str) -> str: + """Use DuckDuckGo to search for specific information.""" + return search_tool.run(query) + + # Initialize tools + tools = [ + DownloadListTool(), + QBitSearchTool(), + DownloadTorrentTool(), + Tool( + name="ForcedDuckDuckGoSearch", + func=forced_duckduckgo_search, + description="Use this tool when you need to find specific information about movies, TV shows. Input should be a search query including the keyword 'imdb'.", + ) + ] + + # Initialize the agent with memory + agent = initialize_agent( + tools, + llm, + agent=AgentType.CHAT_CONVERSATIONAL_REACT_DESCRIPTION, + verbose=True, + memory=memory + ) + + return agent + +def process_query(message, history): + try: + # Create agent if it doesn't exist + if not hasattr(process_query, "agent"): + process_query.agent = create_agent() + + # Run the agent with the user's message + response = process_query.agent.run(message) + return response + except Exception as e: + return f"Error: {str(e)}" + +def main(): + print("Starting qBittorrent AI Agent...") + + # Create Gradio interface + with gr.Blocks(title="qBittorrent AI Agent") as interface: + gr.Markdown("# qBittorrent AI Agent") + gr.Markdown("Ask questions about downloads, search for content, or get recommendations.") + + chatbot = gr.ChatInterface( + process_query, + examples=["Find me the latest sci-fi movies", + "What are the top TV shows from 2023?", + "Download Interstellar in 1080p"], + title="qBittorrent Assistant" + ) + + # Launch the interface + interface.launch(share=False) + +def cli_main(): + print("Starting qBittorrent AI Agent in CLI mode...") + agent = create_agent() + + while True: + user_input = input("\nEnter your question (or 'quit' to exit): ") + if user_input.lower() in ['quit', 'exit']: + break + try: + response = agent.run(user_input) + print(response) + except Exception as e: + print(f"Error: {str(e)}") + +if __name__ == "__main__": + # Use main() for Gradio interface or cli_main() for command-line interface + main() + # Uncomment the line below to use CLI instead + # cli_main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b1a423c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +langchain>=0.0.267 +openai>=0.27.8 +requests>=2.28.2 +python-dotenv>=1.0.0 +gradio>=3.0.0 +langchain_community>=0.0.1 \ No newline at end of file diff --git a/tools.py b/tools.py new file mode 100644 index 0000000..97c3aea --- /dev/null +++ b/tools.py @@ -0,0 +1,210 @@ +import os +from langchain.tools.base import BaseTool +from langchain.callbacks.manager import CallbackManagerForToolRun +import requests +from typing import Optional + +class DownloadListTool(BaseTool): + name: str = "get_downloads_list" + description: str = '''Useful for getting a list of current downloads from the qBittorrent API and + information about them. The response will include the name, size, and status of each download. + ''' + + def _run(self, query: str = "", run_manager: Optional[CallbackManagerForToolRun] = None) -> str: + """Get the list of downloads from qBittorrent API.""" + try: + # Configuration for qBittorrent API + QBIT_HOST = os.environ.get("QBIT_HOST", "http://localhost:8080") + QBIT_USERNAME = os.environ.get("QBIT_USERNAME", "admin") + QBIT_PASSWORD = os.environ.get("QBIT_PASSWORD", "adminadmin") + + # First login to get the auth cookie + login_url = f"{QBIT_HOST}/api/v2/auth/login" + login_data = {"username": QBIT_USERNAME, "password": QBIT_PASSWORD} + + session = requests.Session() + login_response = session.post(login_url, data=login_data) + + if login_response.status_code != 200: + return f"Failed to login to qBittorrent API: {login_response.text}" + + # Get the list of torrents + torrents_url = f"{QBIT_HOST}/api/v2/torrents/info" + response = session.get(torrents_url) + + if response.status_code != 200: + return f"Failed to fetch downloads: {response.text}" + + torrents = response.json() + + # Format the response + if not torrents: + return "No active downloads found." + + result = "Current downloads:\n" + for i, torrent in enumerate(torrents, 1): + result += f"{i}:" + for key, value in torrent.items(): + if key in ["name", "progress", "size", "state", "eta"]: + result += f" {key}: {value}," + result += "\n" + result += "\n" + result += "Total downloads: " + str(len(torrents)) + + return result + + except Exception as e: + return f"Error getting downloads list: {str(e)}" + +class QBitSearchTool(BaseTool): + name: str = "qbittorrent_search" + description: str = '''Useful for searching torrents using qBittorrent's search functionality. + Input should be a search query for content the user wants to find. + The tool will return a list of matching torrents with their details including magnet links. + ''' + + def _run(self, query: str, run_manager: Optional[CallbackManagerForToolRun] = None) -> str: + """Search for torrents using qBittorrent's search functionality.""" + try: + # Configuration for qBittorrent API + QBIT_HOST = os.environ.get("QBIT_HOST", "http://localhost:8080") + QBIT_USERNAME = os.environ.get("QBIT_USERNAME", "admin") + QBIT_PASSWORD = os.environ.get("QBIT_PASSWORD", "adminadmin") + + # First login to get the auth cookie + login_url = f"{QBIT_HOST}/api/v2/auth/login" + login_data = {"username": QBIT_USERNAME, "password": QBIT_PASSWORD} + + session = requests.Session() + login_response = session.post(login_url, data=login_data) + + if login_response.status_code != 200: + return f"Failed to login to qBittorrent API: {login_response.text}" + + # Start a search + start_search_url = f"{QBIT_HOST}/api/v2/search/start" + search_data = {"pattern": query, "plugins": "all", "category": "all"} + + search_response = session.post(start_search_url, data=search_data) + + if search_response.status_code != 200: + return f"Failed to start search: {search_response.text}" + + search_id = search_response.json().get("id") + + if not search_id: + return "Failed to get search ID" + + # Wait for results (simple implementation, can be improved) + import time + max_wait = 10 # seconds + wait_time = 0 + step = 1 + + while wait_time < max_wait: + time.sleep(step) + wait_time += step + + # Get search status + status_url = f"{QBIT_HOST}/api/v2/search/status" + status_params = {"id": search_id} + status_response = session.get(status_url, params=status_params) + + if status_response.status_code != 200: + return f"Failed to get search status: {status_response.text}" + + status_data = status_response.json() + if status_data[0].get("status") == "Stopped": + break + + # Get search results + results_url = f"{QBIT_HOST}/api/v2/search/results" + results_params = {"id": search_id, "limit": 10} # Limiting to top 10 results + + results_response = session.get(results_url, params=results_params) + + if results_response.status_code != 200: + return f"Failed to get search results: {results_response.text}" + + results_data = results_response.json() + results = results_data.get("results", []) + + # Stop the search + stop_url = f"{QBIT_HOST}/api/v2/search/stop" + stop_params = {"id": search_id} + session.post(stop_url, params=stop_params) + + # Format the response + if not results: + return f"No results found for '{query}'." + + response = f"Search results for '{query}':\n\n" + + for i, result in enumerate(results, 1): + name = result.get("fileName", "Unknown") + size = result.get("fileSize", "Unknown") + seeds = result.get("seeders", 0) + leech = result.get("leechers", 0) + magnet = result.get("fileUrl", "") + + # Convert size to human-readable format + if isinstance(size, (int, float)): + units = ["B", "KB", "MB", "GB", "TB"] + size_index = 0 + while size >= 1024 and size_index < len(units) - 1: + size /= 1024 + size_index += 1 + size = f"{size:.2f} {units[size_index]}" + + response += f"{i}. {name}\n" + response += f" Size: {size}, Seeds: {seeds}, Leechers: {leech}\n" + if magnet: + response += f" Magnet: {magnet}\n" + + return response + + except Exception as e: + return f"Error searching torrents: {str(e)}" + +class DownloadTorrentTool(BaseTool): + name: str = "download_torrent" + description: str = '''Useful for starting a new torrent download in qBittorrent. + Input should be a magnet link or a torrent URL that the user wants to download. + The tool will add the torrent to qBittorrent's download queue and return status. + ''' + + def _run(self, torrent_url: str, run_manager: Optional[CallbackManagerForToolRun] = None) -> str: + """Start downloading a torrent by adding it to qBittorrent.""" + try: + # Check if the input is a valid torrent URL or magnet link + if not (torrent_url.startswith("http") or torrent_url.startswith("magnet:")): + return "Error: Please provide a valid torrent URL or magnet link." + + # Configuration for qBittorrent API + QBIT_HOST = os.environ.get("QBIT_HOST", "http://localhost:8080") + QBIT_USERNAME = os.environ.get("QBIT_USERNAME", "admin") + QBIT_PASSWORD = os.environ.get("QBIT_PASSWORD", "adminadmin") + + # First login to get the auth cookie + login_url = f"{QBIT_HOST}/api/v2/auth/login" + login_data = {"username": QBIT_USERNAME, "password": QBIT_PASSWORD} + + session = requests.Session() + login_response = session.post(login_url, data=login_data) + + if login_response.status_code != 200: + return f"Failed to login to qBittorrent API: {login_response.text}" + + # Add torrent to download queue + add_url = f"{QBIT_HOST}/api/v2/torrents/add" + add_data = {"urls": torrent_url} + + add_response = session.post(add_url, data=add_data) + + if add_response.status_code != 200: + return f"Failed to add torrent: {add_response.text}" + + return f"Torrent has been added to download queue successfully. Check downloads list for status." + + except Exception as e: + return f"Error adding torrent: {str(e)}"