diff --git a/app/api/direct-download/route.ts b/app/api/direct-download/route.ts new file mode 100644 index 0000000..83e8cea --- /dev/null +++ b/app/api/direct-download/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { downloadVideo } from '@/lib/youtube'; +import type { DownloadRequest, AudioFormat, VideoFormat, FormatType } from '@/lib/types'; +import { createReadStream } from 'fs'; +import { stat } from 'fs/promises'; +import path from 'path'; +import { Readable } from 'stream'; + +const VIDEO_MIME: Record = { + mp4: 'video/mp4', + webm: 'video/webm', + mkv: 'video/x-matroska', + avi: 'video/x-msvideo', +}; + +const AUDIO_MIME: Record = { + mp3: 'audio/mpeg', + wav: 'audio/wav', + m4a: 'audio/mp4', + opus: 'audio/ogg', +}; + +function getMimeType(formatType: FormatType, format: VideoFormat | AudioFormat): string { + if (formatType === 'video') { + return VIDEO_MIME[format as VideoFormat] || 'application/octet-stream'; + } + return AUDIO_MIME[format as AudioFormat] || 'application/octet-stream'; +} + +async function resolveDownloadPath(downloadUrl: string) { + const sanitized = downloadUrl.startsWith('/') ? downloadUrl.slice(1) : downloadUrl; + const filePath = path.join(process.cwd(), 'public', sanitized); + const fileInfo = await stat(filePath); + return { filePath, size: fileInfo.size }; +} + +export async function POST(request: NextRequest) { + try { + const body: DownloadRequest = await request.json(); + const { url, format, formatType } = body; + + if (!url || !format || !formatType) { + return NextResponse.json( + { success: false, error: 'Missing required fields' }, + { status: 400 } + ); + } + + if (!isValidYouTubeUrl(url)) { + return NextResponse.json( + { success: false, error: 'Invalid YouTube URL' }, + { status: 400 } + ); + } + + const result = await downloadVideo(body); + + if (!result.success || !result.downloadUrl || !result.filename) { + return NextResponse.json( + { success: false, error: result.error || 'Failed to process download' }, + { status: 500 } + ); + } + + const { filePath, size } = await resolveDownloadPath(result.downloadUrl); + const nodeStream = createReadStream(filePath); + const webStream = Readable.toWeb(nodeStream) as unknown as ReadableStream; + + const headers = new Headers(); + headers.set('Content-Type', getMimeType(formatType, format)); + headers.set('Content-Length', size.toString()); + headers.set('Content-Disposition', `attachment; filename="${result.filename}"`); + + return new NextResponse(webStream, { status: 200, headers }); + } catch (error: any) { + console.error('Direct download error:', error); + return NextResponse.json( + { success: false, error: error.message || 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/app/globals.css b/app/globals.css index 76d5945..949415f 100644 --- a/app/globals.css +++ b/app/globals.css @@ -145,6 +145,18 @@ } } +@keyframes grid-wave { + 0% { + transform: perspective(100vh) rotateX(60deg) translateY(0) scale(2); + } + 50% { + transform: perspective(120vh) rotateX(50deg) translateY(-2%) scale(2.2); + } + 100% { + transform: perspective(100vh) rotateX(60deg) translateY(0) scale(2); + } +} + @keyframes slide-up { from { opacity: 0; @@ -165,6 +177,15 @@ } } +@keyframes shimmer-text-sweep { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + @layer base { * { @apply border-border outline-ring/50; @@ -190,6 +211,24 @@ animation: pulse-glow 8s ease-in-out infinite; } + /* Retro-futuristic flexible grid */ + .background-grid { + position: fixed; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + z-index: -2; + background-image: + linear-gradient(to right, oklch(1 0 0 / 0.03) 1px, transparent 1px), + linear-gradient(to bottom, oklch(1 0 0 / 0.03) 1px, transparent 1px); + background-size: 60px 60px; + transform-origin: 50% 50%; + animation: grid-wave 20s ease-in-out infinite both alternate; + mask-image: radial-gradient(circle at center, black 0%, transparent 70%); + pointer-events: none; + } + /* Floating orbs */ .floating-orb { position: absolute; @@ -246,6 +285,7 @@ /* Gradient text */ .gradient-text { + position: relative; background: linear-gradient(135deg, oklch(0.85 0.18 280) 0%, oklch(0.75 0.22 300) 50%, @@ -257,6 +297,30 @@ color: transparent; animation: gradient-shift 6s ease infinite; } + + .gradient-text::after { + content: attr(data-text); + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + z-index: 2; + background: linear-gradient( + 110deg, + transparent 35%, + oklch(1 0 0 / 0.3) 45%, + oklch(1 0 0 / 0.7) 50%, + oklch(1 0 0 / 0.3) 55%, + transparent 65% + ); + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + animation: shimmer-text-sweep 4s linear infinite; + pointer-events: none; + } /* Shimmer effect for loading */ .shimmer { diff --git a/app/page.tsx b/app/page.tsx index 2529a50..5381c36 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; @@ -19,6 +19,18 @@ export default function Home() { const [formatType, setFormatType] = useState<'video' | 'audio'>('video'); const [downloadResult, setDownloadResult] = useState(null); const [error, setError] = useState(null); + const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + const x = (e.clientX / window.innerWidth) * 2 - 1; + const y = (e.clientY / window.innerHeight) * 2 - 1; + setMousePos({ x, y }); + }; + + window.addEventListener('mousemove', handleMouseMove); + return () => window.removeEventListener('mousemove', handleMouseMove); + }, []); const handleGetInfo = async () => { if (!url.trim()) { @@ -110,6 +122,16 @@ export default function Home() {
{/* Animated background */}
+ +
+
+
+
@@ -121,7 +143,7 @@ export default function Home() { Fast & Free Downloads
-

+

Downlink

diff --git a/lib/types.ts b/lib/types.ts index ecf5ea9..fed6317 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -9,10 +9,34 @@ export interface DownloadRequest { formatType: FormatType; } +export interface DirectDownloadRequest { + id: string; + format: VideoFormat | AudioFormat; + formatType: FormatType; +} + +export interface DownloadResponse { + + success: boolean; + downloadUrl?: string; + filename?: string; + filePath?: string; + error?: string; + videoInfo?: VideoInfo; +} + + +export interface DirectDownloadRequest { + videoId: string; + format: VideoFormat | AudioFormat; + formatType: FormatType; +} + export interface DownloadResponse { success: boolean; downloadUrl?: string; filename?: string; + filePath?: string; error?: string; videoInfo?: VideoInfo; } diff --git a/lib/utils.ts b/lib/utils.ts index 4153064..01533cf 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -28,3 +28,8 @@ export function extractYouTubeId(url: string): string | null { return null; } + +// Validate a bare YouTube video ID (11 chars, URL-safe) +export function isValidYouTubeId(id: string): boolean { + return /^[A-Za-z0-9_-]{11}$/.test(id); +} diff --git a/lib/youtube.ts b/lib/youtube.ts index 897b585..d8fa52c 100644 --- a/lib/youtube.ts +++ b/lib/youtube.ts @@ -1,6 +1,6 @@ import { exec } from 'child_process'; import { promisify } from 'util'; -import { writeFile, readFile, unlink, mkdir, copyFile } from 'fs/promises'; +import { readFile, unlink, mkdir, copyFile } from 'fs/promises'; import { existsSync } from 'fs'; import path from 'path'; import type { VideoInfo, FormatOption, DownloadRequest, DownloadResponse } from './types';