Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 66 additions & 4 deletions python_rpc/main.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from flask import Flask, request, jsonify
import sys, json, urllib.parse, psutil
import sys, json, urllib.parse, psutil, time, tempfile
from torrent_downloader import TorrentDownloader
from http_downloader import HttpDownloader
from profile_image_processor import ProfileImageProcessor
Expand Down Expand Up @@ -37,7 +37,7 @@
torrent_downloader = TorrentDownloader(torrent_session)
downloads[initial_download['game_id']] = torrent_downloader
try:
torrent_downloader.start_download(initial_download['url'], initial_download['save_path'])
torrent_downloader.start_download(initial_download['url'], initial_download['save_path'], initial_download.get('file_indices'))
except Exception as e:
print("Error starting torrent download", e)
else:
Expand Down Expand Up @@ -130,6 +130,67 @@ def seed_status():
def healthcheck():
return "ok", 200

@app.route("/torrent-files", methods=["POST"])
def get_torrent_files():
auth_error = validate_rpc_password()
if auth_error:
return auth_error

data = request.get_json()
magnet_uri = data.get('magnet_uri')

print(f"[torrent-files] Received request for magnet: {magnet_uri[:50] if magnet_uri else 'None'}...")

if not magnet_uri or not magnet_uri.startswith('magnet'):
print("[torrent-files] Invalid magnet URI")
return jsonify({"error": "Invalid magnet URI"}), 400

try:
print("[torrent-files] Creating temporary torrent handle...")
# Create temporary torrent handle to get file info
params = {
'url': magnet_uri,
'save_path': tempfile.gettempdir(),
'flags': lt.torrent_flags.upload_mode # Don't start downloading
}
temp_handle = torrent_session.add_torrent(params)

print("[torrent-files] Waiting for metadata (max 20s)...")
# Wait for metadata (up to 20 seconds)
for i in range(80):
if temp_handle.status().has_metadata:
print(f"[torrent-files] Metadata received after {i * 0.25}s")
break
time.sleep(0.25)

if not temp_handle.status().has_metadata:
print("[torrent-files] Metadata timeout after 20s")
torrent_session.remove_torrent(temp_handle)
return jsonify({"error": "Failed to fetch torrent metadata (timeout)"}), 408

# Get file information
info = temp_handle.get_torrent_info()
files = []
for i in range(info.num_files()):
file = info.file_at(i)
files.append({
'index': i,
'name': file.path,
'size': file.size
})

print(f"[torrent-files] Found {len(files)} files")

# Clean up temporary handle
torrent_session.remove_torrent(temp_handle)

return jsonify(files), 200
except Exception as e:
print(f"[torrent-files] ERROR: {type(e).__name__}: {str(e)}")
import traceback
traceback.print_exc()
return jsonify({"error": f"{type(e).__name__}: {str(e)}"}), 500

@app.route("/process-list", methods=["GET"])
def process_list():
auth_error = validate_rpc_password()
Expand Down Expand Up @@ -174,6 +235,7 @@ def action():

if action == 'start':
url = data.get('url')
file_indices = data.get('file_indices') # Optional list of file indices to download

existing_downloader = downloads.get(game_id)

Expand All @@ -187,11 +249,11 @@ def action():
http_multi_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'))
elif url.startswith('magnet'):
if existing_downloader and isinstance(existing_downloader, TorrentDownloader):
existing_downloader.start_download(url, data['save_path'])
existing_downloader.start_download(url, data['save_path'], file_indices)
else:
torrent_downloader = TorrentDownloader(torrent_session)
downloads[game_id] = torrent_downloader
torrent_downloader.start_download(url, data['save_path'])
torrent_downloader.start_download(url, data['save_path'], file_indices)
else:
if existing_downloader and isinstance(existing_downloader, HttpDownloader):
existing_downloader.start_download(url, data['save_path'], data.get('header'), data.get('out'))
Expand Down
120 changes: 115 additions & 5 deletions python_rpc/torrent_downloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ def __init__(self, torrent_session, flags = lt.torrent_flags.auto_managed):
self.torrent_handle = None
self.session = torrent_session
self.flags = flags
self.cached_file_size = None # Cache for selected files size
self.trackers = [
"udp://tracker.opentrackr.org:1337/announce",
"http://tracker.opentrackr.org:1337/announce",
Expand Down Expand Up @@ -102,11 +103,89 @@ def __init__(self, torrent_session, flags = lt.torrent_flags.auto_managed):
"http://bvarf.tracker.sh:2086/announce",
]

