Tutorial: Building a Basic Download Manager

Tutorial: Building a Basic Download Manager

In this tutorial, you’ll build a complete torrent download manager with a user interface. This is a practical, real-world example that demonstrates core Godot-Torrent functionality.

What You’ll Build

A download manager with:

Prerequisites


Part 1: Project Setup

Step 1: Create the Project Structure

your_project/
├── scenes/
│   ├── main.tscn          # Main scene
│   └── torrent_item.tscn  # Individual torrent UI
├── scripts/
│   ├── download_manager.gd
│   └── torrent_item.gd
└── addons/
    └── godot-torrent/     # The GDExtension

Step 2: Create the Main Scene

Create a new scene (scenes/main.tscn) with this structure:

Control (MarginContainer)
├── VBoxContainer
│   ├── HBoxContainer (Top bar)
│   │   ├── LineEdit (for magnet links)
│   │   ├── Button ("Add Magnet")
│   │   └── Button ("Add File")
│   ├── ScrollContainer
│   │   └── VBoxContainer (TorrentList)
│   └── HBoxContainer (Status bar)
│       └── Label (StatusLabel)

Part 2: The Download Manager Script

Create scripts/download_manager.gd:

extends Control

# UI References
@onready var magnet_input: LineEdit = $VBoxContainer/TopBar/MagnetInput
@onready var add_magnet_btn: Button = $VBoxContainer/TopBar/AddMagnetBtn
@onready var add_file_btn: Button = $VBoxContainer/TopBar/AddFileBtn
@onready var torrent_list: VBoxContainer = $VBoxContainer/ScrollContainer/TorrentList
@onready var status_label: Label = $VBoxContainer/StatusBar/StatusLabel

# Torrent session
var session: TorrentSession
var download_path: String = "user://downloads"

# Track active torrents
var active_torrents: Dictionary = {}  # handle -> torrent_item_node

# Preload the torrent item scene
var TorrentItem = preload("res://scenes/torrent_item.tscn")

func _ready():
    # Create download directory
    _ensure_download_directory()

    # Initialize torrent session
    _init_session()

    # Connect UI signals
    add_magnet_btn.pressed.connect(_on_add_magnet_pressed)
    add_file_btn.pressed.connect(_on_add_file_pressed)
    magnet_input.text_submitted.connect(_on_magnet_submitted)

    _update_status("Ready to download")

func _init_session():
    session = TorrentSession.new()

    if not session.start_session():
        _show_error("Failed to start torrent session")
        return

    # Enable DHT for better peer discovery
    session.start_dht()

    # Configure reasonable defaults
    session.set_max_connections(200)
    session.set_max_uploads(4)
    session.set_listen_port_range(6881, 6889)

    print("✅ Torrent session initialized")

func _ensure_download_directory():
    var dir = DirAccess.open("user://")
    if not dir.dir_exists("downloads"):
        dir.make_dir("downloads")

func _on_add_magnet_pressed():
    var magnet = magnet_input.text.strip_edges()
    if magnet.is_empty():
        _show_error("Please enter a magnet link")
        return

    _add_magnet(magnet)
    magnet_input.clear()

func _on_magnet_submitted(text: String):
    _on_add_magnet_pressed()

func _on_add_file_pressed():
    # Create file dialog
    var dialog = FileDialog.new()
    dialog.access = FileDialog.ACCESS_FILESYSTEM
    dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
    dialog.filters = PackedStringArray(["*.torrent ; Torrent Files"])
    dialog.file_selected.connect(_on_torrent_file_selected)
    add_child(dialog)
    dialog.popup_centered(Vector2(800, 600))

func _on_torrent_file_selected(path: String):
    var file = FileAccess.open(path, FileAccess.READ)
    if not file:
        _show_error("Failed to read torrent file")
        return

    var torrent_data = file.get_buffer(file.get_length())
    file.close()

    _add_torrent_file(torrent_data)

func _add_magnet(magnet: String):
    if not session or not session.is_running():
        _show_error("Session not running")
        return

    var handle = session.add_magnet_uri(magnet, download_path)

    if not handle or not handle.is_valid():
        _show_error("Failed to add magnet link")
        return

    _create_torrent_item(handle)
    _update_status("Torrent added - waiting for metadata...")

func _add_torrent_file(torrent_data: PackedByteArray):
    if not session or not session.is_running():
        _show_error("Session not running")
        return

    var handle = session.add_torrent_file(torrent_data, download_path)

    if not handle or not handle.is_valid():
        _show_error("Failed to add torrent file")
        return

    _create_torrent_item(handle)
    _update_status("Torrent added successfully")

func _create_torrent_item(handle: TorrentHandle):
    var item = TorrentItem.instantiate()
    torrent_list.add_child(item)
    item.initialize(handle, self)

    active_torrents[handle] = item

    print("📦 Torrent added, handle valid: ", handle.is_valid())

