Skip to main content

Overview

OfflineTube’s Download Manager provides complete control over your downloads with real-time progress tracking, quality selection, and format options. All downloads run in the background using yt-dlp and FFmpeg.

Starting a Download

From the Explorer

  1. Search for a video in the Explorer tab
  2. Click on a video to see its details
  3. Choose your preferred quality and format
  4. Click Descargar Video or Descargar Audio

Quality Options

OfflineTube shows all available formats for each video: Video formats:
  • Resolution options from 240p to 4K (2160p)
  • Codec information (h264, av1, vp9)
  • File size estimates
  • Automatically merges best audio with selected video quality
Audio formats:
  • Bitrate options (48kbps to 256kbps+)
  • Codec details (opus, m4a, mp3)
  • Audio-only extraction for music
Video downloads automatically include the best available audio track using format selector: {format_id}+bestaudio/best

Download API

Starting a Download

// From page.tsx:192-221
const handleExplorerDownload = async (
  url: string,
  options: Omit<DownloadOptions, 'url' | 'subtitle_langs' | 'download_thumbnail'>
) => {
  const response = await fetch(`${API_URL}/api/download`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      url,
      quality: options.quality,
      format_id: options.format_id,
      download_type: options.download_type,
      subtitle_langs: [],
      download_thumbnail: false,
    }),
  });
  
  if (response.ok) {
    toast.success('Descarga iniciada');
    setActiveTab('downloads');
    fetchDownloads(true);
  }
}

Backend Download Endpoint

# From main.py:435-446
@app.post("/api/download")
async def start_download(request: DownloadRequest, background_tasks: BackgroundTasks):
    download_id = str(uuid.uuid4())
    downloads[download_id] = DownloadTask(
        id=download_id,
        url=request.url,
        quality=request.quality,
        download_type=request.download_type,
        format_id=request.format_id or "",
    )
    background_tasks.add_task(download_video, download_id)
    return {"download_id": download_id, "id": download_id}

Real-Time Progress Tracking

Download Statuses

Downloads progress through several states:
  • Pending: Queued, waiting to start
  • Downloading: Actively fetching video data
  • Processing: Merging video/audio, converting format
  • Completed: Ready to play
  • Error: Failed (with retry option)
  • Cancelled: Manually stopped by user

Progress Updates

The app polls the backend every 5 seconds for updates:
// From page.tsx:108-112
useEffect(() => {
  fetchDownloads();
  const timer = setInterval(() => fetchDownloads(true), 5000);
  return () => clearInterval(timer);
}, [fetchDownloads]);

Progress Hook (Backend)

# From main.py:271-292
def progress_hook(d):
    if d["status"] == "downloading":
        total = d.get("total_bytes") or d.get("total_bytes_estimate") or 0
        downloaded = d.get("downloaded_bytes", 0)
        task.downloaded_bytes = downloaded
        task.filesize = total
        if total:
            task.progress = round(downloaded / total * 100, 2)
        raw_speed = d.get("speed")
        task.speed = (
            f"{raw_speed / 1024 / 1024:.1f} MB/s"
            if raw_speed and raw_speed > 1_048_576
            else f"{raw_speed / 1024:.0f} KB/s"
            if raw_speed
            else ""
        )
        eta = d.get("eta")
        task.eta = f"{eta}s" if eta else ""
    elif d["status"] == "finished":
        task.status = DownloadStatus.PROCESSING
        task.progress = 99

Download Manager UI

Filtering Downloads

The Downloads tab provides filter buttons:
// From DownloadManager.tsx:72-77
const filteredDownloads = downloads.filter(d => {
  if (filter === 'active') return ['pending', 'downloading', 'processing'].includes(d.status);
  if (filter === 'completed') return d.status === 'completed';
  if (filter === 'error') return d.status === 'error';
  return true;
});

Progress Visualization

  • Circular progress for active downloads (MiniCircularProgress component)
  • Linear progress bar with downloaded/total bytes
  • Speed and ETA display during download
  • Large circular detail view when selecting a download

