Skip to content

Progress & Cancellation

Track download progress and cancel operations in real time.

Progress Reporting

DownloadProgress

The progress object passed to callbacks:

@dataclass(frozen=True)
class DownloadProgress:
    id: str                    # Unique download ID
    status: DownloadStatus     # Current status (DOWNLOADING, PROCESSING, etc.)
    percent: int | None        # 0–100 (None if unknown)
    speed_bps: float           # Bytes per second
    title: str                 # Media title
    eta: int | None            # Estimated seconds remaining
    current_file: str | None   # Filename being downloaded
    output_path: str | None    # Final output path
    error: str | None          # Error message if failed
    updated_at: datetime       # Timestamp

Progress Callback (per-call)

Pass a callback to download(), download_playlist(), or execute_plan():

async def on_progress(progress: DownloadProgress) -> None:
    if progress.percent is not None:
        print(f"{progress.percent}% — {progress.title}")

    if progress.speed_bps > 0:
        print(f"  Speed: {progress.speed_bps / 1e6:.1f} Mbps")

    if progress.eta is not None:
        print(f"  ETA: {progress.eta}s")

result = await client.download("URL", on_progress=on_progress)

client.progress Event (client-wide)

client.progress is an Event[DownloadProgress] that fires for every progress update from any download started on that client instance. Use it when you want a single listener across all operations without threading a callback through every call:

from vdl import VDLClient, DownloadProgress

client = VDLClient()

async def handler(p: DownloadProgress) -> None:
    print(f"[{p.status.value}] {p.title}: {p.percent}%")

client.progress += handler              # subscribe

# All subsequent downloads fire handler automatically
result = await client.download("URL1")
results = await client.download_playlist("URL2")

client.progress -= handler              # unsubscribe when done

on_progress= on an individual call still takes effect and does not suppress the client-level event — both fire if both are set.

Percent Unknown

For some sources (e.g., streaming or when size is unknown), percent is None. Use speed_bps and current_file to show progress:

async def on_progress(progress: DownloadProgress) -> None:
    if progress.percent is not None:
        print(f"{progress.percent}%")
    else:
        print(f"Downloading {progress.current_file}...")

Rich Terminal UI

VDL's interactive mode displays progress with RichProgressEmitter, showing: - Live bar with percent, elapsed, and remaining - Download speed and ETA - Concurrent task list with status badges - Total, active, remaining, and per-status counts

This is automatic in interactive mode. For headless usage, provide your own callback.

Cancellation

Cancel a download in progress using a CancellationToken.

CancellationToken

A thread-safe flag for signaling cancellation:

from vdl.cancellation import CancellationToken

token = CancellationToken()

# Start download in one coroutine
task = asyncio.create_task(client.download("URL", cancel_token=token))

# Cancel from another
token.cancel()

result = await task
assert result.status == DownloadStatus.CANCELLED

CancellationController

For named per-task cancellation (e.g., from a CLI menu):

from vdl.cancellation import CancellationController

controller = CancellationController()

# Register and get a token for a task
with controller.task("My Video", parent=None) as token:
    result = await client.download("URL", cancel_token=token)

# Later, from a menu or signal handler:
controller.cancel("My Video")  # Cancel by name
controller.cancel_all()         # Cancel all

SIGINT Handler Example

Cancel on Ctrl+C:

import asyncio
import signal
from vdl.cancellation import CancellationToken

async def main():
    token = CancellationToken()

    def handle_sigint(sig, frame):
        print("\nCancelling...")
        token.cancel()

    signal.signal(signal.SIGINT, handle_sigint)

    result = await client.download("URL", cancel_token=token)
    print(result.status)

asyncio.run(main())

CompositeCancellationToken

Internally, VDL combines per-task and plan-level tokens using CompositeCancellationToken:

parent_token = CancellationToken()
per_task_token = CancellationToken()

composite = CompositeCancellationToken([parent_token, per_task_token])

# Composite is cancelled if either parent_token or per_task_token is cancelled
assert composite.is_cancelled == (parent_token.is_cancelled or per_task_token.is_cancelled)

You typically don't use this directly; it's used internally by CancellationController.

Interactive Mode Cancellation

In interactive mode, Ctrl+C pauses the Rich UI and shows a menu:

Paused. Options:
  1. Resume
  2. Cancel this task
  3. Cancel all
  4. Exit menu
Select: _

Choose an option to resume, cancel specific task(s), or exit to the main menu.

Progress in Batch Operations

For download_playlist() or execute_plan(), the callback is invoked for each item:

async def on_progress(progress: DownloadProgress):
    print(f"[{progress.id}] {progress.title}: {progress.percent}%")

results = await client.download_playlist("URL", on_progress=on_progress)

Each item gets a unique progress.id. Filter or route by ID if needed:

async def on_progress(progress: DownloadProgress):
    if progress.status == DownloadStatus.COMPLETED:
        print(f"✓ {progress.title}")
    elif progress.status == DownloadStatus.FAILED:
        print(f"✗ {progress.title}: {progress.error}")

Concurrency and Cancellation

When multiple downloads run concurrently (e.g., in a playlist), cancelling the plan-level token cancels all active tasks:

plan = await client.plan("playlist_url")
token = CancellationToken()

# Start all downloads concurrently
task = asyncio.create_task(
    client.execute_plan(plan, cancel_token=token)
)

# After 10 seconds, cancel all
await asyncio.sleep(10)
token.cancel()

results = await task
# Some items may have COMPLETED, some CANCELLED

See Also