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:
- PUBLIC_API.md — what you can import, what you cannot
- ARCHITECTURE.md — module layout and service roles
- 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¶
- PUBLIC_API.md — what's safe to import
- ARCHITECTURE.md — module structure
- RETRY_AND_DUPLICATES.md — subtle semantics
- EXAMPLES.md — working code samples