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¶
- Python API — full callback signatures
- Downloads — download workflow