func remove_torrent(handle: TorrentHandle):
    if handle in active_torrents:
        var item = active_torrents[handle]
        item.queue_free()
        active_torrents.erase(handle)

    if session:
        session.remove_torrent(handle, false)  # Don't delete files

    _update_status("Torrent removed")

func _update_status(message: String):
    status_label.text = message

func _show_error(message: String):
    status_label.text = "❌ " + message
    push_error(message)

func _process(_delta):
    # Update active torrent count
    if active_torrents.size() > 0:
        _update_status("Active torrents: %d" % active_torrents.size())

func _exit_tree():
    # Clean up session
    if session:
        session.stop_session()
    print("👋 Download manager closed")

Part 3: The Torrent Item UI

Create scenes/torrent_item.tscn with this structure:

PanelContainer
└── MarginContainer
    └── VBoxContainer
        ├── HBoxContainer (Header)
        │   ├── Label (NameLabel)
        │   └── Button (RemoveBtn "✕")
        ├── ProgressBar (ProgressBar)
        └── HBoxContainer (Stats)
            ├── Label (StatsLabel)
            └── HBoxContainer (Controls)
                ├── Button (PauseBtn)
                └── Button (ResumeBtn)

Part 4: The Torrent Item Script

Create scripts/torrent_item.gd:

extends PanelContainer

# UI References
@onready var name_label: Label = $Margin/VBox/Header/NameLabel
@onready var remove_btn: Button = $Margin/VBox/Header/RemoveBtn
@onready var progress_bar: ProgressBar = $Margin/VBox/ProgressBar
@onready var stats_label: Label = $Margin/VBox/Stats/StatsLabel
@onready var pause_btn: Button = $Margin/VBox/Stats/Controls/PauseBtn
@onready var resume_btn: Button = $Margin/VBox/Stats/Controls/ResumeBtn

# Torrent data
var handle: TorrentHandle
var manager  # Reference to DownloadManager

# Update timer
var update_timer: float = 0.0
var update_interval: float = 1.0  # Update every second

func _ready():
    # Connect button signals
    remove_btn.pressed.connect(_on_remove_pressed)
    pause_btn.pressed.connect(_on_pause_pressed)
    resume_btn.pressed.connect(_on_resume_pressed)

    # Configure progress bar
    progress_bar.min_value = 0.0
    progress_bar.max_value = 100.0
    progress_bar.value = 0.0

    # Initially show pause button
    resume_btn.visible = false

func initialize(torrent_handle: TorrentHandle, download_manager):
    handle = torrent_handle
    manager = download_manager

    # Set initial name (will update when metadata arrives)
    name_label.text = "Downloading metadata..."

func _process(delta):
    if not handle or not handle.is_valid():
        return

    update_timer += delta
    if update_timer >= update_interval:
        update_timer = 0.0
        _update_display()

func _update_display():
    # Request status updates via alerts (non-blocking)
    manager.session.post_torrent_updates()
    
    # Get alerts (also non-blocking)
    var alerts = manager.session.get_alerts()
    
    for alert in alerts:
        if alert.has("torrent_status"):
            for status_dict in alert["torrent_status"]:
                # Check if this is our torrent
                if status_dict.get("info_hash") == handle.get_info_hash():
                    _update_from_status_dict(status_dict)
                    return

func _update_from_status_dict(status_dict):
    # Update name if we have metadata
    var info = handle.get_torrent_info()
    if info and info.is_valid():
        var torrent_name = info.get_name()
        if torrent_name and not torrent_name.is_empty():
            name_label.text = torrent_name

    # Update progress
    var progress = status_dict.get("progress", 0.0) * 100.0
    progress_bar.value = progress

    # Update statistics
    var download_rate = status_dict.get("download_rate", 0) / 1024.0  # KB/s
    var upload_rate = status_dict.get("upload_rate", 0) / 1024.0      # KB/s
    var num_peers = status_dict.get("num_peers", 0)
    var state = status_dict.get("state", 0)
    var state_str = _get_state_string(state)

    stats_label.text = "%.1f%% | ⬇ %.1f KB/s | ⬆ %.1f KB/s | 👥 %d | %s" % [
        progress, download_rate, upload_rate, num_peers, state_str
    ]

    # Update button visibility based on pause state
    var is_paused = status_dict.get("is_paused", false)
    pause_btn.visible = not is_paused
    resume_btn.visible = is_paused

    # Check if download is complete
    if status_dict.get("is_finished", false):
        progress_bar.modulate = Color.GREEN
        stats_label.text = "✅ Download complete! | Seeding | ⬆ %.1f KB/s | 👥 %d" % [
            upload_rate, num_peers
        ]

