Skip to content

Agent Guide for VDL

This guide is specifically for AI agents, LLMs, and automation systems integrating VDL. It clarifies boundaries, hidden constraints, and design decisions that might not be obvious from the code alone.

Reading Order

Before editing VDL code or suggesting changes:

  1. PUBLIC_API.md — what you can import, what you cannot
  2. ARCHITECTURE.md — module layout and service roles
  3. This file — hidden constraints and common mistakes

Core Boundaries (Do Not Violate)

Boundary 1: VDL is Library-First, Not CLI-First

DO ✓:  Put logic in core/
DON'T ✗: Put logic in apps/cli/ or apps/interactive/
DON'T ✗: Make interactive/ or cli/ do the real work, then call from core/

Why: VDL is used as a library by MCP, Python developers, and other tools. Core logic must be independet of any user interface.

How to apply: If adding a feature: - Implement in vdl/core/ or vdl/client.py - CLI is just a thin adapter that calls the core API - Interactive mode may add state/UI on top, but not new business logic

Boundary 2: App Layers Never Import Each Other

# ✓ ALLOWED
from vdl.core.download.downloader import Downloader
from vdl.client import VDLClient
from vdl.apps.cli.main import main

# ✗ FORBIDDEN
# from vdl.apps.cli.main import cli_function  (don't call from interactive)
# from vdl.apps.interactive.main import ui_fn  (don't call from CLI)

Why: CLI and interactive modes are independent interfaces. Sharing code between them creates subtle coupling.

How to apply: If you find yourself wanting to call a function from one app layer to another, extract it to vdl/core/ instead.

Boundary 3: No yt-dlp in Duplicate Detection or Retry Logic

# ✓ ALLOWED
from vdl.core.download.duplicates import DuplicateDetectionService
from vdl.core.retry import retryable_failed_results

# ✗ FORBIDDEN
# from yt_dlp import YoutubeDL  (don't import in duplicates.py or retry.py)
# from vdl.core.download.downloader import YtDlpDownloader

Why: Duplicate detection and retry must work independently of the downloader. A future change to swap downloaders should not affect duplicate logic.

How to apply: Duplicate and retry logic lives at the library level. It examines results and filesystem state, not the downloader itself.

Boundary 4: Core Never Imports Apps

# ✓ ALLOWED
import vdl.core.download.downloader
from vdl.client import VDLClient

# ✗ FORBIDDEN
# from vdl.apps.cli.main import ...
# from vdl.apps.interactive.main import ...
# from vdl.apps.mcp.server import ...

Why: Core logic must be usable without CLI, interactive, or MCP layers present.

How to apply: If you're in vdl/core/ and feel like importing vdl.apps, refactor instead.

State Model Clarity

Three Wiring Modes

VDL has three distinct wiring configurations, each with different state guarantees:

1. Headless Library Mode (Default)

from vdl.runtime import build_library_components
from vdl import VDLClient

components = build_library_components()
client = VDLClient(...)  # stateless, no DB, no logs

State guarantees: - state_store=None (no SQLite) - logger=NullLogger() (no file logging) - Filesystem is the only source of truth - Safe to use in stateless environments (serverless, scripts, MCP)

How to apply: This is the default for CLI and MCP. Do not add state to this mode.

2. Interactive Mode (Stateful)

from vdl.runtime import build_interactive_components
from vdl import VDLClient

components = build_interactive_components()
client = VDLClient(...)  # stateful, SQLite DB, audit logs

State guarantees: - state_store=SQLiteStateStore() at ~/.local/state/vdl/vdl.db - logger=FileEventLogger() at ~/.local/state/vdl/logs/ - Tracks download history, status, retries - May be out of sync with filesystem (disk is source of truth)

How to apply: Only use in interactive mode. Do not wire stateful components into headless CLI or MCP.

3. Custom/Testing Mode

from vdl import VDLClient
from vdl.runtime import build_library_components

components = build_library_components()
# Swap any component
custom_client = VDLClient(
    config=components.config,
    downloader=FakeDownloader(...),
    state_store=InMemoryStateStore(...),
    logger=MyLogger(...),
)

How to apply: Tests use this. Don't hardcode wiring in production code.

Common Mistakes

Mistake 1: Putting Decisions in the Wrong Layer

# ✗ BAD: CLI owns the skip/retry decision
async def cli_download(url, quality):
    # Check if exists, then call client
    if file_exists(url):
        return "SKIPPED"
    result = await client.download(url, quality)
    return result

# ✓ GOOD: Core owns the decision, CLI just calls it
async def cli_download(url, quality):
    result = await client.download(url, quality)
    print(f"Status: {result.status}")

Why: If logic lives in CLI, MCP won't have it. The core must own all decisions.

Mistake 2: Treating State as Source of Truth

# ✗ BAD: Trust the DB completely
record = await state_store.get(url)
if record and record.status == "completed":
    return "SKIPPED"  # Wrong if file was deleted

# ✓ GOOD: Verify disk, use DB as a hint
record = await state_store.get(url)
if record:
    # But still check the filesystem
    decision = await duplicate_detection.check(request)
    if decision.action == "skip":
        return "SKIPPED"