def start_download(self, magnet: str, save_path: str):
params = {'url': magnet, 'save_path': save_path, 'trackers': self.trackers, 'flags': self.flags}
def _wait_for_metadata(self, timeout_seconds=30):
"""Wait for torrent metadata to become available."""
import time
max_iterations = int(timeout_seconds / 0.25)

for i in range(max_iterations):
if self.torrent_handle.status().has_metadata:
print(f"[torrent] Metadata available after {i * 0.25:.2f}s")
return True
time.sleep(0.25)

print("[torrent] WARNING: Metadata not available after 30s, downloading all files")
return False

def _set_file_priorities(self, file_indices):
"""Set file priorities for selective download."""
info = self.torrent_handle.get_torrent_info()
num_files = info.num_files()
print(f"[torrent] Torrent has {num_files} files total")
print(f"[torrent] Setting priorities for file indices: {file_indices}")

# Set all files to priority 0 (don't download) first
for i in range(num_files):
self.torrent_handle.file_priority(i, 0)

# Then set selected files to priority 4 (normal download)
selected_file_sizes = []
for idx in file_indices:
if 0 <= idx < num_files:
self.torrent_handle.file_priority(idx, 4)
file_info = info.file_at(idx)
file_size = file_info.size
selected_file_sizes.append(file_size)
print(f"[torrent] File {idx}: {file_info.path} - Size: {file_size} bytes - Priority set to 4 (download)")
else:
print(f"[torrent] WARNING: File index {idx} out of range (0-{num_files-1})")

# Calculate cached size from the files we just set
self.cached_file_size = sum(selected_file_sizes)
print("[torrent] File priorities set successfully.")
print(f"[torrent] Total size of selected files: {self.cached_file_size} bytes ({self.cached_file_size / (1024**3):.2f} GB)")

def start_download(self, magnet: str, save_path: str, file_indices=None):
# Add torrent initially paused to prevent auto-download before setting priorities
temp_flags = self.flags
if file_indices is not None and len(file_indices) > 0:
temp_flags = lt.torrent_flags.paused | lt.torrent_flags.auto_managed

params = {'url': magnet, 'save_path': save_path, 'trackers': self.trackers, 'flags': temp_flags}
self.torrent_handle = self.session.add_torrent(params)

# If file_indices is provided, wait for metadata then set file priorities
if file_indices is not None and len(file_indices) > 0:
print(f"[torrent] Selective download requested for {len(file_indices)} files")
print(f"[torrent] File indices to download: {file_indices}")

if self._wait_for_metadata():
self._set_file_priorities(file_indices)

# Resume the torrent to start downloading
self.torrent_handle.resume()

def get_files(self):
"""Get list of files in the torrent"""
if self.torrent_handle is None:
return None

info = self.torrent_handle.get_torrent_info()
if not info:
return None

files = []
for i in range(info.num_files()):
file = info.file_at(i)
files.append({
'index': i,
'name': file.path,
'size': file.size,
'priority': self.torrent_handle.file_priority(i)
})

return files

def pause_download(self):
if self.torrent_handle:
self.torrent_handle.pause()
Expand All @@ -133,17 +212,48 @@ def get_download_status(self):

status = self.torrent_handle.status()
info = self.torrent_handle.get_torrent_info()


# Delegate file size computation to helper to reduce cognitive complexity
file_size = self._calculate_file_size() if info else 0

response = {
'folderName': info.name() if info else "",
'fileSize': info.total_size() if info else 0,
'fileSize': file_size,
'progress': status.progress,
'downloadSpeed': status.download_rate,
'uploadSpeed': status.upload_rate,
'numPeers': status.num_peers,
'numSeeds': status.num_seeds,
'status': status.state,
'bytesDownloaded': status.progress * info.total_size() if info else status.all_time_download,
'bytesDownloaded': (status.progress * file_size) if info else status.all_time_download,
}

return response

def _calculate_file_size(self):
"""Helper to calculate and cache file size based on file priorities."""
if self.torrent_handle is None:
return 0

info = self.torrent_handle.get_torrent_info()
if not info:
return 0

if self.cached_file_size is not None and self.cached_file_size > 0:
return self.cached_file_size