Download Actions

Cancel Download

// From page.tsx:223-231
const handleCancelDownload = async (id: string) => {
  await fetch(`${API_URL}/api/downloads/${id}`, { method: 'DELETE' });
  toast.info('Descarga cancelada');
  fetchDownloads(true);
}

Remove Download

// From page.tsx:233-242
const handleRemoveDownload = async (id: string) => {
  await fetch(`${API_URL}/api/downloads/${id}/remove`, { method: 'DELETE' });
  setDownloads((prev) => prev.filter((d) => d.id !== id));
  toast.info('Descarga eliminada');
}

Retry Failed Download

// From page.tsx:244-251
const handleRetryDownload = async (id: string) => {
  const download = downloads.find((d) => d.id === id);
  if (download) {
    await handleRemoveDownload(id);
    handleStartDownload(download.url, download.quality || '720p');
  }
}

Download to Disk

// From page.tsx:253-255
const handleDownloadFile = (id: string) => {
  window.open(`${API_URL}/api/download/${id}/file`, '_blank');
}

yt-dlp Configuration

Format Selection

# From main.py:256-269
def get_ydl_opts(task: DownloadTask) -> dict:
    if task.download_type == "audio":
        if task.format_id:
            format_selector = task.format_id
        else:
            format_selector = "bestaudio/best"
    else:
        if task.format_id:
            # merge selected video stream with best audio
            format_selector = f"{task.format_id}+bestaudio/best"
        else:
            height = int(task.quality.replace("p", "")) if task.quality else 720
            format_selector = (
                f"bv*[height<={height}]+ba/best[height<={height}]/best"
            )

Post-Processing

# From main.py:302-307
"postprocessors": [
    {"key": "FFmpegMerger"},
    {"key": "FFmpegVideoConvertor", "preferedformat": "mp4"},
    {"key": "EmbedThumbnail", "already_have_thumbnail": False},
]
This ensures:
  • Video and audio streams are merged
  • Output is always MP4 (browser-compatible)
  • Thumbnails are embedded in the file

Configuration Options

Set default preferences in the Settings tab:

Default Quality

// From page.tsx:54-57
const [defaultQuality, setDefaultQuality] = useState<string>(() => {
  if (typeof window !== 'undefined') return localStorage.getItem('yt-default-quality') || '720p';
  return '720p';
});

Default Download Type

// From page.tsx:58-61
const [defaultDownloadType, setDefaultDownloadType] = useState<'video' | 'audio'>(() => {
  if (typeof window !== 'undefined') return (localStorage.getItem('yt-default-type') as 'video' | 'audio') || 'video';
  return 'video';
});

Tips and Best Practices

Choose the right quality: Higher quality means larger files. 720p is optimal for most devices.
Audio extraction: Select “Audio” mode in the Explorer to download music without video, saving significant disk space.
Downloads require FFmpeg to be installed on the backend server. The app will show an error if FFmpeg is missing (main.py:319).

Playlist Support

OfflineTube automatically detects playlists:
# From main.py:333-334
task.is_playlist = info.get("_type") == "playlist"
task.playlist_total = len(info.get("entries", [])) if task.is_playlist else 1
When downloading a playlist, each video is queued as a separate download task.

Error Recovery

The backend includes intelligent error recovery:
# From main.py:367-395
except Exception as e:
    # yt-dlp sometimes raises cleanup errors even though
    # the output .mp4 was merged successfully.
    # Check if the file actually landed on disk before marking as error.
    recovered = False
    if not task.filename:
        for f in DOWNLOAD_DIR.glob(f"*__{task.id}.*"):
            if f.suffix in [".mp4", ".mkv", ".webm", ".m4a", ".mp3", ".opus"]:
                task.filename = f.name
                break
    if task.filename and (DOWNLOAD_DIR / task.filename).exists():
        # Recover from false error
        task.status = DownloadStatus.COMPLETED
        task.progress = 100
        recovered = True
    if not recovered:
        task.status = DownloadStatus.ERROR
        task.error_message = str(e)