initial commit

This commit is contained in:
2025-12-21 11:43:22 -06:00
parent 1f7eb01afb
commit d28de69bcb
28 changed files with 4067 additions and 108 deletions

4
.gitignore vendored
View File

@@ -39,3 +39,7 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# downloads
/downloads
/public/downloads

View File

@@ -1,36 +1,66 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# Downlink - YouTube Downloader
## Getting Started
A sleek Next.js application for downloading YouTube videos and audio in various formats.
First, run the development server:
## Features
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
- 🎥 Download YouTube videos in multiple formats (MP4, WebM, MKV, AVI)
- 🎵 Extract audio in various formats (MP3, WAV, M4A, Opus)
- 🎨 Modern UI built with shadcn/ui and Tailwind CSS
- ⚡ Server-side processing with yt-dlp
- 🔄 Format transcoding support
## Quick Start
1. **Install dependencies**
```bash
npm install
```
2. **Install system dependencies**
```bash
# macOS
brew install yt-dlp ffmpeg
# Linux
pip3 install yt-dlp
sudo apt-get install ffmpeg
```
3. **Run development server**
```bash
npm run dev
```
4. **Open browser**
Navigate to `http://localhost:3000`
## Documentation
- [Setup Guide](./docs/setup.md) - Installation and configuration
- [YouTube Downloader](./docs/youtube-downloader.md) - Download feature documentation
- [Transcoding](./docs/transcoding.md) - Format conversion documentation
- [Agent Instructions](./docs/AGENTS.md) - Guidelines for contributors
## Tech Stack
- **Framework**: Next.js 16 (App Router)
- **UI**: shadcn/ui + Tailwind CSS
- **TypeScript**: Full type safety
- **Download Engine**: yt-dlp
- **Transcoding**: ffmpeg (via yt-dlp)
## Project Structure
```
Downlink/
├── app/ # Next.js app directory
├── components/ # React components
├── lib/ # Utilities and types
├── docs/ # Documentation
└── public/ # Static assets
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
## License
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
MIT

39
app/api/download/route.ts Normal file
View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import { downloadVideo } from '@/lib/youtube';
import { isValidYouTubeUrl } from '@/lib/utils';
import type { DownloadRequest } from '@/lib/types';
export async function POST(request: NextRequest) {
try {
const body: DownloadRequest = await request.json();
if (!body.url || !body.format || !body.formatType) {
return NextResponse.json(
{ success: false, error: 'Missing required fields' },
{ status: 400 }
);
}
// Validate YouTube URL
if (!isValidYouTubeUrl(body.url)) {
return NextResponse.json(
{ success: false, error: 'Invalid YouTube URL' },
{ status: 400 }
);
}
const result = await downloadVideo(body);
if (!result.success) {
return NextResponse.json(result, { status: 500 });
}
return NextResponse.json(result);
} catch (error: any) {
console.error('API error:', error);
return NextResponse.json(
{ success: false, error: error.message || 'Internal server error' },
{ status: 500 }
);
}
}

42
app/api/info/route.ts Normal file
View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
import { getVideoInfo } from '@/lib/youtube';
import { isValidYouTubeUrl } from '@/lib/utils';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { url } = body;
if (!url) {
return NextResponse.json(
{ success: false, error: 'URL is required' },
{ status: 400 }
);
}
// Validate YouTube URL
if (!isValidYouTubeUrl(url)) {
return NextResponse.json(
{ success: false, error: 'Invalid YouTube URL' },
{ status: 400 }
);
}
const videoInfo = await getVideoInfo(url);
if (!videoInfo) {
return NextResponse.json(
{ success: false, error: 'Failed to get video info' },
{ status: 500 }
);
}
return NextResponse.json({ success: true, videoInfo });
} catch (error: any) {
console.error('API error:', error);
return NextResponse.json(
{ success: false, error: error.message || 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -1,26 +1,353 @@
@import "tailwindcss";
@import "tw-animate-css";
:root {
--background: #ffffff;
--foreground: #171717;
}
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
:root {
--radius: 0.75rem;
--background: oklch(0.98 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0 / 0.8);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.6 0.25 280);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.6 0.25 280);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.06 0.015 280);
--foreground: oklch(0.95 0.01 280);
--card: oklch(0.12 0.02 280 / 0.6);
--card-foreground: oklch(0.95 0.01 280);
--popover: oklch(0.12 0.02 280 / 0.95);
--popover-foreground: oklch(0.95 0.01 280);
--primary: oklch(0.72 0.22 280);
--primary-foreground: oklch(0.98 0 0);
--secondary: oklch(0.16 0.03 280);
--secondary-foreground: oklch(0.95 0.01 280);
--muted: oklch(0.14 0.02 280);
--muted-foreground: oklch(0.60 0.03 280);
--accent: oklch(0.18 0.04 300);
--accent-foreground: oklch(0.95 0.01 280);
--destructive: oklch(0.60 0.22 25);
--border: oklch(0.25 0.04 280 / 0.4);
--input: oklch(0.14 0.02 280);
--ring: oklch(0.72 0.22 280);
--chart-1: oklch(0.72 0.22 280);
--chart-2: oklch(0.68 0.18 320);
--chart-3: oklch(0.62 0.20 200);
--chart-4: oklch(0.65 0.16 350);
--chart-5: oklch(0.70 0.18 40);
--sidebar: oklch(0.10 0.015 280);
--sidebar-foreground: oklch(0.95 0.01 280);
--sidebar-primary: oklch(0.72 0.22 280);
--sidebar-primary-foreground: oklch(0.98 0 0);
--sidebar-accent: oklch(0.14 0.02 280);
--sidebar-accent-foreground: oklch(0.95 0.01 280);
--sidebar-border: oklch(0.20 0.03 280 / 0.3);
--sidebar-ring: oklch(0.72 0.22 280);
}
/* Keyframe Animations */
@keyframes gradient-shift {
0%, 100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
@keyframes float {
0%, 100% {
transform: translateY(0) rotate(0deg);
}
50% {
transform: translateY(-20px) rotate(2deg);
}
}
@keyframes pulse-glow {
0%, 100% {
opacity: 0.4;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(1.05);
}
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
min-height: 100vh;
overflow-x: hidden;
}
}
@layer components {
/* Animated gradient background */
.animated-bg {
position: fixed;
inset: 0;
z-index: -1;
background:
radial-gradient(ellipse 80% 50% at 20% 40%, oklch(0.25 0.15 280 / 0.3) 0%, transparent 50%),
radial-gradient(ellipse 60% 40% at 80% 60%, oklch(0.20 0.12 320 / 0.25) 0%, transparent 50%),
radial-gradient(ellipse 50% 30% at 50% 90%, oklch(0.18 0.10 260 / 0.2) 0%, transparent 50%);
animation: pulse-glow 8s ease-in-out infinite;
}
/* Floating orbs */
.floating-orb {
position: absolute;
border-radius: 50%;
filter: blur(60px);
animation: float 12s ease-in-out infinite;
pointer-events: none;
}
.floating-orb-1 {
width: 400px;
height: 400px;
background: oklch(0.50 0.20 280 / 0.15);
top: -100px;
left: -100px;
animation-delay: 0s;
}
.floating-orb-2 {
width: 350px;
height: 350px;
background: oklch(0.45 0.18 320 / 0.12);
bottom: -50px;
right: -50px;
animation-delay: -4s;
}
.floating-orb-3 {
width: 250px;
height: 250px;
background: oklch(0.55 0.15 260 / 0.1);
top: 40%;
right: 20%;
animation-delay: -8s;
}
/* Glass card effect */
.glass-card {
background: oklch(0.12 0.02 280 / 0.4);
backdrop-filter: blur(20px) saturate(1.5);
-webkit-backdrop-filter: blur(20px) saturate(1.5);
border: 1px solid oklch(0.40 0.08 280 / 0.15);
box-shadow:
0 8px 32px oklch(0 0 0 / 0.3),
inset 0 1px 0 oklch(1 0 0 / 0.05);
}
.glass-card:hover {
border-color: oklch(0.55 0.15 280 / 0.3);
box-shadow:
0 12px 40px oklch(0.50 0.20 280 / 0.15),
inset 0 1px 0 oklch(1 0 0 / 0.08);
}
/* Gradient text */
.gradient-text {
background: linear-gradient(135deg,
oklch(0.85 0.18 280) 0%,
oklch(0.75 0.22 300) 50%,
oklch(0.70 0.20 260) 100%
);
background-size: 200% 200%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: gradient-shift 6s ease infinite;
}
/* Shimmer effect for loading */
.shimmer {
background: linear-gradient(
90deg,
oklch(0.20 0.03 280 / 0) 0%,
oklch(0.30 0.05 280 / 0.3) 50%,
oklch(0.20 0.03 280 / 0) 100%
);
background-size: 200% 100%;
animation: shimmer 2s ease-in-out infinite;
}
/* Glow button */
.glow-button {
position: relative;
background: linear-gradient(135deg, oklch(0.65 0.22 280), oklch(0.55 0.20 300));
box-shadow:
0 4px 20px oklch(0.50 0.20 280 / 0.4),
inset 0 1px 0 oklch(1 0 0 / 0.15);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.glow-button:hover {
transform: translateY(-2px);
box-shadow:
0 8px 30px oklch(0.55 0.22 280 / 0.5),
inset 0 1px 0 oklch(1 0 0 / 0.2);
}
.glow-button:active {
transform: translateY(0);
}
/* Slide up animation for cards */
.slide-up {
animation: slide-up 0.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.slide-up-delay-1 { animation-delay: 0.1s; opacity: 0; }
.slide-up-delay-2 { animation-delay: 0.2s; opacity: 0; }
.slide-up-delay-3 { animation-delay: 0.3s; opacity: 0; }
/* Input glow */
.input-glow {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.input-glow:focus {
box-shadow:
0 0 0 3px oklch(0.60 0.20 280 / 0.2),
0 4px 20px oklch(0.50 0.18 280 / 0.15);
}
/* Thumbnail hover effect */
.thumbnail-container {
position: relative;
overflow: hidden;
}
.thumbnail-container::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
to top,
oklch(0 0 0 / 0.6) 0%,
oklch(0 0 0 / 0) 50%
);
opacity: 0;
transition: opacity 0.3s ease;
}
.thumbnail-container:hover::after {
opacity: 1;
}
.thumbnail-container img {
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1);
}
.thumbnail-container:hover img {
transform: scale(1.05);
}
/* Success card glow */
.success-glow {
background: oklch(0.15 0.08 150 / 0.3);
border-color: oklch(0.60 0.20 150 / 0.4);
box-shadow:
0 8px 32px oklch(0.50 0.18 150 / 0.15),
inset 0 1px 0 oklch(0.70 0.15 150 / 0.1);
}
}

View File

@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Downlink - YouTube Downloader",
description: "Download YouTube videos and audio in your preferred format",
};
export default function RootLayout({
@@ -23,7 +23,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" className="dark">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>

View File

@@ -1,65 +1,335 @@
import Image from "next/image";
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Download, Loader2, Play, Music, Sparkles, Zap, Check } from 'lucide-react';
import { isValidYouTubeUrl } from '@/lib/utils';
import type { VideoInfo, DownloadResponse, VideoFormat, AudioFormat } from '@/lib/types';
export default function Home() {
const [url, setUrl] = useState('');
const [loading, setLoading] = useState(false);
const [videoInfo, setVideoInfo] = useState<VideoInfo | null>(null);
const [videoFormat, setVideoFormat] = useState<VideoFormat>('mp4');
const [audioFormat, setAudioFormat] = useState<AudioFormat>('mp3');
const [formatType, setFormatType] = useState<'video' | 'audio'>('video');
const [downloadResult, setDownloadResult] = useState<DownloadResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const handleGetInfo = async () => {
if (!url.trim()) {
setError('Please enter a YouTube URL');
return;
}
if (!isValidYouTubeUrl(url)) {
setError('Please enter a valid YouTube URL');
return;
}
setLoading(true);
setError(null);
setVideoInfo(null);
setDownloadResult(null);
try {
const response = await fetch('/api/info', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
const data = await response.json();
if (data.success && data.videoInfo) {
setVideoInfo(data.videoInfo);
} else {
setError(data.error || 'Failed to get video information');
}
} catch (err: any) {
setError(err.message || 'Failed to fetch video info');
} finally {
setLoading(false);
}
};
const handleDownload = async () => {
if (!url.trim()) {
setError('Please enter a YouTube URL');
return;
}
if (!isValidYouTubeUrl(url)) {
setError('Please enter a valid YouTube URL');
return;
}
setLoading(true);
setError(null);
setDownloadResult(null);
try {
const response = await fetch('/api/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url,
format: formatType === 'video' ? videoFormat : audioFormat,
formatType,
}),
});
const data: DownloadResponse = await response.json();
if (data.success) {
setDownloadResult(data);
if (data.videoInfo) {
setVideoInfo(data.videoInfo);
}
} else {
setError(data.error || 'Failed to download video');
}
} catch (err: any) {
setError(err.message || 'Failed to download video');
} finally {
setLoading(false);
}
};
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
<div className="min-h-screen relative">
{/* Animated background */}
<div className="animated-bg" />
<div className="floating-orb floating-orb-1" />
<div className="floating-orb floating-orb-2" />
<div className="floating-orb floating-orb-3" />
<div className="container mx-auto px-4 py-12 md:py-20 max-w-2xl relative z-10">
{/* Header */}
<div className="text-center mb-10 md:mb-14 slide-up">
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-primary/10 border border-primary/20 text-primary text-sm font-medium mb-6">
<Sparkles className="w-4 h-4" />
Fast & Free Downloads
</div>
<h1 className="text-4xl md:text-6xl font-bold mb-4 gradient-text tracking-tight">
Downlink
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
<p className="text-muted-foreground text-base md:text-lg max-w-md mx-auto">
Download YouTube videos and audio in any format. No limits, no ads, just downloads.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
{/* URL Input Card */}
<Card className="mb-6 glass-card slide-up slide-up-delay-1 border-0">
<CardContent className="pt-6 pb-6">
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1 relative">
<Input
type="url"
placeholder="Paste YouTube URL here..."
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !loading) {
handleGetInfo();
}
}}
className="h-12 px-4 text-base bg-background/50 border-white/10 input-glow rounded-xl placeholder:text-muted-foreground/50"
/>
</div>
<Button
onClick={handleGetInfo}
disabled={loading}
className="h-12 px-6 glow-button text-white border-0 rounded-xl font-medium"
>
{loading ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<>
<Zap className="h-4 w-4 mr-2" />
Fetch
</>
)}
</Button>
</div>
</CardContent>
</Card>
{/* Error Display */}
{error && (
<Card className="mb-6 slide-up border-destructive/30 bg-destructive/10 backdrop-blur-xl">
<CardContent className="py-4">
<p className="text-destructive text-sm font-medium text-center">{error}</p>
</CardContent>
</Card>
)}
{/* Video Info Card */}
{videoInfo && (
<Card className="mb-6 glass-card slide-up slide-up-delay-2 border-0 overflow-hidden">
<CardContent className="p-0">
{videoInfo.thumbnail && (
<div className="thumbnail-container aspect-video w-full bg-muted">
<img
src={videoInfo.thumbnail}
alt={videoInfo.title}
className="w-full h-full object-cover"
/>
</div>
)}
<div className="p-5">
<h3 className="font-semibold text-base md:text-lg mb-2 line-clamp-2 leading-snug">
{videoInfo.title}
</h3>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-primary/10 text-primary text-xs font-medium">
<Play className="w-3 h-3" />
{formatDuration(videoInfo.duration)}
</span>
</div>
</div>
</CardContent>
</Card>
)}
{/* Download Options Card */}
{videoInfo && (
<Card className="mb-6 glass-card slide-up slide-up-delay-3 border-0">
<CardHeader className="pb-4">
<CardTitle className="text-lg font-semibold">Download Options</CardTitle>
<CardDescription>
Choose your format and quality
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{/* Format Type Toggle */}
<div className="grid grid-cols-2 gap-2 p-1 bg-background/30 rounded-xl">
<button
onClick={() => setFormatType('video')}
className={`flex items-center justify-center gap-2 py-2.5 px-4 rounded-lg text-sm font-medium transition-all ${
formatType === 'video'
? 'bg-primary text-white shadow-lg shadow-primary/25'
: 'text-muted-foreground hover:text-foreground hover:bg-white/5'
}`}
>
<Play className="h-4 w-4" />
Video
</button>
<button
onClick={() => setFormatType('audio')}
className={`flex items-center justify-center gap-2 py-2.5 px-4 rounded-lg text-sm font-medium transition-all ${
formatType === 'audio'
? 'bg-primary text-white shadow-lg shadow-primary/25'
: 'text-muted-foreground hover:text-foreground hover:bg-white/5'
}`}
>
<Music className="h-4 w-4" />
Audio
</button>
</div>
{/* Format Selection */}
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">
{formatType === 'video' ? 'Video Format' : 'Audio Format'}
</Label>
{formatType === 'video' ? (
<Select
value={videoFormat}
onValueChange={(value: VideoFormat) => setVideoFormat(value)}
>
<SelectTrigger className="h-11 bg-background/50 border-white/10 rounded-xl">
<SelectValue />
</SelectTrigger>
<SelectContent className="glass-card border-white/10">
<SelectItem value="mp4">MP4 (Recommended)</SelectItem>
<SelectItem value="webm">WebM</SelectItem>
<SelectItem value="mkv">MKV</SelectItem>
<SelectItem value="avi">AVI</SelectItem>
</SelectContent>
</Select>
) : (
<Select
value={audioFormat}
onValueChange={(value: AudioFormat) => setAudioFormat(value)}
>
<SelectTrigger className="h-11 bg-background/50 border-white/10 rounded-xl">
<SelectValue />
</SelectTrigger>
<SelectContent className="glass-card border-white/10">
<SelectItem value="mp3">MP3 (Recommended)</SelectItem>
<SelectItem value="m4a">M4A</SelectItem>
<SelectItem value="wav">WAV</SelectItem>
<SelectItem value="opus">Opus</SelectItem>
</SelectContent>
</Select>
)}
</div>
{/* Download Button */}
<Button
onClick={handleDownload}
disabled={loading}
className="w-full h-12 glow-button text-white border-0 rounded-xl font-medium text-base"
>
{loading ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Processing...
</>
) : (
<>
<Download className="mr-2 h-5 w-5" />
Download {formatType === 'video' ? videoFormat.toUpperCase() : audioFormat.toUpperCase()}
</>
)}
</Button>
</CardContent>
</Card>
)}
{/* Success Card */}
{downloadResult?.success && downloadResult.downloadUrl && (
<Card className="slide-up success-glow backdrop-blur-xl border-0">
<CardContent className="py-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-green-500/20 flex items-center justify-center">
<Check className="w-5 h-5 text-green-400" />
</div>
<div>
<h3 className="font-semibold text-green-400">Ready to Download</h3>
<p className="text-sm text-muted-foreground">Your file has been prepared</p>
</div>
</div>
<a
href={downloadResult.downloadUrl}
download={downloadResult.filename}
className="block"
>
<Button className="w-full h-12 bg-green-600 hover:bg-green-500 text-white border-0 rounded-xl font-medium text-base shadow-lg shadow-green-600/25 transition-all hover:shadow-green-500/30 hover:-translate-y-0.5">
<Download className="mr-2 h-5 w-5" />
Save File
</Button>
</a>
</CardContent>
</Card>
)}
{/* Footer */}
<div className="text-center mt-12 text-sm text-muted-foreground/60">
<p>Free and open source. No tracking, no ads.</p>
</div>
</main>
</div>
</div>
);
}

22
components.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

62
components/ui/button.tsx Normal file
View File

@@ -0,0 +1,62 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-lg shadow-primary/25",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 shadow-lg shadow-destructive/25",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 hover:border-primary/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-md",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

92
components/ui/card.tsx Normal file
View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-2xl border py-6 shadow-xl transition-all duration-300",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

21
components/ui/input.tsx Normal file
View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-white/10 h-9 w-full min-w-0 rounded-xl border bg-background/50 px-4 py-1 text-base shadow-sm transition-all duration-300 outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-primary/40 focus-visible:ring-primary/20 focus-visible:ring-[3px] focus-visible:shadow-lg focus-visible:shadow-primary/10 focus-visible:bg-background/70",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

24
components/ui/label.tsx Normal file
View File

@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

190
components/ui/select.tsx Normal file
View File

@@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-white/10 data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-primary/40 focus-visible:ring-primary/20 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-background/50 dark:hover:bg-background/70 flex w-full items-center justify-between gap-2 rounded-xl border bg-transparent px-4 py-2 text-sm whitespace-nowrap shadow-sm transition-all duration-300 outline-none focus-visible:ring-[3px] focus-visible:shadow-lg focus-visible:shadow-primary/10 disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover/95 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-xl border border-white/10 shadow-2xl shadow-black/40 backdrop-blur-xl",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-primary/20 focus:text-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-lg py-2 pr-8 pl-3 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2 transition-all duration-200 hover:bg-white/5",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

58
docs/AGENTS.md Normal file
View File

@@ -0,0 +1,58 @@
# Agent Instructions
## Documentation Guidelines
When adding new features or making significant changes to this project, please follow these documentation guidelines:
### 1. Feature Documentation
- **Always document new features** in markdown files within the `docs/` directory
- Use descriptive filenames that match the feature (e.g., `youtube-downloader.md`, `transcoding.md`)
- Include the following sections in feature documentation:
- **Overview**: What the feature does
- **Usage**: How to use the feature
- **Technical Details**: Implementation details, APIs used, dependencies
- **Configuration**: Any configuration options or environment variables
- **Examples**: Code examples or usage examples
### 2. Documentation Structure
- Keep documentation files focused on a single feature or topic
- Use clear headings and formatting
- Include code examples where relevant
- Link between related documentation files when appropriate
### 3. When to Document
- Adding a new feature
- Adding a new API endpoint
- Adding a new configuration option
- Making significant changes to existing features
- Adding new dependencies or tools
### 4. File Naming Convention
- Use kebab-case for filenames (e.g., `youtube-downloader.md`)
- Be descriptive but concise
- Group related features in the same file when appropriate
### 5. Update Existing Documentation
- When modifying existing features, update the relevant documentation file
- If a feature is removed, mark it as deprecated or remove the documentation
## Example Documentation Template
```markdown
# Feature Name
## Overview
Brief description of what this feature does.
## Usage
How to use this feature.
## Technical Details
Implementation details, APIs, dependencies.
## Configuration
Any configuration options.
## Examples
Code or usage examples.
```

84
docs/README.md Normal file
View File

@@ -0,0 +1,84 @@
# Documentation Index
Welcome to the Downlink documentation! This directory contains comprehensive documentation for the YouTube downloader application.
## Documentation Files
### Getting Started
- **[Setup Guide](./setup.md)** - Installation, configuration, and troubleshooting
- **[README.md](../README.md)** - Project overview and quick start
### Feature Documentation
- **[YouTube Downloader](./youtube-downloader.md)** - Core download feature documentation
- **[Transcoding](./transcoding.md)** - Format conversion and transcoding details
- **[Features](./features.md)** - Complete feature list and roadmap
### Technical Documentation
- **[API Documentation](./api.md)** - API endpoints, requests, and responses
- **[Architecture](./architecture.md)** - System architecture and design decisions
- **[Configuration](./configuration.md)** - Environment variables and configuration options
### Contributing
- **[Agent Instructions](./AGENTS.md)** - Guidelines for documenting new features
## Quick Links
### For Users
- Start with the [Setup Guide](./setup.md)
- Learn about features in [Features](./features.md)
- Check [YouTube Downloader](./youtube-downloader.md) for usage details
### For Developers
- Read [Architecture](./architecture.md) for system overview
- Check [API Documentation](./api.md) for endpoint details
- Follow [Agent Instructions](./AGENTS.md) when adding features
### For Contributors
- Follow [Agent Instructions](./AGENTS.md) for documentation standards
- Update relevant docs when adding/modifying features
- Keep documentation in sync with code changes
## Documentation Standards
When adding or updating documentation:
1. **Use clear headings** - Organize content with proper markdown headings
2. **Include examples** - Provide code examples and usage scenarios
3. **Keep it updated** - Update docs when features change
4. **Be comprehensive** - Cover overview, usage, technical details, and examples
5. **Link related docs** - Cross-reference related documentation
## Document Structure Template
Each feature document should include:
```markdown
# Feature Name
## Overview
Brief description
## Usage
How to use
## Technical Details
Implementation details
## Configuration
Settings and options
## Examples
Code examples
## Limitations
Known limitations
## Future Improvements
Planned enhancements
```
## Need Help?
- Check the [Setup Guide](./setup.md) for installation issues
- Review [Architecture](./architecture.md) for system understanding
- See [API Documentation](./api.md) for endpoint details

197
docs/api.md Normal file
View File

@@ -0,0 +1,197 @@
# API Documentation
## Overview
The Downlink API provides endpoints for fetching YouTube video information and downloading videos/audio in various formats.
## Base URL
- Development: `http://localhost:3000`
- Production: `https://your-domain.com`
## Endpoints
### Get Video Information
Retrieve metadata about a YouTube video without downloading it.
**Endpoint**: `POST /api/info`
**Request Body**:
```json
{
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
}
```
**Response** (Success):
```json
{
"success": true,
"videoInfo": {
"title": "Video Title",
"duration": 120,
"thumbnail": "https://i.ytimg.com/vi/.../maxresdefault.jpg",
"formats": [
{
"formatId": "137",
"ext": "mp4",
"resolution": "1920x1080",
"filesize": 50000000,
"formatNote": "1080p"
}
]
}
}
```
**Response** (Error):
```json
{
"success": false,
"error": "Invalid YouTube URL"
}
```
**Status Codes**:
- `200`: Success
- `400`: Bad Request (invalid URL or missing fields)
- `500`: Internal Server Error
---
### Download Video/Audio
Download a YouTube video or extract audio in the specified format.
**Endpoint**: `POST /api/download`
**Request Body**:
```json
{
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"format": "mp4",
"formatType": "video"
}
```
**Format Types**:
- `"video"`: Download as video file
- `"audio"`: Extract audio only
**Video Formats**:
- `"mp4"`: MP4 video (most compatible)
- `"webm"`: WebM video (web-optimized)
- `"mkv"`: MKV container (high quality)
- `"avi"`: AVI video (legacy)
**Audio Formats**:
- `"mp3"`: MP3 audio (most compatible)
- `"wav"`: WAV audio (uncompressed)
- `"m4a"`: M4A audio (Apple format)
- `"opus"`: Opus audio (modern, efficient)
**Response** (Success):
```json
{
"success": true,
"downloadUrl": "/downloads/1234567890-abc123.mp4",
"filename": "Video_Title.mp4",
"videoInfo": {
"title": "Video Title",
"duration": 120,
"thumbnail": "https://...",
"formats": [...]
}
}
```
**Response** (Error):
```json
{
"success": false,
"error": "Failed to download video"
}
```
**Status Codes**:
- `200`: Success
- `400`: Bad Request (invalid URL, format, or missing fields)
- `500`: Internal Server Error
## Error Handling
All endpoints return JSON responses with a `success` boolean field. When `success` is `false`, an `error` field contains the error message.
Common error messages:
- `"Missing required fields"`: Required request body fields are missing
- `"Invalid YouTube URL"`: The provided URL is not a valid YouTube URL
- `"Failed to get video info"`: Error occurred while fetching video metadata
- `"Failed to download video"`: Error occurred during download/transcoding
## Rate Limiting
Currently, no rate limiting is implemented. Consider adding rate limiting for production use.
## File Storage
Downloaded files are stored in `/public/downloads/` and are accessible via the `downloadUrl` returned in the response. Files are named with a timestamp and random ID to avoid conflicts.
**Note**: Files are not automatically cleaned up. Consider implementing a cleanup job for production.
## Examples
### JavaScript/TypeScript
```typescript
// Get video info
const infoResponse = await fetch('/api/info', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'
})
});
const infoData = await infoResponse.json();
// Download MP4 video
const downloadResponse = await fetch('/api/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
format: 'mp4',
formatType: 'video'
})
});
const downloadData = await downloadResponse.json();
if (downloadData.success) {
// Download the file
window.location.href = downloadData.downloadUrl;
}
```
### cURL
```bash
# Get video info
curl -X POST http://localhost:3000/api/info \
-H "Content-Type: application/json" \
-d '{"url":"https://www.youtube.com/watch?v=dQw4w9WgXcQ"}'
# Download MP3 audio
curl -X POST http://localhost:3000/api/download \
-H "Content-Type: application/json" \
-d '{
"url":"https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"format":"mp3",
"formatType":"audio"
}'
```
## Security Considerations
- URLs are validated to ensure they are YouTube URLs
- Filenames are sanitized to prevent directory traversal
- No user authentication is currently implemented
- Consider adding file size limits and download quotas
- Implement CORS policies if needed for cross-origin requests