file_size = 0
for i in range(info.num_files()):
try:
if self.torrent_handle.file_priority(i) > 0:
file_size += info.file_at(i).size
except Exception:
continue
if file_size == 0:
try:
file_size = info.total_size()
except Exception:
file_size = 0

self.cached_file_size = file_size
return file_size
7 changes: 7 additions & 0 deletions src/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,13 @@
"open_screenshot": "Open screenshot {{number}}",
"download_settings": "Download settings",
"downloader": "Downloader",
"select_files": "Select Files",
"select_all": "Select All",
"name": "Name",
"size": "Size",
"downloading": "Downloading",
"loading_files": "Loading files...",
"error": "Error",
"select_executable": "Select",
"no_executable_selected": "No executable selected",
"open_folder": "Open folder",
Expand Down
1 change: 1 addition & 0 deletions src/main/events/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import "./torrenting/start-game-download";
import "./torrenting/pause-game-seed";
import "./torrenting/resume-game-seed";
import "./torrenting/check-debrid-availability";
import "./torrenting/get-torrent-files";
import "./user-preferences/get-user-preferences";
import "./user-preferences/update-user-preferences";
import "./user-preferences/auto-launch";
Expand Down
79 changes: 79 additions & 0 deletions src/main/events/torrenting/get-torrent-files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { registerEvent } from "../register-event";
import { PythonRPC } from "@main/services/python-rpc";
import { logger } from "@main/services";
import type { TorrentFile } from "@types";
import axios, { AxiosError } from "axios";

const PING_ATTEMPTS = 5;
const PING_BACKOFF_MS = 200;

function isValidMagnet(uri?: string) {
return typeof uri === "string" && uri.startsWith("magnet:");
}

async function pingRpcOnce(): Promise<boolean> {
try {
const client = (PythonRPC.rpc as typeof axios) || axios;
await client.get("/healthcheck", { timeout: 1000 });
return true;
} catch (err) {
return false;
}
}

async function ensurePythonRpcRunning(): Promise<void> {
const firstOk = await pingRpcOnce();
if (firstOk) return;

if (PythonRPC.isRunning()) {
logger.warn(
"Python RPC reported running but did not respond to ping; attempting restart"
);
} else {
logger.log("Python RPC server not running, starting it now...");
}
await PythonRPC.spawn();

for (let attempt = 1; attempt <= PING_ATTEMPTS; attempt += 1) {
if (await pingRpcOnce()) return;

logger.log(`Python RPC ping attempt ${attempt} failed`);
await new Promise((r) => setTimeout(r, PING_BACKOFF_MS * attempt));
}

throw new Error("Python RPC did not respond to ping after starting");
}

const getTorrentFiles = async (
_event: Electron.IpcMainInvokeEvent,
magnetUri: string
): Promise<TorrentFile[]> => {
if (!isValidMagnet(magnetUri)) {
throw new Error("Invalid magnet URI");
}

await ensurePythonRpcRunning();

try {
const response = await PythonRPC.rpc.post<TorrentFile[]>("/torrent-files", {
magnet_uri: magnetUri,
});

return response.data;
} catch (error) {
logger.error("Failed to fetch torrent files", error);

if (error instanceof AxiosError) {
const errorMessage = error.response?.data?.error || error.message;
throw new Error(errorMessage);
}

if (error instanceof Error) {
throw new Error(error.message);
}

throw new Error("Failed to fetch torrent files");
}
};

registerEvent("getTorrentFiles", getTorrentFiles);
5 changes: 4 additions & 1 deletion src/main/events/torrenting/start-game-download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const startGameDownload = async (
downloader,
uri,
automaticallyExtract,
fileIndices,
selectedFilesSize,
} = payload;

const gameKey = levelKeys.game(shop, objectId);
Expand Down Expand Up @@ -76,12 +78,13 @@ const startGameDownload = async (
downloader,
uri,
folderName: null,
fileSize: null,
fileSize: selectedFilesSize ?? null,
shouldSeed: false,
timestamp: Date.now(),
queued: true,
extracting: false,
automaticallyExtract,
fileIndices,
};

try {
Expand Down
1 change: 1 addition & 0 deletions src/main/services/download/download-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@ export class DownloadManager {
game_id: downloadId,
url: download.uri,
save_path: download.downloadPath,
file_indices: download.fileIndices,
};
case Downloader.RealDebrid: {
const downloadUrl = await RealDebridClient.getDownloadUrl(download.uri);
Expand Down
Loading
Loading