initial commit
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -39,3 +39,7 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# downloads
|
||||
/downloads
|
||||
/public/downloads
|
||||
|
||||
90
README.md
90
README.md
@@ -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
39
app/api/download/route.ts
Normal 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
42
app/api/info/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
351
app/globals.css
351
app/globals.css
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`}
|
||||
>
|
||||
|
||||
380
app/page.tsx
380
app/page.tsx
@@ -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
22
components.json
Normal 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
62
components/ui/button.tsx
Normal 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
92
components/ui/card.tsx
Normal 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
21
components/ui/input.tsx
Normal 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
24
components/ui/label.tsx
Normal 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
190
components/ui/select.tsx
Normal 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
58
docs/AGENTS.md
Normal 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
84
docs/README.md
Normal 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
197
docs/api.md
Normal 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
223
docs/architecture.md
Normal 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
158
docs/configuration.md
Normal 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
174
docs/features.md
Normal 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
153
docs/setup.md
Normal 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
114
docs/transcoding.md
Normal 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
190
docs/troubleshooting.md
Normal 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
161
docs/youtube-downloader.md
Normal 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
39
lib/types.ts
Normal 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
30
lib/utils.ts
Normal 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
165
lib/youtube.ts
Normal 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
1094
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user