main
All checks were successful
UI Deploy - indir.bilgich.com 🎨 / build-and-deploy (push) Successful in 4m8s

This commit is contained in:
2026-03-06 15:44:44 +03:00
parent 3aa07d096f
commit ce7702b1cb
43 changed files with 4279 additions and 78 deletions

View File

@@ -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>

View File

@@ -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>
);
}

View 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 },
);
}
}

View 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 }
);
}
}

View 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>
);
}

View 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>
);
}

View File

@@ -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 },
];

View 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;
}

View 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 };

View 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;
}

View 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,
};
}

View 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;
}
}

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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>;
}