223
docs/architecture.md Normal file
View File

@@ -0,0 +1,223 @@
# Architecture Documentation
## Overview
Downlink is a Next.js application that provides a web interface for downloading YouTube videos and audio files. The application uses server-side processing with yt-dlp and provides a modern, responsive UI built with shadcn/ui.
## System Architecture
```
┌─────────────┐
│ Browser │
│ (Client) │
└──────┬──────┘
│ HTTP Requests
┌─────────────────────────────────┐
│ Next.js App Router │
│ ┌───────────────────────────┐ │
│ │ UI Components (React) │ │
│ └───────────────────────────┘ │
│ ┌───────────────────────────┐ │
│ │ API Routes │ │
│ │ - /api/info │ │
│ │ - /api/download │ │
│ └───────────┬───────────────┘ │
└──────────────┼──────────────────┘
┌─────────────────────────────────┐
│ Business Logic Layer │
│ ┌───────────────────────────┐ │
│ │ lib/youtube.ts │ │
│ │ - getVideoInfo() │ │
│ │ - downloadVideo() │ │
│ └───────────┬───────────────┘ │
└──────────────┼──────────────────┘
┌─────────────────────────────────┐
│ External Tools │
│ ┌───────────────────────────┐ │
│ │ yt-dlp (Python) │ │
│ │ - Video download │ │
│ │ - Format conversion │ │
│ └───────────────────────────┘ │
│ ┌───────────────────────────┐ │
│ │ ffmpeg (via yt-dlp) │ │
│ │ - Audio transcoding │ │
│ └───────────────────────────┘ │
└─────────────────────────────────┘
```
## Component Structure
### Frontend Components
#### `app/page.tsx`
Main page component containing:
- URL input field
- Video information display
- Format selection (video/audio)
- Download button
- Error handling and success states
#### UI Components (`components/ui/`)
shadcn/ui components:
- `button.tsx`: Button component
- `input.tsx`: Input field component
- `card.tsx`: Card container component
- `label.tsx`: Form label component
- `select.tsx`: Dropdown select component
### Backend Components
#### API Routes
**`app/api/info/route.ts`**
- Handles POST requests for video information
- Validates YouTube URL
- Calls `getVideoInfo()` from `lib/youtube.ts`
- Returns video metadata
**`app/api/download/route.ts`**
- Handles POST requests for downloads
- Validates request body (url, format, formatType)
- Calls `downloadVideo()` from `lib/youtube.ts`
- Returns download URL and file information
#### Business Logic
**`lib/youtube.ts`**
Core functionality:
- `getVideoInfo(url)`: Fetches video metadata using yt-dlp
- `downloadVideo(request)`: Downloads and processes video/audio
- `ensureDirectories()`: Creates necessary directories
- `sanitizeFilename()`: Sanitizes filenames for safe storage
**`lib/types.ts`**
Centralized TypeScript type definitions:
- `VideoFormat`, `AudioFormat`: Format type unions
- `DownloadRequest`, `DownloadResponse`: API request/response types
- `VideoInfo`, `FormatOption`: Video metadata types
**`lib/utils.ts`**
Utility functions:
- `cn()`: Tailwind class name merger
- `isValidYouTubeUrl()`: URL validation
- `extractYouTubeId()`: Extract video ID from URL
## Data Flow
### Video Information Flow
1. User enters YouTube URL and clicks "Get Info"
2. Frontend sends POST request to `/api/info`
3. API route validates URL and calls `getVideoInfo()`
4. `getVideoInfo()` executes `yt-dlp --dump-json`
5. JSON response is parsed and formatted
6. Video info (title, thumbnail, duration) returned to frontend
7. Frontend displays video information
### Download Flow
1. User selects format and clicks "Download"
2. Frontend sends POST request to `/api/download`
3. API route validates request and calls `downloadVideo()`
4. `downloadVideo()`:
- Generates unique file ID
- Constructs yt-dlp command based on format
- Executes download command
- Moves file to `/public/downloads/`
- Returns download URL
5. Frontend receives download URL
6. User can download file via provided link
## File Storage
### Directory Structure
```
Downlink/
├── downloads/ # Temporary storage (gitignored)
│ └── [fileId].[ext] # Downloaded files before processing
├── public/
│ └── downloads/ # Public downloads (gitignored)
│ └── [fileId].[ext] # Final files served to users
```
### File Naming
- Format: `[timestamp]-[randomId].[extension]`
- Example: `1234567890-abc123.mp4`
- Prevents filename conflicts
- Timestamp enables cleanup of old files
## Dependencies
### Runtime Dependencies
- **next**: Next.js framework
- **react**: React library
- **react-dom**: React DOM rendering
- **lucide-react**: Icon library
- **@radix-ui/***: UI component primitives
- **tailwind-merge**: Tailwind class merging
- **clsx**: Conditional class names
### System Dependencies
- **yt-dlp**: Python-based YouTube downloader
- **ffmpeg**: Media conversion tool (used by yt-dlp)
### Development Dependencies
- **typescript**: TypeScript compiler
- **tailwindcss**: CSS framework
- **eslint**: Code linting
## Security Considerations
### Current Implementation
- URL validation to ensure YouTube URLs only
- Filename sanitization to prevent directory traversal
- Server-side processing (no client-side download logic)
### Recommended Enhancements
- Rate limiting to prevent abuse
- File size limits
- User authentication/authorization
- Download quotas per user/IP
- Automatic file cleanup
- CORS configuration
- Input sanitization for all user inputs
## Performance Considerations
### Current Limitations
- Synchronous file operations (could block for large files)
- No progress tracking during downloads
- Files stored in memory during copy operations
- No caching of video information
### Optimization Opportunities
- Stream large files instead of loading into memory
- Implement download progress tracking
- Cache video metadata
- Use background jobs for long-running downloads
- Implement CDN for file serving
- Add database for download history
## Deployment Considerations
### Requirements
- Node.js 18+ runtime
- Python 3.6+ (for yt-dlp)
- ffmpeg installed on server
- Sufficient disk space for downloads
- Network access to YouTube
### Environment Variables
Currently none required, but consider:
- `MAX_FILE_SIZE`: Maximum file size limit
- `CLEANUP_INTERVAL`: File cleanup schedule
- `ALLOWED_DOMAINS`: Allowed video platforms
- `STORAGE_PATH`: Custom storage location
### Scaling Considerations
- Stateless API design (can scale horizontally)
- File storage should be shared (S3, etc.) for multi-instance deployments
- Consider message queue for download processing
- Implement caching layer for video metadata

158
docs/configuration.md Normal file
View File

@@ -0,0 +1,158 @@
# Configuration Guide
## Environment Variables
Downlink uses environment variables for configuration. Create a `.env.local` file in the project root (this file is gitignored and won't be committed).
### Required Configuration
#### YT_DLP_PATH
**Required if yt-dlp is not in your system PATH**
Full path to the yt-dlp executable.
```bash
YT_DLP_PATH=/Users/jeff/Desktop/yt-dlp_macos
```
**When to set this:**
- yt-dlp is not installed via package manager (brew, pip, etc.)
- yt-dlp is in a custom location
- You're using a standalone yt-dlp binary
**How to find your yt-dlp path:**
```bash
# If installed via brew
which yt-dlp
# Output: /opt/homebrew/bin/yt-dlp
# If installed via pip
which yt-dlp
# Output: /usr/local/bin/yt-dlp
# If using standalone binary
# Use the full path to the binary file
```
### Optional Configuration
#### FFMPEG_PATH
**Optional - Reserved for future use**
Full path to ffmpeg executable (currently not used, but reserved for future configuration).
```bash
FFMPEG_PATH=/usr/local/bin/ffmpeg
```
## Configuration Examples
### Example 1: yt-dlp in Custom Location
```bash
# .env.local
YT_DLP_PATH=/Users/jeff/Desktop/yt-dlp_macos
```
### Example 2: yt-dlp Installed via Homebrew
```bash
# .env.local
# Can be left empty if yt-dlp is in PATH
# Or explicitly set:
YT_DLP_PATH=/opt/homebrew/bin/yt-dlp
```
### Example 3: yt-dlp Installed via pip
```bash
# .env.local
YT_DLP_PATH=/usr/local/bin/yt-dlp
```
## Environment File Setup
1. **Copy the example file:**
```bash
cp .env.example .env.local
```
2. **Edit `.env.local` with your configuration:**
```bash
YT_DLP_PATH=/path/to/your/yt-dlp
```
3. **Restart the development server:**
```bash
npm run dev
```
## Troubleshooting
### yt-dlp not found error
If you see `yt-dlp: command not found`:
1. **Check if yt-dlp exists:**
```bash
which yt-dlp
```
2. **If it returns nothing, set YT_DLP_PATH:**
```bash
# Find where yt-dlp is located
find /usr -name yt-dlp 2>/dev/null
find ~ -name yt-dlp 2>/dev/null
# Or if you downloaded it manually, use that path
```
3. **Add to .env.local:**
```bash
YT_DLP_PATH=/full/path/to/yt-dlp
```
4. **Verify the path is correct:**
```bash
ls -la "$YT_DLP_PATH"
# Should show the yt-dlp executable
```
### Path with spaces
If your path contains spaces, use quotes in the environment variable:
```bash
YT_DLP_PATH="/Users/jeff/My Apps/yt-dlp_macos"
```
### Executable permissions
Ensure yt-dlp has execute permissions:
```bash
chmod +x /path/to/yt-dlp
```
## Production Configuration
For production deployments:
1. Set environment variables in your hosting platform (Vercel, Railway, etc.)
2. Ensure yt-dlp is installed on the server
3. Set `YT_DLP_PATH` if yt-dlp is not in the default PATH
### Vercel Example
```bash
# In Vercel dashboard → Settings → Environment Variables
YT_DLP_PATH=/usr/local/bin/yt-dlp
```
### Docker Example
```dockerfile
# In Dockerfile
ENV YT_DLP_PATH=/usr/local/bin/yt-dlp
```
## Next.js Environment Variables
Next.js automatically loads `.env.local` files. Variables prefixed with `NEXT_PUBLIC_` are exposed to the browser, but `YT_DLP_PATH` should remain server-side only (no prefix).
## Security Notes
- Never commit `.env.local` to version control (already in .gitignore)
- Keep yt-dlp paths secure
- Use absolute paths for reliability
- Test configuration after changes

174
docs/features.md Normal file
View File

@@ -0,0 +1,174 @@
# Features Documentation
## Current Features
### 1. YouTube Video Information Retrieval
- Fetch video metadata without downloading
- Display video title, thumbnail, and duration
- View available formats
**Implementation**: `app/api/info` endpoint
### 2. Video Download
- Download videos in multiple formats:
- MP4 (most compatible)
- WebM (web-optimized)
- MKV (high quality)
- AVI (legacy support)
**Implementation**: `app/api/download` endpoint with `formatType: "video"`
### 3. Audio Extraction
- Extract audio from videos in multiple formats:
- MP3 (most compatible, compressed)
- WAV (uncompressed, high quality)
- M4A (Apple format)
- Opus (modern, efficient)
**Implementation**: `app/api/download` endpoint with `formatType: "audio"`
### 4. Format Transcoding
- Automatic format conversion using yt-dlp and ffmpeg
- Best quality selection for requested format
- Audio extraction and conversion for audio formats
**Implementation**: Server-side processing in `lib/youtube.ts`
### 5. Modern UI
- Responsive design with Tailwind CSS
- shadcn/ui components for consistent styling
- Loading states and error handling
- Video thumbnail preview
- Format selection dropdowns
**Implementation**: `app/page.tsx` with shadcn/ui components
### 6. URL Validation
- Client-side and server-side URL validation
- Support for various YouTube URL formats:
- `https://www.youtube.com/watch?v=...`
- `https://youtu.be/...`
- `youtube.com/watch?v=...`
**Implementation**: `lib/utils.ts` - `isValidYouTubeUrl()`
### 7. Safe File Handling
- Filename sanitization to prevent directory traversal
- Unique file IDs to prevent conflicts
- Proper file extension handling
**Implementation**: `lib/youtube.ts` - `sanitizeFilename()`
## Feature Roadmap
### Planned Features
#### Short Term
- [ ] Download progress tracking
- [ ] File size display before download
- [ ] Video quality selection (720p, 1080p, etc.)
- [ ] Download history
- [ ] Error recovery and retry mechanism
#### Medium Term
- [ ] Playlist support
- [ ] Batch downloads
- [ ] Custom ffmpeg parameters
- [ ] Download queue management
- [ ] File cleanup automation
#### Long Term
- [ ] User authentication
- [ ] Download quotas and limits
- [ ] Cloud storage integration (S3, etc.)
- [ ] API rate limiting
- [ ] Webhook notifications
- [ ] Mobile app support
## Feature Usage Examples
### Download MP4 Video
```typescript
const response = await fetch('/api/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: 'https://www.youtube.com/watch?v=...',
format: 'mp4',
formatType: 'video'
})
});
```
### Extract MP3 Audio
```typescript
const response = await fetch('/api/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: 'https://www.youtube.com/watch?v=...',
format: 'mp3',
formatType: 'audio'
})
});
```
### Get Video Info
```typescript
const response = await fetch('/api/info', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: 'https://www.youtube.com/watch?v=...'
})
});
```
## Feature Limitations
### Current Limitations
1. **No Progress Tracking**: Users cannot see download progress
2. **No File Size Limits**: Large files may cause issues
3. **No Cleanup**: Files accumulate in storage
4. **Single Format**: One format per download request
5. **No Playlist Support**: Only single videos
6. **No Quality Selection**: Downloads best available quality
7. **No Authentication**: No user accounts or history
### Known Issues
- Large files may timeout on slow connections
- Some videos may not be available in requested format
- Audio conversion (MP3/WAV) requires ffmpeg
- No validation of available formats before download
## Feature Dependencies
### Required System Tools
- **yt-dlp**: For video downloading and format selection
- **ffmpeg**: For audio format conversion (MP3, WAV)
### Required npm Packages
- Next.js 16+
- React 19+
- shadcn/ui components
- Tailwind CSS
- Lucide React (icons)
## Feature Testing
### Manual Testing Checklist
- [ ] Enter valid YouTube URL and get info
- [ ] Download video in MP4 format
- [ ] Download video in WebM format
- [ ] Extract audio as MP3
- [ ] Extract audio as WAV
- [ ] Test with invalid URL (should show error)
- [ ] Test with missing format (should show error)
- [ ] Verify file downloads correctly
- [ ] Check filename sanitization
### Automated Testing (Future)
- Unit tests for utility functions
- Integration tests for API endpoints
- E2E tests for user flows
- Format validation tests

153
docs/setup.md Normal file
View File

@@ -0,0 +1,153 @@
# Setup Guide
## Prerequisites
### System Requirements
- Node.js 18+ and npm
- Python 3.6+ (for yt-dlp)
- ffmpeg (for audio conversion)
### Installing Dependencies
#### macOS
```bash
# Install yt-dlp
brew install yt-dlp
# Install ffmpeg
brew install ffmpeg
```
#### Linux (Ubuntu/Debian)
```bash
# Install yt-dlp
pip3 install yt-dlp
# Install ffmpeg
sudo apt-get update
sudo apt-get install ffmpeg
```
#### Windows
```bash
# Install yt-dlp
pip install yt-dlp
# Install ffmpeg
# Download from https://ffmpeg.org/download.html
# Add to PATH
```
## Installation
1. **Clone the repository**
```bash
git clone <repository-url>
cd Downlink
```
2. **Install npm dependencies**
```bash
npm install
```
3. **Verify yt-dlp installation**
```bash
yt-dlp --version
```
4. **Verify ffmpeg installation**
```bash
ffmpeg -version
```
## Running the Application
### Development Mode
```bash
npm run dev
```
The application will be available at `http://localhost:3000`
### Production Build
```bash
npm run build
npm start
```
## Directory Structure
```
Downlink/
├── app/ # Next.js app directory
│ ├── api/ # API routes
│ │ ├── download/ # Download endpoint
│ │ └── info/ # Video info endpoint
│ ├── page.tsx # Main page
│ └── layout.tsx # Root layout
├── components/ # React components
│ └── ui/ # shadcn/ui components
├── lib/ # Utility functions
│ ├── types.ts # TypeScript types
│ └── youtube.ts # YouTube download logic
├── docs/ # Documentation
├── downloads/ # Temporary download storage (gitignored)
└── public/
└── downloads/ # Served download files (gitignored)
```
## Environment Variables
Create a `.env.local` file in the project root (copy from `.env.example`):
```bash
# Required if yt-dlp is not in PATH
YT_DLP_PATH=/path/to/yt-dlp
# Optional: FFmpeg path (if not in PATH)
# FFMPEG_PATH=/path/to/ffmpeg
```
### Available Variables
- **`YT_DLP_PATH`** (optional): Full path to yt-dlp executable if not in PATH
- Example: `YT_DLP_PATH=/Users/jeff/Desktop/yt-dlp_macos`
- Example: `YT_DLP_PATH=/opt/homebrew/bin/yt-dlp`
- If not set, the system will look for `yt-dlp` in PATH
- **`FFMPEG_PATH`** (optional): Full path to ffmpeg executable if not in PATH
- Currently not used but reserved for future use
### Future Environment Variables
- `MAX_FILE_SIZE`: Maximum file size limit
- `CLEANUP_INTERVAL`: File cleanup interval
- `ALLOWED_DOMAINS`: Allowed video domains
## Troubleshooting
### yt-dlp not found
- Ensure yt-dlp is installed and in your PATH
- Try: `which yt-dlp` to verify installation
- Reinstall if needed: `pip3 install --upgrade yt-dlp`
### ffmpeg not found
- Ensure ffmpeg is installed and in your PATH
- Try: `which ffmpeg` to verify installation
- Audio conversion (MP3, WAV) will fail without ffmpeg
### Download fails
- Check YouTube URL format
- Verify internet connection
- Check server logs for detailed error messages
- Ensure sufficient disk space in downloads directory
### Port already in use
- Change port: `npm run dev -- -p 3001`
- Or kill process using port 3000
## Next Steps
- Read [YouTube Downloader Documentation](./youtube-downloader.md)
- Read [Transcoding Documentation](./transcoding.md)
- Check [Agent Instructions](./AGENTS.md) for contributing guidelines

114
docs/transcoding.md Normal file
View File

@@ -0,0 +1,114 @@
# Transcoding Feature
## Overview
The transcoding feature converts downloaded YouTube videos and audio files into different formats. This allows users to download content in their preferred format (MP4, WebM, MP3, WAV, etc.) regardless of the original format.
## Usage
### Format Selection
Users can select from the following formats:
**Video Formats**:
- MP4 (most compatible)
- WebM (web-optimized)
- MKV (high quality)
- AVI (legacy support)
**Audio Formats**:
- MP3 (most compatible, compressed)
- WAV (uncompressed, high quality)
- M4A (Apple format, good quality)
- Opus (modern, efficient)
### How It Works
1. User selects desired format from dropdown
2. System downloads best available format from YouTube
3. For audio formats (MP3, WAV), yt-dlp extracts audio and converts
4. For video formats, yt-dlp selects best matching format or transcodes if needed
5. Converted file is saved and served to user
## Technical Details
### Implementation
#### Audio Transcoding
Audio formats like MP3 and WAV require extraction and conversion:
- yt-dlp downloads best audio stream (usually M4A)
- Uses ffmpeg (via yt-dlp) to convert to requested format
- Command: `yt-dlp -f "bestaudio" -x --audio-format mp3 --audio-quality 0`
#### Video Transcoding
Video formats use format selection:
- yt-dlp attempts to find matching format
- Falls back to best available format if exact match not found
- May combine video and audio streams if needed
### Code Location
- Format handling: `lib/youtube.ts` - `downloadVideo()` function
- Format selection logic determines yt-dlp command based on requested format
### Format Mapping
```typescript
// Video formats
mp4 bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best
webm bestvideo[ext=webm]+bestaudio[ext=m4a]/best[ext=webm]/best
mkv bestvideo[ext=mkv]+bestaudio[ext=m4a]/best[ext=mkv]/best
avi bestvideo[ext=avi]+bestaudio[ext=m4a]/best[ext=avi]/best
// Audio formats
mp3 bestaudio[ext=m4a]/bestaudio + convert to MP3
wav bestaudio[ext=m4a]/bestaudio + convert to WAV
m4a bestaudio[ext=m4a]/bestaudio
opus bestaudio[ext=opus]/bestaudio
```
## Configuration
### Audio Quality
Currently set to highest quality (`--audio-quality 0`). Can be adjusted:
- `0`: Best quality (largest file)
- `5`: Default quality
- `9`: Lower quality (smaller file)
### Video Quality
Currently downloads best available. Could be enhanced to allow:
- Resolution selection (720p, 1080p, etc.)
- Bitrate selection
- Codec preference
## Examples
### Transcoding Flow
```typescript
// User requests MP3
{
url: "https://youtube.com/watch?v=...",
format: "mp3",
formatType: "audio"
}
// System process:
// 1. Download best audio (M4A)
// 2. Extract audio stream
// 3. Convert to MP3 using ffmpeg
// 4. Save as .mp3 file
```
## Dependencies
- **yt-dlp**: Handles format selection and basic conversion
- **ffmpeg**: Required for audio format conversion (MP3, WAV)
## Limitations
- Transcoding happens server-side and can be CPU-intensive
- Large files may take significant time to process
- No progress indication during transcoding
- Quality settings are fixed (best quality)
## Future Improvements
- Add quality/bitrate selection options
- Add resolution selection for video
- Add progress tracking for transcoding
- Add batch transcoding support
- Add preview/thumbnail generation
- Add custom ffmpeg parameters option

190
docs/troubleshooting.md Normal file
View File

@@ -0,0 +1,190 @@
# Troubleshooting Guide
## Common Issues and Solutions
### Error: stdout maxBuffer length exceeded
**Symptoms:**
- API returns 500 error
- Error message: `RangeError: stdout maxBuffer length exceeded`
- Happens with long videos or videos with many format options
**Cause:**
Node.js's `exec` function has a default `maxBuffer` of 1MB. For long videos, yt-dlp returns very large JSON output (especially with storyboard fragments) that exceeds this limit.
**Solution:**
The code now uses a 10MB buffer. If you encounter this error again, you can increase it further in `lib/youtube.ts`:
```typescript
await execAsync(command, { maxBuffer: 20 * 1024 * 1024 }); // 20MB buffer
```
**Status:** ✅ Fixed in current version
---
### Error: yt-dlp command not found
**Symptoms:**
- Error: `yt-dlp: command not found`
- API returns 500 error
**Cause:**
yt-dlp is not in your system PATH or the path in `.env.local` is incorrect.
**Solution:**
1. Check if yt-dlp exists:
```bash
which yt-dlp
```
2. If not found, set `YT_DLP_PATH` in `.env.local`:
```bash
YT_DLP_PATH=/full/path/to/yt-dlp
```
3. Restart the dev server
**Status:** ✅ Fixed with environment variable configuration
---
### Error: Failed to get video info
**Symptoms:**
- API returns 500 error
- Generic error message: "Failed to get video info"
**Possible Causes:**
1. **Invalid YouTube URL** - Check URL format
2. **Video is private/restricted** - Video may not be accessible
3. **Network issues** - Check internet connection
4. **yt-dlp needs update** - Update yt-dlp: `pip install --upgrade yt-dlp`
**Solution:**
Check server logs for detailed error messages. The logs will show the actual yt-dlp error.
---
### Warning: No supported JavaScript runtime
**Symptoms:**
- Warning in logs: `No supported JavaScript runtime could be found`
- Some formats may be missing
**Cause:**
yt-dlp needs a JavaScript runtime for some YouTube features. This is optional but recommended.
**Solution:**
Install a JavaScript runtime (optional):
```bash
# Install Node.js (if not already installed)
brew install node
# Or install deno
brew install deno
```
**Note:** This is a warning, not an error. The app will still work, but some formats may be unavailable.
---
### Download fails but info works
**Symptoms:**
- `/api/info` works fine
- `/api/download` fails
**Possible Causes:**
1. **Disk space** - Check available disk space
2. **Permissions** - Ensure write permissions for `downloads/` and `public/downloads/`
3. **ffmpeg missing** - Required for audio conversion (MP3, WAV)
**Solution:**
1. Check disk space: `df -h`
2. Check permissions: `ls -la downloads/ public/downloads/`
3. Install ffmpeg: `brew install ffmpeg`
---
### Files not cleaning up
**Symptoms:**
- Downloaded files accumulate in `public/downloads/`
- Disk space fills up
**Cause:**
No automatic cleanup is implemented yet.
**Solution:**
Manually clean up old files:
```bash
# Remove files older than 24 hours
find public/downloads/ -type f -mtime +1 -delete
```
**Future:** Automatic cleanup will be added in a future update.
---
### Environment variable not loading
**Symptoms:**
- `.env.local` exists but variables aren't being used
- Still using default `yt-dlp` path
**Solution:**
1. Ensure file is named exactly `.env.local` (not `.env.local.txt`)
2. Restart the dev server completely (stop and start)
3. Check server startup logs for: `Environments: .env.local`
4. Verify no syntax errors in `.env.local` (no spaces around `=`)
---
## Getting Help
### Check Server Logs
The dev server logs show detailed error information. Look for:
- `[getVideoInfo] Using yt-dlp path:` - Confirms path is loaded
- `Error getting video info:` - Shows actual error
- `POST /api/info 500` - HTTP status codes
### Enable Debug Logging
Debug logging is automatically enabled in development mode. Check console output for:
- yt-dlp path being used
- Command execution errors
- File operation errors
### Test yt-dlp Directly
Test yt-dlp outside the app:
```bash
yt-dlp --dump-json --no-download "https://www.youtube.com/watch?v=VIDEO_ID"
```
If this fails, the issue is with yt-dlp configuration, not the app.
---
## Performance Issues
### Slow API Responses
- Normal: 5-10 seconds for video info
- Normal: 30-60+ seconds for downloads (depends on video length)
- If slower, check network connection and yt-dlp version
### High Memory Usage
- Large videos with many formats use more memory
- Consider increasing Node.js memory limit if needed:
```bash
NODE_OPTIONS="--max-old-space-size=4096" npm run dev
```
---
## Still Having Issues?
1. Check the [Setup Guide](./setup.md) for installation issues
2. Review [Configuration](./configuration.md) for environment variables
3. Check server logs for detailed error messages
4. Test yt-dlp directly from command line
5. Ensure all dependencies are installed and up to date

161
docs/youtube-downloader.md Normal file
View File

@@ -0,0 +1,161 @@
# YouTube Downloader Feature
## Overview
The YouTube Downloader feature allows users to download YouTube videos and audio files in various formats. Users can paste a YouTube URL, select their preferred format (video or audio), and download the transcoded file.
## Usage
### User Flow
1. User pastes a YouTube URL into the input field
2. User clicks "Get Info" to fetch video metadata (title, thumbnail, duration)
3. User selects format type (Video or Audio)
4. User selects specific format (MP4, WebM, MKV, AVI for video; MP3, WAV, M4A, Opus for audio)
5. User clicks "Download" to start the download process
6. Once complete, user receives a download link
### API Endpoints
#### GET Video Information
**Endpoint**: `POST /api/info`
**Request Body**:
```json
{
"url": "https://www.youtube.com/watch?v=..."
}
```
**Response**:
```json
{
"success": true,
"videoInfo": {
"title": "Video Title",
"duration": 120,
"thumbnail": "https://...",
"formats": [...]
}
}
```
#### Download Video/Audio
**Endpoint**: `POST /api/download`
**Request Body**:
```json
{
"url": "https://www.youtube.com/watch?v=...",
"format": "mp4",
"formatType": "video"
}
```
**Response**:
```json
{
"success": true,
"downloadUrl": "/downloads/1234567890-abc123.mp4",
"filename": "Video Title.mp4",
"videoInfo": {...}
}
```
## Technical Details
### Dependencies
- **yt-dlp**: Python-based YouTube downloader (must be installed system-wide)
- **ffmpeg**: Required for audio format conversion (MP3, WAV)
### Implementation
#### Server-Side Processing
- Downloads are processed server-side using `yt-dlp`
- Files are temporarily stored in `/downloads` directory
- Completed files are moved to `/public/downloads` for serving
- Files are named with timestamp and random ID to avoid conflicts
#### Format Handling
- **Video formats**: MP4, WebM, MKV, AVI
- **Audio formats**: MP3, WAV, M4A, Opus
- MP3 and WAV require audio extraction and conversion via yt-dlp
- Other formats use direct format selection from available streams
#### File Storage
- Temporary storage: `downloads/` (excluded from git)
- Public storage: `public/downloads/` (served statically)
- Files are not automatically cleaned up (consider adding cleanup job)
### Code Structure
- **Types**: `lib/types.ts` - Centralized type definitions
- **YouTube Logic**: `lib/youtube.ts` - Core download and info functions
- **API Routes**:
- `app/api/info/route.ts` - Video information endpoint
- `app/api/download/route.ts` - Download endpoint
- **UI**: `app/page.tsx` - Main user interface
## Configuration
### Environment Variables
Create a `.env.local` file in the project root:
```bash
# Required if yt-dlp is not in PATH
YT_DLP_PATH=/path/to/yt-dlp
```
- **`YT_DLP_PATH`**: Full path to yt-dlp executable if not in system PATH
- Required if yt-dlp is not installed via package manager
- Example: `YT_DLP_PATH=/Users/jeff/Desktop/yt-dlp_macos`
Future environment variables:
- `MAX_FILE_SIZE`: Maximum file size limit
- `CLEANUP_INTERVAL`: How often to clean up old files
- `ALLOWED_DOMAINS`: Restrict to specific video platforms
### System Requirements
- Python 3.6+ (for yt-dlp)
- yt-dlp installed: `pip install yt-dlp` or `brew install yt-dlp`
- ffmpeg installed: `brew install ffmpeg` (for audio conversion)
## Examples
### Download MP4 Video
```typescript
const response = await fetch('/api/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
format: 'mp4',
formatType: 'video'
})
});
```
### Download MP3 Audio
```typescript
const response = await fetch('/api/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: 'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
format: 'mp3',
formatType: 'audio'
})
});
```
## Limitations
- Requires yt-dlp and ffmpeg to be installed on the server
- No automatic file cleanup (files accumulate in public/downloads)
- No file size limits or rate limiting
- No user authentication or download history
## Future Improvements
- Add file cleanup job for old downloads
- Add download progress tracking
- Add download history for users
- Add file size limits and validation
- Add support for playlists
- Add video quality selection (720p, 1080p, etc.)

39
lib/types.ts Normal file
View File

@@ -0,0 +1,39 @@
export type VideoFormat = 'mp4' | 'webm' | 'mkv' | 'avi';
export type AudioFormat = 'mp3' | 'wav' | 'm4a' | 'opus';
export type FormatType = 'video' | 'audio';
export interface DownloadRequest {
url: string;
format: VideoFormat | AudioFormat;
formatType: FormatType;
}
export interface DownloadResponse {
success: boolean;
downloadUrl?: string;
filename?: string;
error?: string;
videoInfo?: VideoInfo;
}
export interface VideoInfo {
title: string;
duration: number;
thumbnail?: string;
formats?: FormatOption[];
}
export interface FormatOption {
formatId: string;
ext: string;
resolution?: string;
filesize?: number;
formatNote?: string;
}
export interface TranscodeRequest {
fileId: string;
format: VideoFormat | AudioFormat;
formatType: FormatType;
}

30
lib/utils.ts Normal file
View File

@@ -0,0 +1,30 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// Validate YouTube URL
export function isValidYouTubeUrl(url: string): boolean {
const youtubeRegex = /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\/.+/;
return youtubeRegex.test(url);
}
// Extract YouTube video ID
export function extractYouTubeId(url: string): string | null {
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/)([^&\n?#]+)/,
/youtube\.com\/embed\/([^&\n?#]+)/,
/youtube\.com\/v\/([^&\n?#]+)/,
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match && match[1]) {
return match[1];
}
}
return null;
}

165
lib/youtube.ts Normal file
View File

@@ -0,0 +1,165 @@
import { exec } from 'child_process';
import { promisify } from 'util';
import { writeFile, readFile, unlink, mkdir, copyFile } from 'fs/promises';
import { existsSync } from 'fs';
import path from 'path';
import type { VideoInfo, FormatOption, DownloadRequest, DownloadResponse } from './types';
const execAsync = promisify(exec);
// Get yt-dlp path from environment variable or use default
// Read at runtime to ensure env vars are loaded
function getYtDlpPath(): string {
return process.env.YT_DLP_PATH || 'yt-dlp';
}
const DOWNLOADS_DIR = path.join(process.cwd(), 'downloads');
const PUBLIC_DOWNLOADS_DIR = path.join(process.cwd(), 'public', 'downloads');
// Sanitize filename for safe storage
function sanitizeFilename(filename: string): string {
return filename
.replace(/[^a-z0-9]/gi, '_')
.replace(/_+/g, '_')
.substring(0, 200);
}
// Ensure directories exist
async function ensureDirectories() {
if (!existsSync(DOWNLOADS_DIR)) {
await mkdir(DOWNLOADS_DIR, { recursive: true });
}
if (!existsSync(PUBLIC_DOWNLOADS_DIR)) {
await mkdir(PUBLIC_DOWNLOADS_DIR, { recursive: true });
}
}
export async function getVideoInfo(url: string): Promise<VideoInfo | null> {
try {
await ensureDirectories();
// Get video info as JSON
const ytDlpPath = getYtDlpPath();
console.log('[getVideoInfo] Using yt-dlp path:', ytDlpPath);
// Increase maxBuffer to handle large JSON responses from long videos
// Default is 1MB, increase to 10MB to handle videos with many formats
const { stdout } = await execAsync(
`"${ytDlpPath}" --dump-json --no-download "${url}"`,
{ maxBuffer: 10 * 1024 * 1024 } // 10MB buffer
);
const info = JSON.parse(stdout);
const formats: FormatOption[] = (info.formats || []).map((f: any) => ({
formatId: f.format_id,
ext: f.ext,
resolution: f.resolution || `${f.width}x${f.height}`,
filesize: f.filesize,
formatNote: f.format_note,
}));
return {
title: info.title || 'Unknown',
duration: info.duration || 0,
thumbnail: info.thumbnail,
formats,
};
} catch (error) {
console.error('Error getting video info:', error);
return null;
}
}
export async function downloadVideo(
request: DownloadRequest
): Promise<DownloadResponse> {
try {
await ensureDirectories();
const { url, format, formatType } = request;
const fileId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
let ytDlpFormat = '';
let outputExt = format;
if (formatType === 'audio') {
// Audio formats
if (format === 'mp3') {
ytDlpFormat = 'bestaudio[ext=m4a]/bestaudio';
outputExt = 'mp3';
} else if (format === 'wav') {
ytDlpFormat = 'bestaudio[ext=m4a]/bestaudio';
outputExt = 'wav';
} else {
ytDlpFormat = `bestaudio[ext=${format}]/bestaudio`;
}
} else {
// Video formats
ytDlpFormat = `bestvideo[ext=${format}]+bestaudio[ext=m4a]/best[ext=${format}]/best`;
}
const outputPath = path.join(DOWNLOADS_DIR, `${fileId}.%(ext)s`);
const finalPath = path.join(PUBLIC_DOWNLOADS_DIR, `${fileId}.${outputExt}`);
// Get yt-dlp path at runtime
const ytDlpPath = getYtDlpPath();
console.log('[downloadVideo] Using yt-dlp path:', ytDlpPath);
// Download with yt-dlp
let command = `"${ytDlpPath}" -f "${ytDlpFormat}" -o "${outputPath}" "${url}"`;
if (formatType === 'audio' && format === 'mp3') {
// For MP3, we need to extract audio and convert
command = `"${ytDlpPath}" -f "bestaudio[ext=m4a]/bestaudio" -x --audio-format mp3 --audio-quality 0 -o "${outputPath}" "${url}"`;
} else if (formatType === 'audio' && format === 'wav') {
// For WAV, extract and convert
command = `"${ytDlpPath}" -f "bestaudio[ext=m4a]/bestaudio" -x --audio-format wav -o "${outputPath}" "${url}"`;
}
// Increase maxBuffer for downloads as well
await execAsync(command, { maxBuffer: 10 * 1024 * 1024 }); // 10MB buffer
// Find the downloaded file
const files = await import('fs/promises').then(fs =>
fs.readdir(DOWNLOADS_DIR)
);
const downloadedFile = files.find(f => f.startsWith(fileId));
if (!downloadedFile) {
throw new Error('Downloaded file not found');
}
const downloadedPath = path.join(DOWNLOADS_DIR, downloadedFile);
const downloadedExt = path.extname(downloadedFile).slice(1);
// Get video info for response (before moving file)
const videoInfo = await getVideoInfo(url);
const safeFilename = sanitizeFilename(videoInfo?.title || 'video');
// If extension matches, just copy. Otherwise, rename during copy
if (downloadedExt === outputExt) {
// Copy file to public directory
await copyFile(downloadedPath, finalPath);
await unlink(downloadedPath);
} else {
// For format conversions, yt-dlp should have already converted
// But if extension doesn't match, copy with new extension
await copyFile(downloadedPath, finalPath);
await unlink(downloadedPath);
}
return {
success: true,
downloadUrl: `/downloads/${fileId}.${outputExt}`,
filename: `${safeFilename}.${outputExt}`,
videoInfo: videoInfo || undefined,
};
} catch (error: any) {
console.error('Error downloading video:', error);
return {
success: false,
error: error.message || 'Failed to download video',
};
}
}

1094
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{
"name": "toob",
"name": "downlink",
"version": "0.1.0",
"private": true,
"scripts": {
@@ -9,9 +9,16 @@
"lint": "eslint"
},
"dependencies": {
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.562.0",
"next": "16.1.0",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
@@ -21,6 +28,7 @@
"eslint": "^9",
"eslint-config-next": "16.1.0",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}