func _get_state_string(state: int) -> String:
    match state:
        0: return "Queued for checking"
        1: return "Checking files"
        2: return "Downloading metadata"
        3: return "Downloading"
        4: return "Finished"
        5: return "Seeding"
        6: return "Allocating"
        7: return "Checking resume data"
        _: return "Unknown"

func _on_pause_pressed():
    if handle and handle.is_valid():
        handle.pause()

func _on_resume_pressed():
    if handle and handle.is_valid():
        handle.resume()

func _on_remove_pressed():
    if manager:
        manager.remove_torrent(handle)

Part 5: Styling the UI (Optional)

Add a custom theme to make it look better. Here’s a simple theme:

# In your _ready() function of download_manager.gd

func _ready():
    _setup_theme()
    # ... rest of setup

func _setup_theme():
    # Create a simple dark theme
    var theme = Theme.new()

    # Panel style
    var panel_style = StyleBoxFlat.new()
    panel_style.bg_color = Color(0.2, 0.2, 0.2)
    panel_style.border_width_all = 1
    panel_style.border_color = Color(0.4, 0.4, 0.4)
    panel_style.corner_radius_all = 4
    theme.set_stylebox("panel", "PanelContainer", panel_style)

    # Apply theme
    self.theme = theme

Part 6: Testing Your Download Manager

  1. Run the scene
  2. Add a test magnet link (public domain content):
    magnet:?xt=urn:btih:08ada5a7a6183aae1e09d831df6748d566095a10&dn=Sintel
    
  3. Watch the progress in real-time
  4. Test pause/resume functionality
  5. Remove the torrent when done

Part 7: Enhancements

Add Bandwidth Controls

# In download_manager.gd

@onready var download_limit: SpinBox = $VBoxContainer/TopBar/DownloadLimit
@onready var upload_limit: SpinBox = $VBoxContainer/TopBar/UploadLimit

func _ready():
    # ... existing code ...

    download_limit.value_changed.connect(_on_download_limit_changed)
    upload_limit.value_changed.connect(_on_upload_limit_changed)

    # Set defaults (0 = unlimited)
    download_limit.value = 0
    upload_limit.value = 512  # 512 KB/s default

func _on_download_limit_changed(value: float):
    if session:
        var bytes_per_sec = int(value * 1024)  # Convert KB/s to bytes/s
        session.set_download_rate_limit(bytes_per_sec)

func _on_upload_limit_changed(value: float):
    if session:
        var bytes_per_sec = int(value * 1024)
        session.set_upload_rate_limit(bytes_per_sec)

Add Torrent Queue

# Limit simultaneous downloads
var max_active_downloads: int = 3
var queued_torrents: Array[TorrentHandle] = []

func _add_magnet(magnet: String):
    var handle = session.add_magnet_uri(magnet, download_path)

    if not handle or not handle.is_valid():
        return

    if active_torrents.size() >= max_active_downloads:
        handle.pause()
        queued_torrents.append(handle)
        _update_status("Torrent queued")

    _create_torrent_item(handle)

func remove_torrent(handle: TorrentHandle):
    # ... existing code ...

    # Start next queued torrent
    if queued_torrents.size() > 0:
        var next_handle = queued_torrents.pop_front()
        if next_handle and next_handle.is_valid():
            next_handle.resume()

Save/Load Session State

# Save on exit
func _exit_tree():
    _save_session_state()
    if session:
        session.stop_session()

func _save_session_state():
    if not session:
        return

    var state = session.save_state()
    var file = FileAccess.open("user://session.dat", FileAccess.WRITE)
    if file:
        file.store_buffer(state)
        file.close()
        print("💾 Session state saved")

# Load on start
func _init_session():
    session = TorrentSession.new()

    if not session.start_session():
        return

    _load_session_state()
    session.start_dht()

func _load_session_state():
    var file = FileAccess.open("user://session.dat", FileAccess.READ)
    if not file:
        return

    var state = file.get_buffer(file.get_length())
    file.close()

    if state.size() > 0:
        session.load_state(state)
        print("📂 Session state loaded")

Complete Example

The complete working example is available in the repository:


Troubleshooting

Solution: Make sure DHT is enabled:

session.start_dht()

Issue: No progress after adding torrent

Possible causes:

  1. Waiting for metadata (normal for magnet links)
  2. No peers available (check peer count)
  3. Firewall blocking connections (check ports)

Issue: Downloads are slow

Solutions:

  1. Increase connection limits:
    session.set_max_connections(200)
    
  2. Remove bandwidth limits:
    session.set_download_rate_limit(0)  # Unlimited
    
  3. Wait for more peers to connect

Next Steps


🎉 Congratulations! You’ve built a complete torrent download manager!