generated from fahricansecer/boilerplate-fe
main
All checks were successful
UI Deploy - indir.bilgich.com 🎨 / build-and-deploy (push) Successful in 4m8s
All checks were successful
UI Deploy - indir.bilgich.com 🎨 / build-and-deploy (push) Successful in 4m8s
This commit is contained in:
@@ -27,9 +27,10 @@ export default async function RootLayout({
|
||||
<html lang={locale} dir={dir(locale)} suppressHydrationWarning data-scroll-behavior='smooth'>
|
||||
<head>
|
||||
<link rel='apple-touch-icon' sizes='180x180' href='/favicon/apple-touch-icon.png' />
|
||||
<link rel='icon' type='image/png' sizes='32x32' href='/favicon/favicon-32x32.png' />
|
||||
<link rel='icon' type='image/png' sizes='96x96' href='/favicon/favicon-96x96.png' />
|
||||
<link rel='icon' type='image/png' sizes='16x16' href='/favicon/favicon-16x16.png' />
|
||||
<link rel='manifest' href='/favicon/site.webmanifest' />
|
||||
<title>Sosyal Medya Post İndirme</title>
|
||||
</head>
|
||||
<body className={bricolage.variable}>
|
||||
<NextIntlClientProvider>
|
||||
|
||||
@@ -1,5 +1,186 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
/**
|
||||
* Home Page - Video Downloader
|
||||
* Main page for social media video downloads
|
||||
*/
|
||||
|
||||
export default async function Page() {
|
||||
redirect('/home');
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Box, Container, Heading, Text, VStack, Flex } from '@chakra-ui/react';
|
||||
import { FiDownloadCloud } from 'react-icons/fi';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { DownloadForm } from '@/components/site/download/download-form';
|
||||
import { DownloadResult } from '@/components/site/download/download-result';
|
||||
import { detectPlatform } from '@/lib/download/platform-detector';
|
||||
import { Platform, DownloadResponse, AudioFormat } from '@/types/download';
|
||||
import { toaster } from '@/components/ui/feedback/toaster';
|
||||
|
||||
export default function HomePage() {
|
||||
const t = useTranslations('download');
|
||||
const [url, setUrl] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [detectedPlatform, setDetectedPlatform] = useState<Platform | null>(null);
|
||||
const [result, setResult] = useState<DownloadResponse | null>(null);
|
||||
|
||||
// Detect platform when URL changes
|
||||
useEffect(() => {
|
||||
if (url.trim()) {
|
||||
const detection = detectPlatform(url);
|
||||
setDetectedPlatform(detection.platform);
|
||||
} else {
|
||||
setDetectedPlatform(null);
|
||||
}
|
||||
}, [url]);
|
||||
|
||||
// Handle download submission
|
||||
const handleSubmit = useCallback(async (
|
||||
inputUrl: string,
|
||||
audioFormat?: AudioFormat,
|
||||
videoQuality?: string,
|
||||
audioBitrate?: string
|
||||
) => {
|
||||
setUrl(inputUrl);
|
||||
setIsLoading(true);
|
||||
setResult(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/download', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: inputUrl,
|
||||
audioFormat: audioFormat,
|
||||
quality: videoQuality,
|
||||
audioBitrate: audioBitrate,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
setResult(data);
|
||||
|
||||
// Show error toaster if request failed
|
||||
if (!data.success && data.error) {
|
||||
toaster.error({
|
||||
title: t('errors.downloadFailed'),
|
||||
description: data.error.message,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Download error:', error);
|
||||
const errorResponse = {
|
||||
success: false,
|
||||
error: {
|
||||
code: 'NETWORK_ERROR',
|
||||
message: 'Network error. Please check your connection.',
|
||||
},
|
||||
};
|
||||
setResult(errorResponse);
|
||||
|
||||
// Show error toaster
|
||||
toaster.error({
|
||||
title: t('errors.networkError'),
|
||||
description: errorResponse.error.message,
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
// Handle URL change for platform detection
|
||||
const handleUrlChange = useCallback((newUrl: string) => {
|
||||
setUrl(newUrl);
|
||||
}, []);
|
||||
|
||||
// Reset state
|
||||
const handleReset = useCallback(() => {
|
||||
setResult(null);
|
||||
setUrl('');
|
||||
setDetectedPlatform(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Container maxW="container.md" py={{ base: 8, md: 16 }}>
|
||||
<VStack gap={8} align="stretch">
|
||||
{/* Header */}
|
||||
<VStack gap={4} textAlign="center">
|
||||
<Flex
|
||||
w={16}
|
||||
h={16}
|
||||
borderRadius="full"
|
||||
bg="primary.100"
|
||||
_dark={{ bg: 'primary.900' }}
|
||||
align="center"
|
||||
justify="center"
|
||||
mx="auto"
|
||||
>
|
||||
<Box as={FiDownloadCloud} boxSize={8} color="primary.500" />
|
||||
</Flex>
|
||||
<Heading size={{ base: 'xl', md: '2xl' }} fontWeight="bold">
|
||||
{t('title')}
|
||||
</Heading>
|
||||
<Text fontSize="lg" color="fg.muted" maxW="md" mx="auto">
|
||||
{t('subtitle')}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
{/* Main Card */}
|
||||
<Box
|
||||
p={{ base: 4, md: 8 }}
|
||||
borderRadius="2xl"
|
||||
bg="bg.panel"
|
||||
border="1px solid"
|
||||
borderColor="border.emphasized"
|
||||
boxShadow={{ base: 'none', md: 'lg' }}
|
||||
>
|
||||
<VStack gap={6} align="stretch">
|
||||
{/* Form or Result */}
|
||||
{result ? (
|
||||
<DownloadResult result={result} onReset={handleReset} />
|
||||
) : (
|
||||
<DownloadForm
|
||||
onSubmit={handleSubmit}
|
||||
onUrlChange={handleUrlChange}
|
||||
isLoading={isLoading}
|
||||
detectedPlatform={detectedPlatform}
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* Help Section */}
|
||||
<VStack gap={4} textAlign="center">
|
||||
<Text fontSize="sm" color="fg.muted">
|
||||
{t('supportedPlatforms')}
|
||||
</Text>
|
||||
<Flex
|
||||
gap={6}
|
||||
wrap="wrap"
|
||||
justify="center"
|
||||
fontSize="sm"
|
||||
color="fg.subtle"
|
||||
>
|
||||
<Text>YouTube</Text>
|
||||
<Text>Instagram</Text>
|
||||
<Text>TikTok</Text>
|
||||
<Text>X (Twitter)</Text>
|
||||
<Text>Facebook</Text>
|
||||
</Flex>
|
||||
</VStack>
|
||||
|
||||
{/* Disclaimer */}
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="fg.muted"
|
||||
textAlign="center"
|
||||
maxW="md"
|
||||
mx="auto"
|
||||
>
|
||||
{t('disclaimer')}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
184
src/app/api/download/route.ts
Normal file
184
src/app/api/download/route.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Download API Route
|
||||
* Handles social media video download requests
|
||||
*
|
||||
* Supports:
|
||||
* - YouTube: MP4 video, MP3 audio
|
||||
* - Instagram: Video/Image
|
||||
* - TikTok: Video
|
||||
* - Twitter/X: Video
|
||||
* - Facebook: Video
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { validateUrl } from "@/lib/security/url-validator";
|
||||
import { withRateLimit } from "@/lib/security/rate-limiter";
|
||||
import { detectPlatform } from "@/lib/download/platform-detector";
|
||||
import { fetchVideo, fetchYoutubeAudio, fetchYoutubeVideo } from "@/lib/download/downloader";
|
||||
import { DownloadRequest, DownloadResponse } from "@/types/download";
|
||||
|
||||
/**
|
||||
* POST /api/download
|
||||
* Downloads a social media video
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
): Promise<NextResponse<DownloadResponse>> {
|
||||
console.log("========== DOWNLOAD API START ==========");
|
||||
|
||||
try {
|
||||
// 1. Rate limiting check
|
||||
console.log("[1] Checking rate limit...");
|
||||
const rateLimitResult = withRateLimit(request);
|
||||
if (!rateLimitResult.success) {
|
||||
console.log("[1] Rate limit exceeded:", rateLimitResult.error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: "RATE_LIMITED",
|
||||
message: rateLimitResult.error,
|
||||
},
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"Retry-After": rateLimitResult.retryAfter.toString(),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
console.log("[1] Rate limit passed");
|
||||
|
||||
// 2. Parse request body
|
||||
console.log("[2] Parsing request body...");
|
||||
let body: DownloadRequest;
|
||||
try {
|
||||
body = await request.json();
|
||||
console.log("[2] Request body:", JSON.stringify(body, null, 2));
|
||||
} catch (parseError) {
|
||||
console.log("[2] Failed to parse body:", parseError);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: "INVALID_BODY",
|
||||
message: "Invalid request body",
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Validate URL
|
||||
console.log("[3] Validating URL:", body.url);
|
||||
const validation = validateUrl(body.url);
|
||||
console.log("[3] Validation result:", JSON.stringify(validation, null, 2));
|
||||
|
||||
if (!validation.valid) {
|
||||
console.log("[3] URL validation failed:", validation.error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: "INVALID_URL",
|
||||
message: validation.error || "Invalid URL",
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
console.log("[3] URL is valid, sanitized:", validation.sanitizedUrl);
|
||||
|
||||
// 4. Detect platform
|
||||
console.log("[4] Detecting platform...");
|
||||
const platformResult = detectPlatform(validation.sanitizedUrl!);
|
||||
console.log("[4] Platform detected:", JSON.stringify(platformResult, null, 2));
|
||||
|
||||
if (platformResult.platform === "unknown") {
|
||||
console.log("[4] Unknown platform");
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: "UNSUPPORTED_PLATFORM",
|
||||
message:
|
||||
"Unsupported platform. Supported: YouTube, Instagram, TikTok, Twitter/X, Facebook",
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Fetch video/media using appropriate downloader
|
||||
console.log("[5] Fetching media...");
|
||||
console.log("[5] Sending URL:", validation.sanitizedUrl);
|
||||
console.log("[5] Platform:", platformResult.platform);
|
||||
console.log("[5] Audio format requested:", body.audioFormat);
|
||||
|
||||
let downloadResponse: DownloadResponse;
|
||||
|
||||
// YouTube: Support both MP4 video and MP3 audio with quality options
|
||||
if (platformResult.platform === "youtube") {
|
||||
// If audioFormat is specified, download as MP3 audio
|
||||
if (body.audioFormat) {
|
||||
console.log("[5] Downloading YouTube audio (MP3)...");
|
||||
console.log("[5] Audio bitrate:", body.audioBitrate);
|
||||
downloadResponse = await fetchYoutubeAudio(validation.sanitizedUrl!, body.audioBitrate);
|
||||
} else {
|
||||
console.log("[5] Downloading YouTube video (MP4)...");
|
||||
console.log("[5] Video quality:", body.quality);
|
||||
downloadResponse = await fetchYoutubeVideo(validation.sanitizedUrl!, body.quality);
|
||||
}
|
||||
} else {
|
||||
// Other platforms: Use default fetchVideo
|
||||
downloadResponse = await fetchVideo({
|
||||
url: validation.sanitizedUrl!,
|
||||
quality: body.quality,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("[5] Download response:", JSON.stringify(downloadResponse, null, 2));
|
||||
|
||||
// 6. Handle errors
|
||||
if (!downloadResponse.success || downloadResponse.error) {
|
||||
console.log("[6] Download failed:", downloadResponse.error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: downloadResponse.error || {
|
||||
code: "DOWNLOAD_FAILED",
|
||||
message: "Failed to download video",
|
||||
},
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// 7. Return success response
|
||||
console.log("[7] Success! Returning download URL");
|
||||
console.log("========== DOWNLOAD API END (SUCCESS) ==========");
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: downloadResponse.data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("========== DOWNLOAD API ERROR ==========");
|
||||
console.error("Error type:", error?.constructor?.name);
|
||||
console.error("Error message:", error instanceof Error ? error.message : String(error));
|
||||
console.error("Error stack:", error instanceof Error ? error.stack : "No stack trace");
|
||||
console.error("=========================================");
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: {
|
||||
code: "INTERNAL_ERROR",
|
||||
message: error instanceof Error ? error.message : "An unexpected error occurred",
|
||||
},
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
73
src/app/api/proxy/route.ts
Normal file
73
src/app/api/proxy/route.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* GET /api/proxy
|
||||
* Proxies cross-origin URLs to enforce a 'Save As' download in the browser.
|
||||
*
|
||||
* Query parameters:
|
||||
* - url: The remote URL target (e.g. googlevideo.com mp4 link)
|
||||
* - filename: The desired file name for the downloaded file
|
||||
*/
|
||||
export async function GET(request: NextRequest): Promise<NextResponse> {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const url = searchParams.get("url");
|
||||
let filename = searchParams.get("filename") || "download";
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json({ error: "Missing url parameter" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Ensure safe filenames
|
||||
filename = encodeURIComponent(filename).replace(/['()]/g, escape).replace(/\*/g, '%2A');
|
||||
|
||||
try {
|
||||
const requestHeaders: Record<string, string> = {
|
||||
// Some servers like YT check User-Agent.
|
||||
"User-Agent": request.headers.get("User-Agent") || "Mozilla/5.0",
|
||||
};
|
||||
|
||||
// Forward the Range header if the browser sends it for pausing/resuming
|
||||
if (request.headers.has("range")) {
|
||||
requestHeaders["Range"] = request.headers.get("range")!;
|
||||
}
|
||||
|
||||
const remoteResponse = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: requestHeaders,
|
||||
});
|
||||
|
||||
if (!remoteResponse.ok && remoteResponse.status !== 206) {
|
||||
throw new Error(`Failed to fetch remote resource: ${remoteResponse.status}`);
|
||||
}
|
||||
|
||||
// Forward the content type or fallback
|
||||
const contentType = remoteResponse.headers.get("Content-Type") || "application/octet-stream";
|
||||
const contentLength = remoteResponse.headers.get("Content-Length");
|
||||
const acceptRanges = remoteResponse.headers.get("Accept-Ranges");
|
||||
const contentRange = remoteResponse.headers.get("Content-Range");
|
||||
|
||||
// Prepare headers for the proxied response to enforce download
|
||||
const responseHeaders = new Headers();
|
||||
responseHeaders.set("Content-Type", contentType);
|
||||
// Explicitly set Content-Disposition to attachment so the browser downloads the file
|
||||
responseHeaders.set("Content-Disposition", `attachment; filename*=UTF-8''${filename}`);
|
||||
|
||||
// Transfer content headers if known, useful for download progress bars and resuming
|
||||
if (contentLength) responseHeaders.set("Content-Length", contentLength);
|
||||
if (acceptRanges) responseHeaders.set("Accept-Ranges", acceptRanges);
|
||||
if (contentRange) responseHeaders.set("Content-Range", contentRange);
|
||||
|
||||
// Next.js (Edge/App Router) stream support
|
||||
return new NextResponse(remoteResponse.body, {
|
||||
status: remoteResponse.status,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error("[PROXY API] Proxy download failed:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Target file could not be proxied for download" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
243
src/components/site/download/download-form.tsx
Normal file
243
src/components/site/download/download-form.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* Download Form Component
|
||||
* URL input form for social media video downloads
|
||||
* Supports MP4 video and MP3 audio for YouTube
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { Box, Flex, Text, VStack, Spinner, HStack, ButtonGroup } from '@chakra-ui/react';
|
||||
import { FiDownload, FiLink, FiX, FiYoutube, FiInstagram, FiMusic, FiVideo } from 'react-icons/fi';
|
||||
import { FaTiktok, FaFacebook, FaTwitter } from 'react-icons/fa';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Button } from '@/components/ui/buttons/button';
|
||||
import { InputGroup } from '@/components/ui/forms/input-group';
|
||||
import { Field } from '@/components/ui/forms/field';
|
||||
import { Input } from '@chakra-ui/react';
|
||||
import { IconButton } from '@chakra-ui/react';
|
||||
import { Platform, PLATFORM_INFO, AudioFormat, VideoQuality } from '@/types/download';
|
||||
import { NativeSelectRoot, NativeSelectField } from '@/components/ui/forms/native-select';
|
||||
|
||||
interface DownloadFormProps {
|
||||
onSubmit: (url: string, audioFormat?: AudioFormat, videoQuality?: VideoQuality, audioBitrate?: "320" | "256" | "128" | "64") => Promise<void>;
|
||||
onUrlChange?: (url: string) => void;
|
||||
isLoading: boolean;
|
||||
detectedPlatform: Platform | null;
|
||||
}
|
||||
|
||||
const YOUTUBE_VIDEO_QUALITIES = [
|
||||
{ value: '1080', label: '1080p (FHD)' },
|
||||
{ value: '720', label: '720p (HD)' },
|
||||
{ value: '480', label: '480p' },
|
||||
{ value: '360', label: '360p' },
|
||||
];
|
||||
|
||||
const YOUTUBE_AUDIO_BITRATES = [
|
||||
{ value: '320', label: '320 kbps (High)' },
|
||||
{ value: '256', label: '256 kbps' },
|
||||
{ value: '128', label: '128 kbps (Normal)' },
|
||||
{ value: '92', label: '92 kbps (Low)' },
|
||||
];
|
||||
|
||||
export function DownloadForm({ onSubmit, onUrlChange, isLoading, detectedPlatform }: DownloadFormProps) {
|
||||
const t = useTranslations('download');
|
||||
const [url, setUrl] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [downloadingFormat, setDownloadingFormat] = useState<'video' | 'audio' | null>(null);
|
||||
|
||||
// Quality states
|
||||
const [videoQuality, setVideoQuality] = useState<VideoQuality>('720');
|
||||
const [audioBitrate, setAudioBitrate] = useState<"320" | "256" | "128" | "64" | "92">('128');
|
||||
|
||||
const handleDownload = useCallback(async (format: 'video' | 'audio') => {
|
||||
if (!url.trim()) {
|
||||
setError(t('errors.urlRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setDownloadingFormat(format);
|
||||
|
||||
// Pass audio format if downloading audio from YouTube
|
||||
const audioFormat = format === 'audio' ? 'mp3' : undefined;
|
||||
const vQuality = format === 'video' ? videoQuality : undefined;
|
||||
const aBitrate = format === 'audio' ? (audioBitrate as "320" | "256" | "128" | "64") : undefined;
|
||||
|
||||
await onSubmit(url.trim(), audioFormat, vQuality, aBitrate);
|
||||
setDownloadingFormat(null);
|
||||
}, [url, onSubmit, t, videoQuality, audioBitrate]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setUrl('');
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !isLoading && url.trim()) {
|
||||
handleDownload('video');
|
||||
}
|
||||
},
|
||||
[handleDownload, isLoading, url]
|
||||
);
|
||||
|
||||
const getPlatformIcon = (platform: Platform) => {
|
||||
switch (platform) {
|
||||
case 'youtube':
|
||||
return FiYoutube;
|
||||
case 'instagram':
|
||||
return FiInstagram;
|
||||
case 'tiktok':
|
||||
return FaTiktok;
|
||||
case 'twitter':
|
||||
return FaTwitter;
|
||||
case 'facebook':
|
||||
return FaFacebook;
|
||||
default:
|
||||
return FiLink;
|
||||
}
|
||||
};
|
||||
|
||||
const PlatformIcon = detectedPlatform ? getPlatformIcon(detectedPlatform) : FiLink;
|
||||
|
||||
return (
|
||||
<Box as="form" onSubmit={(e) => e.preventDefault()}>
|
||||
<VStack gap={4} align="stretch">
|
||||
<Field errorText={error || undefined} >
|
||||
<InputGroup
|
||||
width={'100%'}
|
||||
startElement={
|
||||
isLoading ? (
|
||||
<Spinner size="sm" color="primary.500" />
|
||||
) : (
|
||||
<Box as={PlatformIcon} color={detectedPlatform ? PLATFORM_INFO[detectedPlatform]?.color : 'gray.500'} />
|
||||
)
|
||||
}
|
||||
endElement={
|
||||
url && !isLoading ? (
|
||||
<IconButton aria-label="Clear" variant="ghost" size="xs" onClick={handleClear}>
|
||||
<Box as={FiX} />
|
||||
</IconButton>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<Input
|
||||
placeholder={t('placeholder')}
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
const newUrl = e.target.value;
|
||||
setUrl(newUrl);
|
||||
setError(null);
|
||||
onUrlChange?.(newUrl);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
borderRadius="xl"
|
||||
disabled={isLoading}
|
||||
size={'xl'}
|
||||
|
||||
/>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
|
||||
{/* YouTube: MP4 and MP3 buttons side by side */}
|
||||
{detectedPlatform === 'youtube' ? (
|
||||
<VStack gap={4} align="stretch" width="full">
|
||||
<Flex gap={4} direction={{ base: 'column', sm: 'row' }} width="full">
|
||||
{/* Video Download Section */}
|
||||
<VStack align="stretch" flex={1} gap={2} p={4} borderWidth="1px" borderRadius="xl" borderColor="border.subtle" bg="bg.muted">
|
||||
<Text fontSize="sm" fontWeight="medium" color="fg.muted">Video Quality</Text>
|
||||
<NativeSelectRoot size="md" disabled={isLoading}>
|
||||
<NativeSelectField
|
||||
value={videoQuality}
|
||||
onChange={(e) => setVideoQuality(e.currentTarget.value as VideoQuality)}
|
||||
items={YOUTUBE_VIDEO_QUALITIES}
|
||||
/>
|
||||
</NativeSelectRoot>
|
||||
<Button
|
||||
type="button"
|
||||
size="md"
|
||||
colorPalette="primary"
|
||||
fontWeight="semibold"
|
||||
onClick={() => handleDownload('video')}
|
||||
loading={downloadingFormat === 'video'}
|
||||
disabled={isLoading || !url.trim()}
|
||||
mt={2}
|
||||
>
|
||||
<Box as={FiVideo} mr={2} />
|
||||
MP4 Video
|
||||
</Button>
|
||||
</VStack>
|
||||
|
||||
{/* Audio Download Section */}
|
||||
<VStack align="stretch" flex={1} gap={2} p={4} borderWidth="1px" borderRadius="xl" borderColor="border.subtle" bg="bg.muted">
|
||||
<Text fontSize="sm" fontWeight="medium" color="fg.muted">Audio Quality</Text>
|
||||
<NativeSelectRoot size="md" disabled={isLoading}>
|
||||
<NativeSelectField
|
||||
value={audioBitrate}
|
||||
onChange={(e) => setAudioBitrate(e.currentTarget.value as any)}
|
||||
items={YOUTUBE_AUDIO_BITRATES}
|
||||
/>
|
||||
</NativeSelectRoot>
|
||||
<Button
|
||||
type="button"
|
||||
size="md"
|
||||
colorPalette="green"
|
||||
fontWeight="semibold"
|
||||
onClick={() => handleDownload('audio')}
|
||||
loading={downloadingFormat === 'audio'}
|
||||
disabled={isLoading || !url.trim()}
|
||||
mt={2}
|
||||
>
|
||||
<Box as={FiMusic} mr={2} />
|
||||
MP3 Audio
|
||||
</Button>
|
||||
</VStack>
|
||||
</Flex>
|
||||
</VStack>
|
||||
) : (
|
||||
/* Other platforms: Single download button */
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
width="full"
|
||||
colorPalette="primary"
|
||||
borderRadius="xl"
|
||||
fontWeight="semibold"
|
||||
onClick={() => handleDownload('video')}
|
||||
loading={isLoading}
|
||||
disabled={isLoading || !url.trim()}
|
||||
>
|
||||
<Box as={FiDownload} mr={2} />
|
||||
{isLoading ? t('downloading') : t('download')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Flex justify="center" gap={4} wrap="wrap">
|
||||
{['youtube', 'instagram', 'tiktok', 'twitter', 'facebook'].map((platform) => {
|
||||
const IconComponent = getPlatformIcon(platform as Platform);
|
||||
return (
|
||||
<Flex
|
||||
key={platform}
|
||||
align="center"
|
||||
gap={1}
|
||||
opacity={detectedPlatform === platform ? 1 : 0.5}
|
||||
transition="opacity 0.2s"
|
||||
>
|
||||
<Box
|
||||
as={IconComponent}
|
||||
color={PLATFORM_INFO[platform as Platform]?.color}
|
||||
boxSize={4}
|
||||
/>
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
{PLATFORM_INFO[platform as Platform]?.name}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</Flex>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
130
src/components/site/download/download-result.tsx
Normal file
130
src/components/site/download/download-result.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Download Result Component
|
||||
* Displays download result (success or error)
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Box, Flex, Text, VStack, Image, HStack } from '@chakra-ui/react';
|
||||
import { FiCheck, FiDownload, FiAlertCircle, FiX } from 'react-icons/fi';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Alert } from '@/components/ui/feedback/alert';
|
||||
import { Button } from '@/components/ui/buttons/button';
|
||||
import { LinkButton } from '@/components/ui/buttons/link-button';
|
||||
import { Tag } from '@/components/ui/data-display/tag';
|
||||
import { Platform, PLATFORM_INFO, DownloadResponse } from '@/types/download';
|
||||
|
||||
interface DownloadResultProps {
|
||||
result: DownloadResponse | null;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function DownloadResult({ result, onReset }: DownloadResultProps) {
|
||||
const t = useTranslations('download');
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
// Error state
|
||||
if (!result.success || !result.data) {
|
||||
return (
|
||||
<Alert status="error" borderRadius="xl" variant="surface" icon={<Box as={FiAlertCircle} />}>
|
||||
<Text fontWeight="medium">{t('error')}</Text>
|
||||
<Text fontSize="sm">{result.error?.message || t('unknownError')}</Text>
|
||||
<Button size="sm" variant="outline" onClick={onReset} ml="auto">
|
||||
<Box as={FiX} mr={1} />
|
||||
{t('newDownload')}
|
||||
</Button>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const { data } = result;
|
||||
|
||||
return (
|
||||
<Box
|
||||
p={6}
|
||||
borderRadius="xl"
|
||||
bg="bg.subtle"
|
||||
border="1px solid"
|
||||
borderColor="border.emphasized"
|
||||
>
|
||||
<VStack gap={4} align="stretch">
|
||||
{/* Success Header */}
|
||||
<Flex align="center" gap={3}>
|
||||
<Flex
|
||||
w={10}
|
||||
h={10}
|
||||
borderRadius="full"
|
||||
bg="green.100"
|
||||
_dark={{ bg: 'green.900' }}
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<Box as={FiCheck} color="green.600" boxSize={5} />
|
||||
</Flex>
|
||||
<Box>
|
||||
<Text fontWeight="semibold" fontSize="lg">
|
||||
{t('readyToDownload')}
|
||||
</Text>
|
||||
<Text fontSize="sm" color="fg.muted">
|
||||
{data.title || data.filename}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
{/* Platform Badge */}
|
||||
{data.platform && data.platform !== 'unknown' && (
|
||||
<HStack>
|
||||
<Tag colorPalette="primary" variant="subtle" borderRadius="md" px={2} py={1}>
|
||||
{PLATFORM_INFO[data.platform]?.name}
|
||||
</Tag>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{/* Thumbnail if available */}
|
||||
{data.thumbnail && (
|
||||
<Box borderRadius="lg" overflow="hidden" maxH="200px">
|
||||
<Image
|
||||
src={data.thumbnail}
|
||||
alt={data.title || 'Video thumbnail'}
|
||||
w="100%"
|
||||
h="auto"
|
||||
objectFit="cover"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<Flex gap={3} direction={{ base: 'column', sm: 'row' }}>
|
||||
<LinkButton
|
||||
href={`/api/proxy?url=${encodeURIComponent(data.downloadUrl)}&filename=${encodeURIComponent(data.filename)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
colorPalette="primary"
|
||||
size="lg"
|
||||
flex={1}
|
||||
borderRadius="xl"
|
||||
>
|
||||
<Box as={FiDownload} mr={2} />
|
||||
{t('downloadFile')}
|
||||
</LinkButton>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
borderRadius="xl"
|
||||
onClick={onReset}
|
||||
>
|
||||
<Box as={FiX} mr={2} />
|
||||
{t('newDownload')}
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{/* Help text */}
|
||||
<Text fontSize="xs" color="fg.muted" textAlign="center">
|
||||
{t('downloadHint')}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,5 @@ export type NavItem = {
|
||||
};
|
||||
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
{ label: "home", href: "/home", public: true },
|
||||
{ label: "predictions", href: "/predictions", public: true },
|
||||
{ label: "home", href: "/", public: true },
|
||||
];
|
||||
|
||||
354
src/lib/download/cobalt-client.ts
Normal file
354
src/lib/download/cobalt-client.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* Cobalt API Client
|
||||
* Handles communication with Cobalt API for social media downloads
|
||||
*/
|
||||
|
||||
import {
|
||||
CobaltResponse,
|
||||
DownloadRequest,
|
||||
VideoQuality,
|
||||
AudioFormat,
|
||||
Platform,
|
||||
} from "@/types/download";
|
||||
|
||||
// Cobalt API instance URL (public instance - v8)
|
||||
// NOTE: v7 API was shut down on Nov 11th 2024
|
||||
const COBALT_API_URL =
|
||||
process.env.COBALT_API_URL || "https://api.cobalt.tools/api/json";
|
||||
|
||||
// Request timeout in milliseconds
|
||||
const REQUEST_TIMEOUT = 30000;
|
||||
|
||||
/**
|
||||
* Maps our quality to Cobalt quality format
|
||||
*/
|
||||
function mapQuality(quality?: VideoQuality): string {
|
||||
const qualityMap: Record<VideoQuality, string> = {
|
||||
max: "max",
|
||||
"1080": "1080",
|
||||
"720": "720",
|
||||
"480": "480",
|
||||
"360": "360",
|
||||
};
|
||||
return quality ? qualityMap[quality] : "720";
|
||||
}
|
||||
|
||||
/**
|
||||
* Cobalt API request body
|
||||
*/
|
||||
interface CobaltRequestBody {
|
||||
url: string;
|
||||
vCodec?: "h264" | "av1" | "vp9";
|
||||
vQuality?: string;
|
||||
aFormat?: AudioFormat;
|
||||
aBitrate?: "320" | "256" | "128" | "64";
|
||||
isAudioOnly?: boolean;
|
||||
isAudioMuted?: boolean;
|
||||
dubBrowserLang?: boolean;
|
||||
filenamePattern?: "classic" | "pretty" | "basic" | "nerdy";
|
||||
twitterGif?: boolean;
|
||||
tiktokFullAudio?: boolean;
|
||||
tiktokH265?: boolean;
|
||||
twitterXUrl?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a request to Cobalt API
|
||||
*/
|
||||
export async function fetchFromCobalt(
|
||||
request: DownloadRequest,
|
||||
): Promise<CobaltResponse> {
|
||||
console.log("[COBALT] Starting fetch for URL:", request.url);
|
||||
console.log("[COBALT] Using API URL:", COBALT_API_URL);
|
||||
|
||||
const requestBody: CobaltRequestBody = {
|
||||
url: request.url,
|
||||
vQuality: mapQuality(request.quality),
|
||||
filenamePattern: "pretty",
|
||||
aFormat: request.audioFormat || "mp3",
|
||||
aBitrate: request.audioBitrate || "128",
|
||||
tiktokFullAudio: true,
|
||||
twitterXUrl: true,
|
||||
};
|
||||
|
||||
console.log("[COBALT] Request body:", JSON.stringify(requestBody, null, 2));
|
||||
|
||||
try {
|
||||
console.log("[COBALT] Sending POST request...");
|
||||
const response = await fetch(COBALT_API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT),
|
||||
});
|
||||
|
||||
console.log("[COBALT] Response status:", response.status, response.statusText);
|
||||
console.log("[COBALT] Response headers:", Object.fromEntries(response.headers.entries()));
|
||||
|
||||
const data = await response.json();
|
||||
console.log("[COBALT] Response data:", JSON.stringify(data, null, 2));
|
||||
|
||||
if (!response.ok) {
|
||||
console.log("[COBALT] Response not OK, handling error...");
|
||||
// Handle Cobalt error response
|
||||
if (data.error) {
|
||||
console.log("[COBALT] Error from Cobalt:", data.error);
|
||||
return {
|
||||
error: {
|
||||
code: data.error.code || "error.api.fetch.fail",
|
||||
message: data.error.message || "Failed to fetch from Cobalt API",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
error: {
|
||||
code: "error.api.fetch.fail",
|
||||
message: `API error: ${response.status} ${response.statusText}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
console.log("[COBALT] Success! Returning data");
|
||||
return data as CobaltResponse;
|
||||
} catch (error) {
|
||||
console.error("[COBALT] Exception occurred:");
|
||||
console.error("[COBALT] Error type:", error?.constructor?.name);
|
||||
console.error("[COBALT] Error message:", error instanceof Error ? error.message : String(error));
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.name === "AbortError" || error.name === "TimeoutError") {
|
||||
return {
|
||||
error: {
|
||||
code: "error.api.fetch.timeout",
|
||||
message: "Request timed out. Please try again.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
error: {
|
||||
code: "error.api.fetch.fail",
|
||||
message: error.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
error: {
|
||||
code: "error.api.fetch.fail",
|
||||
message: "An unexpected error occurred",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets video info from Cobalt API
|
||||
* Note: Cobalt doesn't have a separate info endpoint, so we use the main endpoint
|
||||
* and extract info from the response
|
||||
*/
|
||||
export async function getVideoInfo(
|
||||
url: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: {
|
||||
title?: string;
|
||||
thumbnail?: string;
|
||||
platform?: Platform;
|
||||
};
|
||||
error?: { code: string; message: string };
|
||||
}> {
|
||||
try {
|
||||
// Make a request to get video info
|
||||
const response = await fetchFromCobalt({ url });
|
||||
|
||||
if ("error" in response) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: response.error.code,
|
||||
message: response.error.message || "Unknown error",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Extract available info from Cobalt response
|
||||
if (response.status === "redirect" || response.status === "stream") {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
// Cobalt doesn't provide title/thumbnail in basic response
|
||||
// These would need additional API calls or scraping
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === "picker" && response.picker) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
// Picker means multiple options (e.g., Instagram carousel)
|
||||
thumbnail: response.picker[0]?.thumb,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "error.api.info.fail",
|
||||
message:
|
||||
error instanceof Error ? error.message : "Failed to get video info",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses Cobalt error codes to user-friendly messages
|
||||
*/
|
||||
export function parseCobaltError(
|
||||
errorCode: string,
|
||||
locale: string = "en",
|
||||
): { title: string; description: string } {
|
||||
const messages: Record<
|
||||
string,
|
||||
Record<string, { title: string; description: string }>
|
||||
> = {
|
||||
"error.api.link.invalid": {
|
||||
en: { title: "Invalid URL", description: "The provided URL is not valid." },
|
||||
tr: { title: "Geçersiz URL", description: "Sağlanan URL geçerli değil." },
|
||||
},
|
||||
"error.api.fetch.fail": {
|
||||
en: {
|
||||
title: "Fetch Failed",
|
||||
description: "Could not fetch the content. Please try again.",
|
||||
},
|
||||
tr: {
|
||||
title: "İndirme Başarısız",
|
||||
description: "İçerik alınamadı. Lütfen tekrar deneyin.",
|
||||
},
|
||||
},
|
||||
"error.api.content.unavailable": {
|
||||
en: {
|
||||
title: "Content Unavailable",
|
||||
description: "This content is not available or has been removed.",
|
||||
},
|
||||
tr: {
|
||||
title: "İçerik Mevcut Değil",
|
||||
description: "Bu içerik mevcut değil veya kaldırılmış.",
|
||||
},
|
||||
},
|
||||
"error.api.rate_exceeded": {
|
||||
en: {
|
||||
title: "Rate Limited",
|
||||
description: "Too many requests. Please wait a moment and try again.",
|
||||
},
|
||||
tr: {
|
||||
title: "Sınır Aşıldı",
|
||||
description: "Çok fazla istek. Lütfen bekleyip tekrar deneyin.",
|
||||
},
|
||||
},
|
||||
"error.api.fetch.rate": {
|
||||
en: {
|
||||
title: "Service Busy",
|
||||
description: "The service is currently busy. Please try again later.",
|
||||
},
|
||||
tr: {
|
||||
title: "Servis Meşgul",
|
||||
description: "Servis şu anda meşgul. Lütfen daha sonra tekrar deneyin.",
|
||||
},
|
||||
},
|
||||
"error.api.content.video_region": {
|
||||
en: {
|
||||
title: "Region Restricted",
|
||||
description: "This video is not available in your region.",
|
||||
},
|
||||
tr: {
|
||||
title: "Bölge Kısıtlaması",
|
||||
description: "Bu video bölgenizde mevcut değil.",
|
||||
},
|
||||
},
|
||||
"error.api.content.post.private": {
|
||||
en: {
|
||||
title: "Private Content",
|
||||
description: "This content is private and cannot be downloaded.",
|
||||
},
|
||||
tr: {
|
||||
title: "Özel İçerik",
|
||||
description: "Bu içerik özel ve indirilemez.",
|
||||
},
|
||||
},
|
||||
"error.api.fetch.short": {
|
||||
en: {
|
||||
title: "Link Expired",
|
||||
description: "This link has expired. Please get a fresh link.",
|
||||
},
|
||||
tr: {
|
||||
title: "Bağlantı Süresi Doldu",
|
||||
description: "Bu bağlantının süresi doldu. Lütfen yeni bir bağlantı alın.",
|
||||
},
|
||||
},
|
||||
"error.api.fetch.critical": {
|
||||
en: {
|
||||
title: "Critical Error",
|
||||
description: "A critical error occurred. Please contact support if this persists.",
|
||||
},
|
||||
tr: {
|
||||
title: "Kritik Hata",
|
||||
description: "Kritik bir hata oluştu. Bu devam ederse destekle iletişime geçin.",
|
||||
},
|
||||
},
|
||||
"error.api.fetch.timeout": {
|
||||
en: {
|
||||
title: "Request Timeout",
|
||||
description: "The request took too long. Please try again.",
|
||||
},
|
||||
tr: {
|
||||
title: "Zaman Aşımı",
|
||||
description: "İstek çok uzun sürdü. Lütfen tekrar deneyin.",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const localeMessages = messages[errorCode] || messages["error.api.fetch.fail"];
|
||||
return localeMessages[locale] || localeMessages["en"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets direct download URL from Cobalt response
|
||||
*/
|
||||
export function getDownloadUrl(response: CobaltResponse): string | null {
|
||||
console.log("[COBALT] Extracting download URL from response...");
|
||||
|
||||
if ("error" in response) {
|
||||
console.log("[COBALT] Response has error, no download URL");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (response.status === "redirect" && response.url) {
|
||||
console.log("[COBALT] Found redirect URL:", response.url);
|
||||
return response.url;
|
||||
}
|
||||
|
||||
if (response.status === "stream" && response.url) {
|
||||
console.log("[COBALT] Found stream URL:", response.url);
|
||||
return response.url;
|
||||
}
|
||||
|
||||
if (response.status === "picker" && response.picker?.length) {
|
||||
console.log("[COBALT] Found picker URL:", response.picker[0].url);
|
||||
return response.picker[0].url;
|
||||
}
|
||||
|
||||
console.log("[COBALT] No download URL found in response");
|
||||
return null;
|
||||
}
|
||||
693
src/lib/download/downloader.ts
Normal file
693
src/lib/download/downloader.ts
Normal file
@@ -0,0 +1,693 @@
|
||||
/**
|
||||
* Social Media Video Downloader using multiple packages
|
||||
* Supports: Instagram, YouTube, TikTok, Twitter/X, Facebook
|
||||
*
|
||||
* - @vreden/youtube_scraper: YouTube (video/audio with quality options)
|
||||
* - snapsave-media-downloader: Instagram, TikTok, Twitter/X
|
||||
* - yt-dlp (global): Facebook (fallback)
|
||||
*/
|
||||
|
||||
import { snapsave } from "snapsave-media-downloader";
|
||||
import { spawn } from "child_process";
|
||||
import {
|
||||
DownloadRequest,
|
||||
DownloadResponse,
|
||||
Platform,
|
||||
MediaType,
|
||||
VideoQuality,
|
||||
AudioFormat,
|
||||
} from "@/types/download";
|
||||
|
||||
// Removed @vreden types
|
||||
|
||||
// ============================================
|
||||
// SnapSave API response types
|
||||
// ============================================
|
||||
interface SnapSaveMedia {
|
||||
url?: string;
|
||||
thumbnail?: string;
|
||||
type?: "video" | "image";
|
||||
resolution?: string;
|
||||
shouldRender?: boolean;
|
||||
}
|
||||
|
||||
interface SnapSaveData {
|
||||
description?: string;
|
||||
preview?: string;
|
||||
media?: SnapSaveMedia[];
|
||||
}
|
||||
|
||||
interface SnapSaveResponse {
|
||||
success: boolean;
|
||||
data?: SnapSaveData;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// yt-dlp video info type
|
||||
// ============================================
|
||||
interface YtDlpVideoInfo {
|
||||
id?: string;
|
||||
title?: string;
|
||||
uploader?: string;
|
||||
uploader_id?: string;
|
||||
thumbnail?: string;
|
||||
duration?: number;
|
||||
duration_string?: string;
|
||||
view_count?: number;
|
||||
like_count?: number;
|
||||
description?: string;
|
||||
formats?: YtDlpFormat[];
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface YtDlpFormat {
|
||||
format_id?: string;
|
||||
format_note?: string;
|
||||
ext?: string;
|
||||
url?: string;
|
||||
acodec?: string;
|
||||
vcodec?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
filesize?: number;
|
||||
abr?: number;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Platform Detection
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Detects the platform from a URL
|
||||
*/
|
||||
export function detectPlatform(url: string): Platform {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const hostname = urlObj.hostname.toLowerCase().replace(/^www\./, "");
|
||||
|
||||
if (
|
||||
hostname === "youtube.com" ||
|
||||
hostname === "youtu.be" ||
|
||||
hostname.includes("youtube")
|
||||
) {
|
||||
return "youtube";
|
||||
}
|
||||
|
||||
if (
|
||||
hostname === "instagram.com" ||
|
||||
hostname.includes("instagram")
|
||||
) {
|
||||
return "instagram";
|
||||
}
|
||||
|
||||
if (
|
||||
hostname === "tiktok.com" ||
|
||||
hostname.includes("tiktok")
|
||||
) {
|
||||
return "tiktok";
|
||||
}
|
||||
|
||||
if (
|
||||
hostname === "twitter.com" ||
|
||||
hostname === "x.com" ||
|
||||
hostname.includes("twitter")
|
||||
) {
|
||||
return "twitter";
|
||||
}
|
||||
|
||||
if (
|
||||
hostname === "facebook.com" ||
|
||||
hostname.includes("facebook") ||
|
||||
hostname === "fb.watch"
|
||||
) {
|
||||
return "facebook";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Helper Functions
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Detects media type from URL
|
||||
*/
|
||||
function detectMediaType(url: string): MediaType {
|
||||
const lowerUrl = url.toLowerCase();
|
||||
if (
|
||||
lowerUrl.includes(".mp4") ||
|
||||
lowerUrl.includes(".webm") ||
|
||||
lowerUrl.includes(".mkv") ||
|
||||
lowerUrl.includes("video")
|
||||
) {
|
||||
return "video";
|
||||
}
|
||||
if (
|
||||
lowerUrl.includes(".mp3") ||
|
||||
lowerUrl.includes(".m4a") ||
|
||||
lowerUrl.includes(".wav") ||
|
||||
lowerUrl.includes("audio")
|
||||
) {
|
||||
return "audio";
|
||||
}
|
||||
if (
|
||||
lowerUrl.includes(".jpg") ||
|
||||
lowerUrl.includes(".jpeg") ||
|
||||
lowerUrl.includes(".png") ||
|
||||
lowerUrl.includes(".gif") ||
|
||||
lowerUrl.includes(".webp")
|
||||
) {
|
||||
return "image";
|
||||
}
|
||||
return "video"; // Default to video
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets file extension from URL
|
||||
*/
|
||||
function getFileExtension(url: string, mediaType?: string): string {
|
||||
const lowerUrl = url.toLowerCase();
|
||||
if (lowerUrl.includes(".mp4")) return ".mp4";
|
||||
if (lowerUrl.includes(".webm")) return ".webm";
|
||||
if (lowerUrl.includes(".mkv")) return ".mkv";
|
||||
if (lowerUrl.includes(".mp3")) return ".mp3";
|
||||
if (lowerUrl.includes(".m4a")) return ".m4a";
|
||||
if (lowerUrl.includes(".jpg") || lowerUrl.includes(".jpeg")) return ".jpg";
|
||||
if (lowerUrl.includes(".png")) return ".png";
|
||||
if (lowerUrl.includes(".gif")) return ".gif";
|
||||
if (lowerUrl.includes(".webp")) return ".webp";
|
||||
|
||||
if (mediaType === "image") return ".jpg";
|
||||
if (mediaType === "audio") return ".mp3";
|
||||
return ".mp4";
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps VideoQuality to numeric value for filtering
|
||||
*/
|
||||
function getQualityNumber(quality?: VideoQuality): number {
|
||||
if (!quality || quality === "max") return 1080;
|
||||
return parseInt(quality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run yt-dlp command and get JSON output
|
||||
*/
|
||||
async function runYtdlp(url: string, args: string[] = []): Promise<YtDlpVideoInfo> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const defaultArgs = [
|
||||
"--dump-single-json",
|
||||
"--no-warnings",
|
||||
"--no-check-certificates",
|
||||
"--prefer-free-formats",
|
||||
];
|
||||
|
||||
const allArgs = [...defaultArgs, ...args, url];
|
||||
|
||||
console.log("[YT-DLP] Running command: yt-dlp", allArgs.join(" "));
|
||||
|
||||
const process = spawn("yt-dlp", allArgs);
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
process.stdout.on("data", (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
process.stderr.on("data", (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
process.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
console.error("[YT-DLP] Error:", stderr);
|
||||
reject(new Error(stderr || `yt-dlp exited with code ${code}`));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const info = JSON.parse(stdout) as YtDlpVideoInfo;
|
||||
resolve(info);
|
||||
} catch (parseError) {
|
||||
reject(new Error("Failed to parse yt-dlp output"));
|
||||
}
|
||||
});
|
||||
|
||||
process.on("error", (error) => {
|
||||
console.error("[YT-DLP] Spawn error:", error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
/**
|
||||
* Download YouTube video via yt-dlp
|
||||
*/
|
||||
async function downloadYoutubeVideo(
|
||||
url: string,
|
||||
quality?: VideoQuality
|
||||
): Promise<DownloadResponse> {
|
||||
console.log("[YT-DLP-YOUTUBE] Downloading video:", url, "Quality:", quality);
|
||||
|
||||
try {
|
||||
const targetHeight = getQualityNumber(quality);
|
||||
const info = await runYtdlp(url);
|
||||
|
||||
// Find formats that have BOTH video and audio mixed into a single MP4 file.
|
||||
// yt-dlp lists these as having vcodec and acodec
|
||||
const videoFormats = info.formats?.filter(
|
||||
(f: YtDlpFormat) =>
|
||||
f.url &&
|
||||
f.ext === "mp4" &&
|
||||
f.vcodec !== "none" &&
|
||||
f.acodec !== "none" &&
|
||||
!f.url.includes(".m3u8") &&
|
||||
!f.url.includes("/manifest/")
|
||||
);
|
||||
|
||||
if (!videoFormats?.length) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "error.download.no_result",
|
||||
message: "Uygun video formatı bulunamadı.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Sort to find the requested quality or closest lower quality
|
||||
const sortedFormats = videoFormats.sort((a, b) => (b.height || 0) - (a.height || 0));
|
||||
|
||||
let bestFormat = sortedFormats.find(f => (f.height || 0) <= targetHeight) || sortedFormats[0];
|
||||
|
||||
// Some 1080p combined might not exist (YouTube usually separates them), fallback gracefully
|
||||
if (!bestFormat) {
|
||||
bestFormat = sortedFormats[0];
|
||||
}
|
||||
|
||||
const safeTitle = info.title?.replace(/[^a-zA-Z0-9ğüşıöçĞÜŞİÖÇ\s]/g, "_") || "youtube_video";
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
downloadUrl: bestFormat.url!,
|
||||
filename: `${safeTitle}.mp4`,
|
||||
platform: "youtube",
|
||||
title: info.title,
|
||||
thumbnail: info.thumbnail,
|
||||
mediaType: "video",
|
||||
duration: info.duration_string,
|
||||
author: info.uploader,
|
||||
views: info.view_count?.toString(),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[YT-DLP-YOUTUBE] Video download error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "error.download.failed",
|
||||
message: error instanceof Error ? error.message : "YouTube video indirme hatası",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download YouTube audio via yt-dlp
|
||||
*/
|
||||
async function downloadYoutubeAudio(
|
||||
url: string,
|
||||
format?: AudioFormat,
|
||||
bitrate?: string
|
||||
): Promise<DownloadResponse> {
|
||||
console.log("[YT-DLP-YOUTUBE] Downloading audio:", url, "Bitrate:", bitrate);
|
||||
|
||||
try {
|
||||
const targetBitrate = bitrate ? parseInt(bitrate) : 128;
|
||||
const info = await runYtdlp(url);
|
||||
|
||||
// Find audio-only formats (m4a or webm/mp3 usually without vcodec)
|
||||
const audioFormats = info.formats?.filter(
|
||||
(f: YtDlpFormat) =>
|
||||
f.url &&
|
||||
f.vcodec === "none" &&
|
||||
f.acodec !== "none" &&
|
||||
(f.ext === "m4a" || f.ext === "webm") &&
|
||||
!f.url.includes(".m3u8") &&
|
||||
!f.url.includes("/manifest/")
|
||||
);
|
||||
|
||||
if (!audioFormats?.length) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "error.download.no_result",
|
||||
message: "Uygun ses formatı bulunamadı.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// We prefer higher audio bitrate equal or closest to requested.
|
||||
const sortedFormats = audioFormats.sort((a, b) => (b.abr || 0) - (a.abr || 0));
|
||||
let bestFormat = sortedFormats.find(f => (f.abr || 0) <= targetBitrate) || sortedFormats[0];
|
||||
|
||||
const safeTitle = info.title?.replace(/[^a-zA-Z0-9ğüşıöçĞÜŞİÖÇ\s]/g, "_") || "youtube_audio";
|
||||
|
||||
// YouTube's M4A is widely supported as direct download
|
||||
const ext = bestFormat.ext || "m4a";
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
downloadUrl: bestFormat.url!,
|
||||
filename: `${safeTitle}.${ext}`,
|
||||
platform: "youtube",
|
||||
title: info.title,
|
||||
thumbnail: info.thumbnail,
|
||||
mediaType: "audio",
|
||||
duration: info.duration_string,
|
||||
author: info.uploader,
|
||||
views: info.view_count?.toString(),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[YT-DLP-YOUTUBE] Audio download error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "error.download.failed",
|
||||
message: error instanceof Error ? error.message : "YouTube ses indirme hatası",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get YouTube video metadata without downloading via yt-dlp
|
||||
*/
|
||||
async function getYoutubeMetadata(url: string): Promise<YtDlpVideoInfo | null> {
|
||||
console.log("[YT-DLP-YOUTUBE] Getting metadata for:", url);
|
||||
|
||||
try {
|
||||
const result = await runYtdlp(url);
|
||||
return result || null;
|
||||
} catch (error) {
|
||||
console.error("[YT-DLP-YOUTUBE] Metadata error:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Facebook Download Functions (yt-dlp fallback)
|
||||
// ============================================
|
||||
|
||||
// Removed obsolete runYtdlp (it is moved UP)
|
||||
|
||||
/**
|
||||
* Download from Facebook using yt-dlp
|
||||
*/
|
||||
async function downloadFromFacebookYtDlp(url: string): Promise<DownloadResponse> {
|
||||
console.log("[YT-DLP] Downloading Facebook:", url);
|
||||
|
||||
try {
|
||||
const info = await runYtdlp(url);
|
||||
|
||||
console.log("[YT-DLP] Facebook info:", {
|
||||
title: info.title,
|
||||
uploader: info.uploader,
|
||||
duration: info.duration_string,
|
||||
});
|
||||
|
||||
// Find video format with URL
|
||||
const videoFormats = info.formats?.filter(
|
||||
(f: YtDlpFormat) => f.url && (f.ext === "mp4" || f.vcodec !== "none")
|
||||
);
|
||||
|
||||
if (!videoFormats?.length) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "error.download.no_result",
|
||||
message: "Video formatı bulunamadı",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Prefer higher quality
|
||||
const bestFormat =
|
||||
videoFormats.find(
|
||||
(f: YtDlpFormat) =>
|
||||
f.format_note?.includes("1080") || f.format_note?.includes("720")
|
||||
) || videoFormats[0];
|
||||
|
||||
const title =
|
||||
info.title?.replace(/[^a-zA-Z0-9ğüşıöçĞÜŞİÖÇ\s]/g, "_") || "facebook_video";
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
downloadUrl: bestFormat.url!,
|
||||
filename: `${title}.mp4`,
|
||||
platform: "facebook",
|
||||
title: info.title,
|
||||
thumbnail: info.thumbnail,
|
||||
mediaType: "video",
|
||||
duration: info.duration_string,
|
||||
author: info.uploader,
|
||||
views: info.view_count?.toString(),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[YT-DLP] Facebook error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "error.download.failed",
|
||||
message: error instanceof Error ? error.message : "Facebook indirme hatası",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SnapSave Download Functions (Instagram, TikTok, Twitter)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Download using SnapSave (Instagram, TikTok, Twitter)
|
||||
*/
|
||||
async function downloadWithSnapSave(
|
||||
url: string,
|
||||
platform: Platform
|
||||
): Promise<DownloadResponse> {
|
||||
console.log(`[SNAPSAVE] Downloading ${platform}:`, url);
|
||||
|
||||
try {
|
||||
const result: SnapSaveResponse = await snapsave(url);
|
||||
console.log(`[SNAPSAVE] ${platform} result:`, JSON.stringify(result, null, 2));
|
||||
|
||||
if (!result.success || !result.data?.media?.length) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "error.download.no_result",
|
||||
message: result.message || "İçerik bulunamadı",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Get the best quality media (prefer HD video)
|
||||
const media = result.data.media;
|
||||
let selectedMedia: SnapSaveMedia | null = null;
|
||||
|
||||
// For videos, prefer HD quality
|
||||
if (media.some((m) => m.type === "video")) {
|
||||
// Sort by resolution (prefer higher quality)
|
||||
const videos = media.filter((m) => m.type === "video");
|
||||
|
||||
// Prefer 1080p, then 720p, then HD, then any video
|
||||
selectedMedia =
|
||||
videos.find((m) => m.resolution?.includes("1080")) ||
|
||||
videos.find((m) => m.resolution?.includes("720")) ||
|
||||
videos.find((m) => m.resolution?.includes("HD")) ||
|
||||
videos.find((m) => !m.shouldRender) ||
|
||||
videos[0];
|
||||
} else {
|
||||
// For images, take the first one
|
||||
selectedMedia = media[0];
|
||||
}
|
||||
|
||||
if (!selectedMedia?.url) {
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "error.download.no_result",
|
||||
message: "Geçerli bir medya URL'si bulunamadı",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const mediaType = selectedMedia.type || detectMediaType(selectedMedia.url);
|
||||
|
||||
// Decode SnapSave render token if present so we download the real file instead of an HTML page
|
||||
let finalDownloadUrl = selectedMedia.url;
|
||||
if (finalDownloadUrl.includes("render.php?token=")) {
|
||||
try {
|
||||
const tokenParts = finalDownloadUrl.split("token=")[1]?.split(".");
|
||||
if (tokenParts && tokenParts.length > 1) {
|
||||
const payload = tokenParts[1];
|
||||
const decoded = Buffer.from(payload, "base64").toString("utf8");
|
||||
const parsed = JSON.parse(decoded);
|
||||
// Prefer audio_url if mediaType is audio, otherwise video_url
|
||||
if (mediaType === "audio" && parsed.audio_url) {
|
||||
finalDownloadUrl = parsed.audio_url;
|
||||
} else if (parsed.video_url) {
|
||||
finalDownloadUrl = parsed.video_url;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("[SNAPSAVE] Failed to decode render token", e);
|
||||
}
|
||||
}
|
||||
|
||||
const ext = getFileExtension(finalDownloadUrl, mediaType);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
downloadUrl: finalDownloadUrl,
|
||||
filename: `${platform}_${Date.now()}${ext}`,
|
||||
platform,
|
||||
title: result.data.description,
|
||||
thumbnail: result.data.preview || selectedMedia.thumbnail,
|
||||
mediaType,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[SNAPSAVE] ${platform} error:`, error);
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "error.download.failed",
|
||||
message: error instanceof Error ? error.message : `${platform} indirme hatası`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Main Download Functions
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Main download function
|
||||
* Routes to platform-specific downloaders based on URL and request parameters
|
||||
*/
|
||||
export async function fetchVideo(request: DownloadRequest): Promise<DownloadResponse> {
|
||||
console.log("[DOWNLOADER] Starting download for URL:", request.url);
|
||||
console.log("[DOWNLOADER] Request parameters:", {
|
||||
quality: request.quality,
|
||||
audioFormat: request.audioFormat,
|
||||
audioBitrate: request.audioBitrate,
|
||||
});
|
||||
|
||||
const platform = detectPlatform(request.url);
|
||||
console.log("[DOWNLOADER] Detected platform:", platform);
|
||||
|
||||
switch (platform) {
|
||||
case "youtube":
|
||||
// If audioFormat is specified, download as audio (MP3)
|
||||
if (request.audioFormat) {
|
||||
console.log("[DOWNLOADER] YouTube audio download requested");
|
||||
return downloadYoutubeAudio(request.url, request.audioFormat, request.audioBitrate);
|
||||
}
|
||||
// Otherwise download as video (MP4)
|
||||
console.log("[DOWNLOADER] YouTube video download requested");
|
||||
return downloadYoutubeVideo(request.url, request.quality);
|
||||
|
||||
case "instagram":
|
||||
return downloadWithSnapSave(request.url, platform);
|
||||
|
||||
case "tiktok":
|
||||
return downloadWithSnapSave(request.url, platform);
|
||||
|
||||
case "twitter":
|
||||
return downloadWithSnapSave(request.url, platform);
|
||||
|
||||
case "facebook":
|
||||
console.log("[DOWNLOADER] Facebook video download requested");
|
||||
const snapResult = await downloadWithSnapSave(request.url, platform);
|
||||
if (snapResult.success) {
|
||||
return snapResult;
|
||||
}
|
||||
console.log("[DOWNLOADER] SnapSave failed for Facebook, falling back to yt-dlp...");
|
||||
return downloadFromFacebookYtDlp(request.url);
|
||||
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
error: {
|
||||
code: "error.download.unsupported_platform",
|
||||
message:
|
||||
"Desteklenmeyen platform. Desteklenenler: YouTube, Instagram, TikTok, Twitter/X, Facebook",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download YouTube video (MP4)
|
||||
* @param url - YouTube URL or video ID
|
||||
* @param quality - Video quality (144, 360, 480, 720, 1080)
|
||||
*/
|
||||
export async function fetchYoutubeVideo(
|
||||
url: string,
|
||||
quality?: VideoQuality
|
||||
): Promise<DownloadResponse> {
|
||||
return downloadYoutubeVideo(url, quality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download YouTube audio (MP3)
|
||||
* @param url - YouTube URL or video ID
|
||||
* @param bitrate - Audio bitrate (92, 128, 256, 320 kbps)
|
||||
*/
|
||||
export async function fetchYoutubeAudio(
|
||||
url: string,
|
||||
bitrate?: string
|
||||
): Promise<DownloadResponse> {
|
||||
return downloadYoutubeAudio(url, undefined, bitrate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets video info without downloading
|
||||
*/
|
||||
export async function getVideoInfo(
|
||||
url: string
|
||||
): Promise<{ title?: string; thumbnail?: string; platform?: Platform }> {
|
||||
const platform = detectPlatform(url);
|
||||
|
||||
if (platform === "youtube") {
|
||||
const metadata = await getYoutubeMetadata(url);
|
||||
if (metadata) {
|
||||
return {
|
||||
title: metadata.title,
|
||||
thumbnail: metadata.thumbnail,
|
||||
platform,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { platform: platform || undefined };
|
||||
}
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { DownloadRequest, DownloadResponse, Platform, MediaType };
|
||||
159
src/lib/download/platform-detector.ts
Normal file
159
src/lib/download/platform-detector.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Platform Detector
|
||||
* Detects social media platform from URL
|
||||
*/
|
||||
|
||||
import {
|
||||
Platform,
|
||||
PlatformDetectionResult,
|
||||
SUPPORTED_DOMAINS,
|
||||
} from "@/types/download";
|
||||
|
||||
/**
|
||||
* Detects the social media platform from a URL
|
||||
* @param url - The URL to detect platform from
|
||||
* @returns PlatformDetectionResult with platform and confidence
|
||||
*/
|
||||
export function detectPlatform(url: string): PlatformDetectionResult {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const hostname = urlObj.hostname.toLowerCase().replace("www.", "");
|
||||
|
||||
// Check each platform's domains
|
||||
for (const [platform, domains] of Object.entries(SUPPORTED_DOMAINS) as [
|
||||
Platform,
|
||||
string[],
|
||||
][]) {
|
||||
if (platform === "unknown") continue;
|
||||
|
||||
for (const domain of domains) {
|
||||
if (hostname === domain || hostname.endsWith("." + domain)) {
|
||||
return {
|
||||
platform,
|
||||
confidence: "high",
|
||||
originalUrl: url,
|
||||
normalizedUrl: normalizeUrl(url, platform),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for known URL patterns even if domain doesn't match exactly
|
||||
const patternResult = detectByPattern(url);
|
||||
if (patternResult) {
|
||||
return patternResult;
|
||||
}
|
||||
|
||||
return {
|
||||
platform: "unknown",
|
||||
confidence: "low",
|
||||
originalUrl: url,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
platform: "unknown",
|
||||
confidence: "low",
|
||||
originalUrl: url,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects platform by URL patterns
|
||||
*/
|
||||
function detectByPattern(url: string): PlatformDetectionResult | null {
|
||||
const patterns: { pattern: RegExp; platform: Platform }[] = [
|
||||
// YouTube patterns
|
||||
{ pattern: /youtube\.com\/watch\?v=[\w-]+/i, platform: "youtube" },
|
||||
{ pattern: /youtu\.be\/[\w-]+/i, platform: "youtube" },
|
||||
{ pattern: /youtube\.com\/shorts\/[\w-]+/i, platform: "youtube" },
|
||||
{ pattern: /youtube\.com\/embed\/[\w-]+/i, platform: "youtube" },
|
||||
|
||||
// Instagram patterns
|
||||
{ pattern: /instagram\.com\/(p|reel|tv)\/[\w-]+/i, platform: "instagram" },
|
||||
{ pattern: /instagr\.am\/(p|reel|tv)\/[\w-]+/i, platform: "instagram" },
|
||||
|
||||
// TikTok patterns
|
||||
{ pattern: /tiktok\.com\/@[\w.]+\/video\/\d+/i, platform: "tiktok" },
|
||||
{ pattern: /vm\.tiktok\.com\/[\w-]+/i, platform: "tiktok" },
|
||||
{ pattern: /vt\.tiktok\.com\/[\w-]+/i, platform: "tiktok" },
|
||||
|
||||
// Twitter/X patterns
|
||||
{ pattern: /twitter\.com\/\w+\/status\/\d+/i, platform: "twitter" },
|
||||
{ pattern: /x\.com\/\w+\/status\/\d+/i, platform: "twitter" },
|
||||
|
||||
// Facebook patterns
|
||||
{ pattern: /facebook\.com\/.*\/videos\/\d+/i, platform: "facebook" },
|
||||
{ pattern: /facebook\.com\/watch\/?\?v=\d+/i, platform: "facebook" },
|
||||
{ pattern: /fb\.watch\/[\w-]+/i, platform: "facebook" },
|
||||
];
|
||||
|
||||
for (const { pattern, platform } of patterns) {
|
||||
if (pattern.test(url)) {
|
||||
return {
|
||||
platform,
|
||||
confidence: "high",
|
||||
originalUrl: url,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes URL for specific platforms
|
||||
* Some platforms need URL normalization for better API compatibility
|
||||
*/
|
||||
function normalizeUrl(url: string, platform: Platform): string {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
switch (platform) {
|
||||
case "youtube": {
|
||||
// Convert youtu.be to youtube.com
|
||||
if (urlObj.hostname === "youtu.be") {
|
||||
const videoId = urlObj.pathname.slice(1);
|
||||
return `https://www.youtube.com/watch?v=${videoId}`;
|
||||
}
|
||||
// Convert shorts to regular watch URL
|
||||
if (urlObj.pathname.startsWith("/shorts/")) {
|
||||
const videoId = urlObj.pathname.replace("/shorts/", "");
|
||||
return `https://www.youtube.com/watch?v=${videoId}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
case "tiktok": {
|
||||
// Keep the original URL - Cobalt handles short URLs
|
||||
return url;
|
||||
}
|
||||
|
||||
case "twitter": {
|
||||
// Ensure x.com URLs are handled properly
|
||||
return url;
|
||||
}
|
||||
|
||||
default:
|
||||
return url;
|
||||
}
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a platform is supported
|
||||
*/
|
||||
export function isPlatformSupported(platform: Platform): boolean {
|
||||
return platform !== "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the best URL to use for downloading
|
||||
* Some platforms work better with normalized URLs
|
||||
*/
|
||||
export function getBestUrlForDownload(url: string): string {
|
||||
const result = detectPlatform(url);
|
||||
return result.normalizedUrl || result.originalUrl;
|
||||
}
|
||||
220
src/lib/security/rate-limiter.ts
Normal file
220
src/lib/security/rate-limiter.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Simple Rate Limiter
|
||||
* IP-based rate limiting for API endpoints
|
||||
*/
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
interface RateLimitEntry {
|
||||
count: number;
|
||||
resetTime: number;
|
||||
blocked: boolean;
|
||||
}
|
||||
|
||||
interface RateLimitConfig {
|
||||
windowMs: number; // Time window in milliseconds
|
||||
maxRequests: number; // Max requests per window
|
||||
blockDurationMs: number; // How long to block after exceeding limit
|
||||
}
|
||||
|
||||
// In-memory store (resets on server restart)
|
||||
// For production, use Redis or similar
|
||||
const rateLimitStore = new Map<string, RateLimitEntry>();
|
||||
|
||||
// Cleanup interval (every 10 minutes)
|
||||
const CLEANUP_INTERVAL = 10 * 60 * 1000;
|
||||
|
||||
// Default rate limit config
|
||||
const DEFAULT_CONFIG: RateLimitConfig = {
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
maxRequests: 20, // 20 requests per hour per IP
|
||||
blockDurationMs: 30 * 60 * 1000, // 30 minutes block
|
||||
};
|
||||
|
||||
// Stricter config for unauthenticated users
|
||||
const PUBLIC_CONFIG: RateLimitConfig = {
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
maxRequests: 10, // 10 requests per hour
|
||||
blockDurationMs: 60 * 60 * 1000, // 1 hour block
|
||||
};
|
||||
|
||||
/**
|
||||
* Cleanup old entries from the rate limit store
|
||||
*/
|
||||
function cleanupStore(): void {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of rateLimitStore.entries()) {
|
||||
if (entry.resetTime < now && !entry.blocked) {
|
||||
rateLimitStore.delete(key);
|
||||
} else if (
|
||||
entry.blocked &&
|
||||
entry.resetTime < now - DEFAULT_CONFIG.blockDurationMs
|
||||
) {
|
||||
rateLimitStore.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run cleanup periodically
|
||||
if (typeof setInterval !== "undefined") {
|
||||
setInterval(cleanupStore, CLEANUP_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets client IP from request
|
||||
*/
|
||||
export function getClientIp(request: NextRequest): string {
|
||||
// Try various headers that might contain the real IP
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
if (forwarded) {
|
||||
// x-forwarded-for can contain multiple IPs, first is the client
|
||||
return forwarded.split(",")[0].trim();
|
||||
}
|
||||
|
||||
const realIp = request.headers.get("x-real-ip");
|
||||
if (realIp) {
|
||||
return realIp;
|
||||
}
|
||||
|
||||
// Fallback to a default (in development)
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an IP is rate limited
|
||||
*/
|
||||
export function checkRateLimit(
|
||||
ip: string,
|
||||
config: RateLimitConfig = DEFAULT_CONFIG,
|
||||
): {
|
||||
allowed: boolean;
|
||||
remaining: number;
|
||||
resetTime: number;
|
||||
blocked: boolean;
|
||||
} {
|
||||
const now = Date.now();
|
||||
const entry = rateLimitStore.get(ip);
|
||||
|
||||
// If no entry exists, create one
|
||||
if (!entry) {
|
||||
rateLimitStore.set(ip, {
|
||||
count: 1,
|
||||
resetTime: now + config.windowMs,
|
||||
blocked: false,
|
||||
});
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: config.maxRequests - 1,
|
||||
resetTime: now + config.windowMs,
|
||||
blocked: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if currently blocked
|
||||
if (entry.blocked) {
|
||||
const blockEndTime = entry.resetTime + config.blockDurationMs;
|
||||
if (now < blockEndTime) {
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetTime: blockEndTime,
|
||||
blocked: true,
|
||||
};
|
||||
}
|
||||
// Block expired, reset
|
||||
entry.blocked = false;
|
||||
entry.count = 0;
|
||||
entry.resetTime = now + config.windowMs;
|
||||
}
|
||||
|
||||
// Check if window has expired
|
||||
if (now > entry.resetTime) {
|
||||
entry.count = 1;
|
||||
entry.resetTime = now + config.windowMs;
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: config.maxRequests - 1,
|
||||
resetTime: entry.resetTime,
|
||||
blocked: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if limit exceeded
|
||||
if (entry.count >= config.maxRequests) {
|
||||
entry.blocked = true;
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetTime: entry.resetTime + config.blockDurationMs,
|
||||
blocked: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Increment count
|
||||
entry.count++;
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: config.maxRequests - entry.count,
|
||||
resetTime: entry.resetTime,
|
||||
blocked: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit middleware for API routes
|
||||
*/
|
||||
export function withRateLimit(
|
||||
request: NextRequest,
|
||||
config: RateLimitConfig = PUBLIC_CONFIG,
|
||||
): { success: true } | { success: false; error: string; retryAfter: number } {
|
||||
const ip = getClientIp(request);
|
||||
const result = checkRateLimit(ip, config);
|
||||
|
||||
if (!result.allowed) {
|
||||
const retryAfter = Math.ceil((result.resetTime - Date.now()) / 1000);
|
||||
return {
|
||||
success: false,
|
||||
error: result.blocked
|
||||
? "Too many requests. Your IP has been temporarily blocked."
|
||||
: "Rate limit exceeded. Please try again later.",
|
||||
retryAfter,
|
||||
};
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets remaining requests for an IP
|
||||
*/
|
||||
export function getRemainingRequests(
|
||||
ip: string,
|
||||
config: RateLimitConfig = DEFAULT_CONFIG,
|
||||
): number {
|
||||
const entry = rateLimitStore.get(ip);
|
||||
if (!entry || Date.now() > entry.resetTime) {
|
||||
return config.maxRequests;
|
||||
}
|
||||
return Math.max(0, config.maxRequests - entry.count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets rate limit for an IP (admin use)
|
||||
*/
|
||||
export function resetRateLimit(ip: string): void {
|
||||
rateLimitStore.delete(ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets current rate limit stats (admin use)
|
||||
*/
|
||||
export function getRateLimitStats(): { totalIps: number; blockedIps: number } {
|
||||
let blockedIps = 0;
|
||||
for (const entry of rateLimitStore.values()) {
|
||||
if (entry.blocked) blockedIps++;
|
||||
}
|
||||
return {
|
||||
totalIps: rateLimitStore.size,
|
||||
blockedIps,
|
||||
};
|
||||
}
|
||||
204
src/lib/security/url-validator.ts
Normal file
204
src/lib/security/url-validator.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* URL Validator
|
||||
* Validates and sanitizes social media URLs
|
||||
*/
|
||||
|
||||
import { Platform, SUPPORTED_DOMAINS } from "@/types/download";
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean;
|
||||
error?: string;
|
||||
sanitizedUrl?: string;
|
||||
platform?: Platform;
|
||||
}
|
||||
|
||||
const DANGEROUS_PATTERNS = [
|
||||
/javascript:/i,
|
||||
/data:/i,
|
||||
/vbscript:/i,
|
||||
/file:/i,
|
||||
/about:/i,
|
||||
/blob:/i,
|
||||
/\s+/g, // Whitespace
|
||||
/<[^>]*>/g, // HTML tags
|
||||
/%3C/gi, // Encoded <
|
||||
/%3E/gi, // Encoded >
|
||||
/%22/gi, // Encoded "
|
||||
/%27/gi, // Encoded '
|
||||
];
|
||||
|
||||
/**
|
||||
* Validates a social media URL
|
||||
* @param url - The URL to validate
|
||||
* @returns ValidationResult with validation status and sanitized URL
|
||||
*/
|
||||
export function validateUrl(url: string): ValidationResult {
|
||||
// 1. Check if URL is provided
|
||||
if (!url || typeof url !== "string") {
|
||||
return { valid: false, error: "URL is required" };
|
||||
}
|
||||
|
||||
// 2. Trim whitespace
|
||||
let sanitizedUrl = url.trim();
|
||||
|
||||
// 3. Check for dangerous patterns (XSS prevention)
|
||||
for (const pattern of DANGEROUS_PATTERNS) {
|
||||
if (pattern.test(sanitizedUrl)) {
|
||||
return { valid: false, error: "Invalid URL format" };
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Add protocol if missing
|
||||
if (
|
||||
!sanitizedUrl.startsWith("http://") &&
|
||||
!sanitizedUrl.startsWith("https://")
|
||||
) {
|
||||
sanitizedUrl = "https://" + sanitizedUrl;
|
||||
}
|
||||
|
||||
// 5. Parse and validate URL structure
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(sanitizedUrl);
|
||||
} catch {
|
||||
return { valid: false, error: "Invalid URL format" };
|
||||
}
|
||||
|
||||
// 6. Enforce HTTPS only
|
||||
if (parsedUrl.protocol !== "https:") {
|
||||
// Allow http for localhost development
|
||||
if (!parsedUrl.hostname.includes("localhost")) {
|
||||
return { valid: false, error: "Only HTTPS URLs are allowed" };
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Check for valid hostname
|
||||
if (!parsedUrl.hostname || parsedUrl.hostname.length < 3) {
|
||||
return { valid: false, error: "Invalid hostname" };
|
||||
}
|
||||
|
||||
// 8. Check against supported domains
|
||||
const hostname = parsedUrl.hostname.toLowerCase().replace("www.", "");
|
||||
let detectedPlatform: Platform | null = null;
|
||||
|
||||
for (const [platform, domains] of Object.entries(SUPPORTED_DOMAINS) as [
|
||||
Platform,
|
||||
string[],
|
||||
][]) {
|
||||
if (platform === "unknown") continue;
|
||||
|
||||
for (const domain of domains) {
|
||||
if (hostname === domain || hostname.endsWith("." + domain)) {
|
||||
detectedPlatform = platform;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (detectedPlatform) break;
|
||||
}
|
||||
|
||||
if (!detectedPlatform) {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
"Unsupported platform. Supported: YouTube, Instagram, TikTok, Twitter/X, Facebook",
|
||||
};
|
||||
}
|
||||
|
||||
// 9. Final sanitization
|
||||
sanitizedUrl = parsedUrl.toString();
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
sanitizedUrl,
|
||||
platform: detectedPlatform,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a URL by removing potentially dangerous characters
|
||||
*/
|
||||
export function sanitizeUrl(url: string): string {
|
||||
return url
|
||||
.trim()
|
||||
.replace(/\s+/g, "")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/javascript:/gi, "")
|
||||
.replace(/data:/gi, "")
|
||||
.replace(/vbscript:/gi, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if URL appears to be a valid social media video URL
|
||||
*/
|
||||
export function isVideoUrl(url: string): boolean {
|
||||
const videoPatterns = [
|
||||
/youtube\.com\/watch\?v=[\w-]+/i,
|
||||
/youtu\.be\/[\w-]+/i,
|
||||
/youtube\.com\/shorts\/[\w-]+/i,
|
||||
/instagram\.com\/reel\/[\w-]+/i,
|
||||
/instagram\.com\/tv\/[\w-]+/i,
|
||||
/tiktok\.com\/@[\w.]+\/video\/\d+/i,
|
||||
/vm\.tiktok\.com\/[\w-]+/i,
|
||||
/twitter\.com\/\w+\/status\/\d+/i,
|
||||
/x\.com\/\w+\/status\/\d+/i,
|
||||
/facebook\.com\/.*\/videos\/\d+/i,
|
||||
/fb\.watch\/[\w-]+/i,
|
||||
];
|
||||
|
||||
return videoPatterns.some((pattern) => pattern.test(url));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts video ID from URL (platform-specific)
|
||||
*/
|
||||
export function extractVideoId(url: string, platform: Platform): string | null {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
switch (platform) {
|
||||
case "youtube": {
|
||||
// youtu.be/VIDEO_ID
|
||||
if (urlObj.hostname === "youtu.be") {
|
||||
return urlObj.pathname.slice(1);
|
||||
}
|
||||
// youtube.com/watch?v=VIDEO_ID
|
||||
const vParam = urlObj.searchParams.get("v");
|
||||
if (vParam) return vParam;
|
||||
// youtube.com/shorts/VIDEO_ID
|
||||
if (urlObj.pathname.startsWith("/shorts/")) {
|
||||
return urlObj.pathname.replace("/shorts/", "");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
case "instagram": {
|
||||
// instagram.com/p/POST_ID or /reel/REEL_ID
|
||||
const match = urlObj.pathname.match(/\/(p|reel|tv)\/([\w-]+)/);
|
||||
return match ? match[2] : null;
|
||||
}
|
||||
|
||||
case "tiktok": {
|
||||
// tiktok.com/@user/video/VIDEO_ID
|
||||
const match = urlObj.pathname.match(/\/video\/(\d+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
case "twitter": {
|
||||
// twitter.com/user/status/TWEET_ID
|
||||
const match = urlObj.pathname.match(/\/status\/(\d+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
case "facebook": {
|
||||
// facebook.com/.../videos/VIDEO_ID
|
||||
const match = urlObj.pathname.match(/\/videos\/(\d+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
42
src/proxy.ts
42
src/proxy.ts
@@ -1,55 +1,19 @@
|
||||
import { NAV_ITEMS } from "@/config/navigation";
|
||||
import { withAuth } from "next-auth/middleware";
|
||||
import createMiddleware from "next-intl/middleware";
|
||||
import { NextRequest } from "next/server";
|
||||
import { routing } from "./i18n/routing";
|
||||
|
||||
const publicPages = NAV_ITEMS.flatMap((item) => [
|
||||
...(!item.protected ? [item.href] : []),
|
||||
...(item.children
|
||||
?.filter((child) => !child.protected)
|
||||
.map((child) => child.href) ?? []),
|
||||
]);
|
||||
|
||||
const handleI18nRouting = createMiddleware(routing);
|
||||
|
||||
const authMiddleware = withAuth(
|
||||
// Note that this callback is only invoked if
|
||||
// the `authorized` callback has returned `true`
|
||||
// and not for pages listed in `pages`.
|
||||
function onSuccess(req) {
|
||||
return handleI18nRouting(req);
|
||||
},
|
||||
{
|
||||
callbacks: {
|
||||
authorized: ({ token }) => token != null,
|
||||
},
|
||||
pages: {
|
||||
signIn: "/home",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export default function proxy(req: NextRequest) {
|
||||
// CRITICAL: Skip API routes entirely - they should not go through i18n or auth middleware
|
||||
// CRITICAL: Skip API routes entirely - they should not go through i18n middleware
|
||||
if (req.nextUrl.pathname.startsWith("/api/")) {
|
||||
return; // Return undefined to pass through without modification
|
||||
}
|
||||
|
||||
const publicPathnameRegex = RegExp(
|
||||
`^(/(${routing.locales.join("|")}))?(${publicPages.flatMap((p) => (p === "/" ? ["", "/"] : p)).join("|")})/?$`,
|
||||
"i",
|
||||
);
|
||||
const isPublicPage = publicPathnameRegex.test(req.nextUrl.pathname);
|
||||
|
||||
if (isPublicPage) {
|
||||
return handleI18nRouting(req);
|
||||
} else {
|
||||
return (authMiddleware as any)(req);
|
||||
}
|
||||
// All pages are public - no auth required
|
||||
return handleI18nRouting(req);
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: "/((?!api|trpc|_next|_vercel|.*\\..*).*)",
|
||||
// matcher: ['/', '/(de|en|tr)/:path*'],
|
||||
};
|
||||
|
||||
33
src/types/ab-downloader.d.ts
vendored
Normal file
33
src/types/ab-downloader.d.ts
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Type declarations for ab-downloader package
|
||||
* @see https://www.npmjs.com/package/ab-downloader
|
||||
*/
|
||||
|
||||
declare module "ab-downloader" {
|
||||
export interface DownloadResult {
|
||||
url: string;
|
||||
title?: string;
|
||||
thumbnail?: string;
|
||||
duration?: number;
|
||||
quality?: string;
|
||||
developer?: string;
|
||||
contactme?: string;
|
||||
}
|
||||
|
||||
// All-in-one downloader (auto-detects platform)
|
||||
export function aio(url: string): Promise<DownloadResult | DownloadResult[]>;
|
||||
|
||||
// Platform-specific downloaders
|
||||
// Note: igdl returns an array
|
||||
export function igdl(url: string): Promise<DownloadResult[]>; // Instagram
|
||||
export function youtube(url: string): Promise<DownloadResult | DownloadResult[]>; // YouTube
|
||||
export function ttdl(url: string): Promise<DownloadResult | DownloadResult[]>; // TikTok
|
||||
export function twitter(url: string): Promise<DownloadResult | DownloadResult[]>; // Twitter/X
|
||||
export function fbdown(url: string): Promise<DownloadResult | DownloadResult[]>; // Facebook
|
||||
|
||||
// Other downloaders
|
||||
export function mediafire(url: string): Promise<DownloadResult | DownloadResult[]>;
|
||||
export function capcut(url: string): Promise<DownloadResult | DownloadResult[]>;
|
||||
export function gdrive(url: string): Promise<DownloadResult | DownloadResult[]>;
|
||||
export function pinterest(url: string): Promise<DownloadResult | DownloadResult[]>;
|
||||
}
|
||||
73
src/types/distube-ytdl-core.d.ts
vendored
Normal file
73
src/types/distube-ytdl-core.d.ts
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Type declarations for @distube/ytdl-core
|
||||
* @see https://www.npmjs.com/package/@distube/ytdl-core
|
||||
*/
|
||||
|
||||
declare module "@distube/ytdl-core" {
|
||||
export interface VideoDetails {
|
||||
title: string;
|
||||
author?: {
|
||||
name: string;
|
||||
user?: string;
|
||||
channel_url?: string;
|
||||
};
|
||||
lengthSeconds: string;
|
||||
viewCount?: string;
|
||||
thumbnails?: Array<{ url: string; width?: number; height?: number }>;
|
||||
description?: string;
|
||||
media?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface Format {
|
||||
itag: number;
|
||||
url: string;
|
||||
mimeType?: string;
|
||||
quality?: string;
|
||||
qualityLabel?: string;
|
||||
audioQuality?: string;
|
||||
hasVideo: boolean;
|
||||
hasAudio: boolean;
|
||||
container?: string;
|
||||
codecs?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fps?: number;
|
||||
bitrate?: number;
|
||||
audioBitrate?: number;
|
||||
duration?: string;
|
||||
}
|
||||
|
||||
export interface VideoInfo {
|
||||
videoDetails: VideoDetails;
|
||||
formats: Format[];
|
||||
related_videos?: unknown[];
|
||||
}
|
||||
|
||||
export function getInfo(url: string): Promise<VideoInfo>;
|
||||
export function getInfo(videoId: string): Promise<VideoInfo>;
|
||||
export function getURLVideoID(url: string): string;
|
||||
export function getVideoID(url: string): string;
|
||||
export function chooseFormat(
|
||||
formats: Format[],
|
||||
options?: { quality?: string; filter?: string }
|
||||
): Format | undefined;
|
||||
export function filterFormats(
|
||||
formats: Format[],
|
||||
filter: string
|
||||
): Format[];
|
||||
export function validateID(id: string): boolean;
|
||||
export function validateURL(url: string): boolean;
|
||||
|
||||
const ytdl: {
|
||||
(url: string, options?: unknown): NodeJS.ReadableStream;
|
||||
getInfo: typeof getInfo;
|
||||
getURLVideoID: typeof getURLVideoID;
|
||||
getVideoID: typeof getVideoID;
|
||||
chooseFormat: typeof chooseFormat;
|
||||
filterFormats: typeof filterFormats;
|
||||
validateID: typeof validateID;
|
||||
validateURL: typeof validateURL;
|
||||
};
|
||||
|
||||
export default ytdl;
|
||||
}
|
||||
154
src/types/download.ts
Normal file
154
src/types/download.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Download Feature Types
|
||||
* Social Media Video Downloader
|
||||
*/
|
||||
|
||||
// Supported platforms
|
||||
export type Platform =
|
||||
| "youtube"
|
||||
| "instagram"
|
||||
| "tiktok"
|
||||
| "twitter"
|
||||
| "facebook"
|
||||
| "unknown";
|
||||
|
||||
// Video quality options
|
||||
export type VideoQuality = "max" | "1080" | "720" | "480" | "360";
|
||||
|
||||
// Audio format options
|
||||
export type AudioFormat = "mp3" | "opus" | "wav" | "best";
|
||||
|
||||
// API Request types
|
||||
export interface DownloadRequest {
|
||||
url: string;
|
||||
quality?: VideoQuality;
|
||||
audioFormat?: AudioFormat;
|
||||
audioBitrate?: "320" | "256" | "128" | "64";
|
||||
}
|
||||
|
||||
export interface InfoRequest {
|
||||
url: string;
|
||||
}
|
||||
|
||||
// API Response types
|
||||
export interface CobaltErrorResponse {
|
||||
error: {
|
||||
code: CobaltErrorCode;
|
||||
message?: string;
|
||||
context?: {
|
||||
service?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface CobaltSuccessResponse {
|
||||
status: "redirect" | "stream" | "picker";
|
||||
url?: string;
|
||||
picker?: PickerItem[];
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export type CobaltResponse = CobaltErrorResponse | CobaltSuccessResponse;
|
||||
|
||||
export interface PickerItem {
|
||||
type: "video" | "photo";
|
||||
url: string;
|
||||
thumb?: string;
|
||||
}
|
||||
|
||||
export type CobaltErrorCode =
|
||||
| "error.api.link.invalid"
|
||||
| "error.api.fetch.fail"
|
||||
| "error.api.content.unavailable"
|
||||
| "error.api.rate_exceeded"
|
||||
| "error.api.fetch.rate"
|
||||
| "error.api.content.video_region"
|
||||
| "error.api.content.post.private"
|
||||
| "error.api.fetch.short"
|
||||
| "error.api.fetch.critical"
|
||||
| "error.api.fetch.timeout"
|
||||
| "error.api.info.fail";
|
||||
|
||||
// Media type
|
||||
export type MediaType = 'video' | 'image' | 'audio';
|
||||
|
||||
// Our API Response types
|
||||
export interface DownloadResponse {
|
||||
success: boolean;
|
||||
data?: {
|
||||
downloadUrl: string;
|
||||
filename: string;
|
||||
platform: Platform;
|
||||
title?: string;
|
||||
thumbnail?: string;
|
||||
mediaType?: MediaType;
|
||||
duration?: string;
|
||||
author?: string;
|
||||
likes?: string;
|
||||
views?: string;
|
||||
};
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface InfoResponse {
|
||||
success: boolean;
|
||||
data?: {
|
||||
platform: Platform;
|
||||
title?: string;
|
||||
thumbnail?: string;
|
||||
duration?: number;
|
||||
qualities?: VideoQuality[];
|
||||
};
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Platform detection result
|
||||
export interface PlatformDetectionResult {
|
||||
platform: Platform;
|
||||
confidence: "high" | "low";
|
||||
originalUrl: string;
|
||||
normalizedUrl?: string;
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
export interface RateLimitInfo {
|
||||
ip: string;
|
||||
requests: number;
|
||||
lastRequest: number;
|
||||
blocked: boolean;
|
||||
blockUntil?: number;
|
||||
}
|
||||
|
||||
// Supported domains map
|
||||
export const SUPPORTED_DOMAINS: Record<Platform, string[]> = {
|
||||
youtube: [
|
||||
"youtube.com",
|
||||
"youtu.be",
|
||||
"music.youtube.com",
|
||||
"youtube-nocookie.com",
|
||||
],
|
||||
instagram: ["instagram.com", "instagr.am"],
|
||||
tiktok: ["tiktok.com", "vm.tiktok.com", "vt.tiktok.com"],
|
||||
twitter: ["twitter.com", "x.com", "t.co"],
|
||||
facebook: ["facebook.com", "fb.watch", "fb.com"],
|
||||
unknown: [],
|
||||
};
|
||||
|
||||
// Platform display info
|
||||
export const PLATFORM_INFO: Record<
|
||||
Platform,
|
||||
{ name: string; color: string; icon: string }
|
||||
> = {
|
||||
youtube: { name: "YouTube", color: "#FF0000", icon: "youtube" },
|
||||
instagram: { name: "Instagram", color: "#E4405F", icon: "instagram" },
|
||||
tiktok: { name: "TikTok", color: "#000000", icon: "tiktok" },
|
||||
twitter: { name: "X (Twitter)", color: "#1DA1F2", icon: "twitter" },
|
||||
facebook: { name: "Facebook", color: "#1877F2", icon: "facebook" },
|
||||
unknown: { name: "Bilinmeyen", color: "#6B7280", icon: "question" },
|
||||
};
|
||||
91
src/types/ruhend-scraper.d.ts
vendored
Normal file
91
src/types/ruhend-scraper.d.ts
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Type declarations for ruhend-scraper
|
||||
* @see https://www.npmjs.com/package/ruhend-scraper
|
||||
*/
|
||||
|
||||
declare module "ruhend-scraper" {
|
||||
// YouTube MP3 result
|
||||
export interface Ytmp3Result {
|
||||
title: string;
|
||||
audio: string;
|
||||
author: string;
|
||||
description: string;
|
||||
duration: string;
|
||||
views: string;
|
||||
upload: string;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
// YouTube MP4 result
|
||||
export interface Ytmp4Result {
|
||||
title: string;
|
||||
audio: string;
|
||||
author: string;
|
||||
description: string;
|
||||
duration: string;
|
||||
views: string;
|
||||
upload: string;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
// TikTok result
|
||||
export interface TtdlResult {
|
||||
title: string;
|
||||
author: string;
|
||||
username: string;
|
||||
published: string;
|
||||
like: string;
|
||||
comment: string;
|
||||
share: string;
|
||||
views: string;
|
||||
bookmark: string;
|
||||
video: string;
|
||||
cover: string;
|
||||
music: string;
|
||||
profilePicture: string;
|
||||
}
|
||||
|
||||
// Instagram/Facebook media item
|
||||
export interface MediaItem {
|
||||
url: string;
|
||||
}
|
||||
|
||||
// Instagram/Facebook response
|
||||
export interface MediaResponse {
|
||||
data: MediaItem[];
|
||||
}
|
||||
|
||||
// YouTube search video result
|
||||
export interface YtSearchVideo {
|
||||
type: "video";
|
||||
title: string;
|
||||
url: string;
|
||||
durationH: string;
|
||||
publishedTime: string;
|
||||
view: string;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
// YouTube search channel result
|
||||
export interface YtSearchChannel {
|
||||
type: "channel";
|
||||
channelName: string;
|
||||
url: string;
|
||||
subscriberH: string;
|
||||
videoCount: string;
|
||||
}
|
||||
|
||||
// YouTube search result
|
||||
export interface YtSearchResult {
|
||||
video: YtSearchVideo[];
|
||||
channel: YtSearchChannel[];
|
||||
}
|
||||
|
||||
// Export functions
|
||||
export function ytmp3(url: string): Promise<Ytmp3Result>;
|
||||
export function ytmp4(url: string): Promise<Ytmp4Result>;
|
||||
export function ttdl(url: string): Promise<TtdlResult>;
|
||||
export function igdl(url: string): Promise<MediaResponse>;
|
||||
export function fbdl(url: string): Promise<MediaResponse>;
|
||||
export function ytsearch(query: string): Promise<YtSearchResult>;
|
||||
}
|
||||
29
src/types/test-downloader.d.ts
vendored
Normal file
29
src/types/test-downloader.d.ts
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Type declarations for test-downloader
|
||||
* Social media downloader package
|
||||
*/
|
||||
|
||||
declare module "test-downloader" {
|
||||
export interface TestDownloaderResult {
|
||||
developer?: string;
|
||||
title?: string;
|
||||
url?: string | Array<{ hd?: string; sd?: string } | Record<string, unknown>>;
|
||||
thumbnail?: string;
|
||||
video?: string[];
|
||||
audio?: string[];
|
||||
HD?: string;
|
||||
Normal_video?: string;
|
||||
status?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export function rebelaldwn(url: string): Promise<TestDownloaderResult>;
|
||||
export function rebelfbdown(url: string): Promise<TestDownloaderResult>;
|
||||
export function rebelinstadl(url: string): Promise<TestDownloaderResult>;
|
||||
export function rebeltiktokdl(url: string): Promise<TestDownloaderResult>;
|
||||
export function rebeltwitter(url: string): Promise<TestDownloaderResult>;
|
||||
export function rebelyt(url: string): Promise<TestDownloaderResult>;
|
||||
export function rebelpindl(url: string): Promise<TestDownloaderResult>;
|
||||
export function rebelcapcutdl(url: string): Promise<TestDownloaderResult>;
|
||||
export function rebellikeedl(url: string): Promise<TestDownloaderResult>;
|
||||
}
|
||||
147
src/types/vreden-youtube-scraper.d.ts
vendored
Normal file
147
src/types/vreden-youtube-scraper.d.ts
vendored
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Type declarations for @vreden/youtube_scraper
|
||||
* YouTube video downloader for audio and video formats
|
||||
*/
|
||||
|
||||
declare module "@vreden/youtube_scraper" {
|
||||
// Metadata types
|
||||
export interface VredenAuthor {
|
||||
name?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface VredenDuration {
|
||||
seconds: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface VredenMetadata {
|
||||
type?: "video";
|
||||
videoId?: string;
|
||||
url?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
thumbnail?: string;
|
||||
seconds?: number;
|
||||
timestamp?: string;
|
||||
duration?: VredenDuration;
|
||||
ago?: string;
|
||||
views?: number;
|
||||
author?: VredenAuthor;
|
||||
}
|
||||
|
||||
// Download types
|
||||
export interface VredenDownload {
|
||||
status?: boolean;
|
||||
quality?: string;
|
||||
availableQuality?: number[];
|
||||
url?: string;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
// Result type
|
||||
export interface VredenYoutubeResult {
|
||||
status?: boolean;
|
||||
creator?: string;
|
||||
metadata?: VredenMetadata;
|
||||
download?: VredenDownload;
|
||||
}
|
||||
|
||||
// Channel metadata types
|
||||
export interface VredenChannelThumbnail {
|
||||
quality?: string;
|
||||
url?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export interface VredenChannelStatistics {
|
||||
view?: string;
|
||||
video?: string;
|
||||
subscriber?: string;
|
||||
}
|
||||
|
||||
export interface VredenChannelResult {
|
||||
id?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
username?: string;
|
||||
thumbnails?: VredenChannelThumbnail[];
|
||||
banner?: string;
|
||||
published_date?: string;
|
||||
published_format?: string;
|
||||
statistics?: VredenChannelStatistics;
|
||||
}
|
||||
|
||||
// Search result types
|
||||
export interface VredenSearchResult {
|
||||
type?: "video" | "channel" | "playlist";
|
||||
videoId?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
thumbnail?: string;
|
||||
duration?: string;
|
||||
views?: number;
|
||||
author?: VredenAuthor;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download YouTube audio as MP3
|
||||
* @param link - YouTube URL or video ID
|
||||
* @param quality - Audio quality in kbps (92, 128, 256, 320)
|
||||
*/
|
||||
export function ytmp3(
|
||||
link: string,
|
||||
quality?: 92 | 128 | 256 | 320
|
||||
): Promise<VredenYoutubeResult>;
|
||||
|
||||
/**
|
||||
* Download YouTube video as MP4
|
||||
* @param link - YouTube URL or video ID
|
||||
* @param quality - Video quality in pixels (144, 360, 480, 720, 1080)
|
||||
*/
|
||||
export function ytmp4(
|
||||
link: string,
|
||||
quality?: 144 | 360 | 480 | 720 | 1080
|
||||
): Promise<VredenYoutubeResult>;
|
||||
|
||||
/**
|
||||
* Alternative MP3 downloader using api.vreden.my.id
|
||||
* @param link - YouTube URL or video ID
|
||||
* @param quality - Audio quality in kbps (92, 128, 256, 320)
|
||||
*/
|
||||
export function apimp3(
|
||||
link: string,
|
||||
quality?: 92 | 128 | 256 | 320
|
||||
): Promise<VredenYoutubeResult>;
|
||||
|
||||
/**
|
||||
* Alternative MP4 downloader using api.vreden.my.id
|
||||
* @param link - YouTube URL or video ID
|
||||
* @param quality - Video quality in pixels (144, 360, 480, 720, 1080)
|
||||
*/
|
||||
export function apimp4(
|
||||
link: string,
|
||||
quality?: 144 | 360 | 480 | 720 | 1080
|
||||
): Promise<VredenYoutubeResult>;
|
||||
|
||||
/**
|
||||
* Search YouTube for videos, channels, and playlists
|
||||
* @param query - Search query string
|
||||
*/
|
||||
export function search(query: string): Promise<VredenSearchResult[]>;
|
||||
|
||||
/**
|
||||
* Fetch detailed video metadata
|
||||
* @param link - YouTube video URL or ID
|
||||
*/
|
||||
export function metadata(link: string): Promise<VredenMetadata>;
|
||||
|
||||
/**
|
||||
* Fetch channel metadata
|
||||
* @param usernameOrUrl - YouTube channel URL or username (@handle or custom URL)
|
||||
*/
|
||||
export function channel(usernameOrUrl: string): Promise<VredenChannelResult>;
|
||||
}
|
||||
Reference in New Issue
Block a user