Why: Filesystem is source of truth. DB can be stale. See RETRY_AND_DUPLICATES.md.

Mistake 3: Coupling to Interactive State in Headless Code

# ✗ BAD: CLI uses interactive mode wiring
from vdl.runtime import build_interactive_components
components = build_interactive_components()  # Wrong for CLI

# ✓ GOOD: Headless CLI uses library wiring
from vdl.runtime import build_library_components
components = build_library_components()

Why: Headless CLI must not require SQLite or ~/.local/state/. Use stateless mode.

Mistake 4: Modifying Request After Sending to Downloader

# ✗ BAD: Change request during download
request.output_path = new_path  # Don't mutate
await downloader.download(request)

# ✓ GOOD: Build the right request before downloading
request = factory.create(url, output_dir=final_dir)
await downloader.download(request)

Why: The downloader assumes the request is immutable. Mutation during execution causes race conditions and lost updates.

Mistake 5: Catching VDLCancelled in User Code

# ✗ BAD: Catch VDLCancelled
try:
    result = await client.download(url)
except VDLCancelled:  # Don't do this
    pass

# ✓ GOOD: Check the result status
result = await client.download(url)
if result.status == DownloadStatus.CANCELLED:
    print("User cancelled")

Why: VDLCancelled is an internal exception. Use result status instead.

Key Data Structures

DownloadResult (Always Present)

result = await client.download(url)

# Always available:
result.status          # COMPLETED, SKIPPED, FAILED, CANCELLED
result.output_path     # Path to file (if completed)
result.error           # Error message (if failed)
result.metadata        # Dict with title, duration, size, etc.

When status is: - COMPLETED: output_path is set, error is None - SKIPPED: output_path is set (already present), error is None - FAILED: output_path is None, error is set - CANCELLED: output_path may be partial, error is None

AcquisitionPlan (For Batch Operations)

plan = await client.plan(input)

plan.kind              # URL, PLAYLIST, FILE, LINK_FILE, etc.
plan.items             # List of AcquisitionItem
plan.title             # Human-readable title

Each item:

item.url               # Original URL
item.title             # Expected media title
item.quality           # User-selected quality
item.metadata          # Pre-fetched info (if available)

DuplicateDecision (Skip/Continue Logic)

decision = await duplicate_service.check(request)

decision.action        # "skip", "continue", "fail"
decision.reason        # Why (e.g., "already_present")
decision.target_path   # Where the file would go

When action is: - skip: File/folder exists, honors the if_exists policy - continue: No conflict, safe to proceed - fail: if_exists=FAIL and file exists

Important Invariants

Invariant 1: Planner Does Not Call Network

plan = await client.plan(url)  # Never makes network calls

Exception: plan_with_probe() does call the network to inspect unknown URLs.

Invariant 2: Completed Media Must Exist on Disk

if result.status == DownloadStatus.COMPLETED:
    assert result.output_path.exists()  # Must be true

Invariant 3: Sidecars Are Not Media

Files like .part, .vtt, .jpg, .json are not counted as "downloaded media". A folder with only sidecars is treated as empty.

Invariant 4: Duplicate Detection Is Stateless

decision = await service.check(request)  # Same decision every time
# (unless filesystem changes between calls)

It does not modify state, does not call the network, and does not affect future checks.

When to Use Each Service

Service Use When Lives In
InputPlanner Classifying raw input (URL, file, list) vdl/core/acquisition/
PlaylistService Resolving playlist items vdl/client.py
DiscoveryService Crawling a page for media vdl/client.py
DownloadExecutionService Executing download requests vdl/client.py
DuplicateDetectionService Preflight duplicate checks vdl/core/download/
RequestFactory Building download requests vdl/core/acquisition/
Doctor Health checks vdl/client.py

Testing Principles

Test in Isolation

# ✓ GOOD: Mock dependencies
def test_duplicate_detection():
    service = DuplicateDetectionService(
        config=mock_config,
        state_store=InMemoryStateStore(),
        logger=NullLogger(),
    )
    # Test the service in isolation

Test Against Real Filesystem

# ✓ GOOD: Use real temp filesystem
def test_download_with_file_collision(tmp_path):
    # Create a dummy file
    (tmp_path / "existing.mp4").touch()
    # Test if duplicate detection finds it

Redirect HOME for All Tests

All tests should redirect HOME to tmp_path to avoid touching user config:

@pytest.fixture
def isolated_home(tmp_path, monkeypatch):
    monkeypatch.setenv("HOME", str(tmp_path))
    return tmp_path

Release and Stability

What Must Not Change Without Bump

  • Public API function signatures
  • Enum values
  • Exception hierarchy
  • Config file format

What Can Change With Minor Version Bump

  • Internal service implementations
  • Private module functions
  • Command-line flags (may be added or renamed with notice)

What Requires Major Version Bump

  • Public API removal
  • Core workflow changes (e.g., Planner behavior)
  • Incompatible config migrations

See Also