main
All checks were successful
UI Deploy - indir.bilgich.com 🎨 / build-and-deploy (push) Successful in 4m8s
@@ -1,5 +1,5 @@
|
|||||||
name: UI Deploy (Next-Auth Support) 🎨
|
name: UI Deploy - indir.bilgich.com 🎨
|
||||||
run-name: ${{ gitea.actor }} frontend güncelliyor...
|
run-name: ${{ gitea.actor }} indir-fe güncelliyor...
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -19,19 +19,19 @@ jobs:
|
|||||||
--build-arg NEXT_PUBLIC_API_URL='${{ secrets.NEXT_PUBLIC_API_URL }}' \
|
--build-arg NEXT_PUBLIC_API_URL='${{ secrets.NEXT_PUBLIC_API_URL }}' \
|
||||||
--build-arg NEXT_PUBLIC_AUTH_REQUIRED='${{ secrets.NEXT_PUBLIC_AUTH_REQUIRED }}' \
|
--build-arg NEXT_PUBLIC_AUTH_REQUIRED='${{ secrets.NEXT_PUBLIC_AUTH_REQUIRED }}' \
|
||||||
--build-arg NEXT_PUBLIC_GOOGLE_API_KEY='${{ secrets.NEXT_PUBLIC_GOOGLE_API_KEY }}' \
|
--build-arg NEXT_PUBLIC_GOOGLE_API_KEY='${{ secrets.NEXT_PUBLIC_GOOGLE_API_KEY }}' \
|
||||||
-t frontend-proje:latest .
|
-t ui-indir:latest .
|
||||||
|
|
||||||
- name: Eski Konteyneri Sil
|
- name: Eski Konteyneri Sil
|
||||||
run: docker rm -f frontend-container || true
|
run: docker rm -f ui-indir-container || true
|
||||||
|
|
||||||
- name: Yeni Versiyonu Başlat
|
- name: Yeni Versiyonu Başlat
|
||||||
# Sunucu tarafında (Server-side/Auth) lazım olanları run anında veriyoruz
|
# Sunucu tarafında (Server-side/Auth) lazım olanları run anında veriyoruz
|
||||||
run: |
|
run: |
|
||||||
docker run -d \
|
docker run -d \
|
||||||
--name frontend-container \
|
--name ui-indir-container \
|
||||||
--restart always \
|
--restart always \
|
||||||
--network gitea-server_gitea \
|
--network gitea-server_gitea \
|
||||||
-p 1800:3000 \
|
-p 1507:3000 \
|
||||||
-e NEXTAUTH_SECRET='${{ secrets.NEXTAUTH_SECRET }}' \
|
-e NEXTAUTH_SECRET='${{ secrets.NEXTAUTH_SECRET }}' \
|
||||||
-e NEXTAUTH_URL='${{ secrets.NEXTAUTH_URL }}' \
|
-e NEXTAUTH_URL='${{ secrets.NEXTAUTH_URL }}' \
|
||||||
frontend-proje:latest
|
ui-indir:latest
|
||||||
6
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"i18n-ally.localesPaths": [
|
||||||
|
"messages",
|
||||||
|
"src/i18n"
|
||||||
|
]
|
||||||
|
}
|
||||||
169
mds/2026-03-05-social-media-downloader.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# AI Session Log - 2026-03-05
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Bu oturumda sosyal medya video indirici özelliği tasarlandı ve implemente edildi.
|
||||||
|
|
||||||
|
## 1. Mimari Planlama
|
||||||
|
|
||||||
|
**İstek:** Sosyal medya platformlarından (YouTube, Instagram, TikTok, X, Facebook) video indirme özelliği.
|
||||||
|
|
||||||
|
**Kararlar:**
|
||||||
|
|
||||||
|
- Next.js API Routes kullanıldı (backend ayrı servis gereksinimi yok)
|
||||||
|
- ~~Cobalt API (public instance) entegrasyonu~~ → Cobalt API v7 Kasım 2024'te kapatıldı
|
||||||
|
- **ab-downloader npm paketi** entegre edildi (kullanıcının isteği üzerine)
|
||||||
|
- Auth sistemi gereksiz (basit, herkes kullanabilir)
|
||||||
|
- IP-based rate limiting (abuse önleme)
|
||||||
|
|
||||||
|
**Plan Dosyası:** `plans/social-media-downloader-plan.md`
|
||||||
|
|
||||||
|
## 2. Oluşturulan Dosyalar
|
||||||
|
|
||||||
|
### Types
|
||||||
|
|
||||||
|
- `src/types/download.ts` - TypeScript tip tanımları
|
||||||
|
- `src/types/ab-downloader.d.ts` - ab-downloader için type declarations
|
||||||
|
|
||||||
|
### Backend (API Routes)
|
||||||
|
|
||||||
|
- `src/app/api/download/route.ts` - Ana download endpoint
|
||||||
|
|
||||||
|
### Utilities
|
||||||
|
|
||||||
|
- `src/lib/download/platform-detector.ts` - URL'den platform tespiti
|
||||||
|
- `src/lib/download/downloader.ts` - ab-downloader wrapper (platform-specific fonksiyonlar)
|
||||||
|
- `src/lib/security/url-validator.ts` - URL validation ve sanitization
|
||||||
|
- `src/lib/security/rate-limiter.ts` - IP-based rate limiting
|
||||||
|
|
||||||
|
### Frontend Components
|
||||||
|
|
||||||
|
- `src/components/site/download/download-form.tsx` - URL input form
|
||||||
|
- `src/components/site/download/download-result.tsx` - Sonuç görüntüleme
|
||||||
|
- `src/app/[locale]/page.tsx` - Ana sayfa (download formu içeriyor)
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
|
||||||
|
- `messages/en.json` - İngilizce çeviriler (download bölümü)
|
||||||
|
- `messages/tr.json` - Türkçe çeviriler (download bölümü)
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
|
||||||
|
- `src/config/navigation.ts` - Basitleştirildi (sadece home linki)
|
||||||
|
- `src/proxy.ts` - Auth gereksinimi kaldırıldı (herkes erişebilir)
|
||||||
|
|
||||||
|
## 3. ab-downloader Entegrasyonu
|
||||||
|
|
||||||
|
### Paket Bilgisi
|
||||||
|
|
||||||
|
- **Paket:** `ab-downloader`
|
||||||
|
- **Kaynak:** https://www.npmjs.com/package/ab-downloader
|
||||||
|
- **Versiyon:** 5.0.0
|
||||||
|
|
||||||
|
### Desteklenen Platformlar
|
||||||
|
|
||||||
|
| Platform | Fonksiyon | Notlar |
|
||||||
|
|----------|-----------|--------|
|
||||||
|
| Instagram | `igdl()` | Array döndürür |
|
||||||
|
| YouTube | `youtube()` | - |
|
||||||
|
| TikTok | `ttdl()` | - |
|
||||||
|
| Twitter/X | `twitter()` | - |
|
||||||
|
| Facebook | `fbdown()` | - |
|
||||||
|
| Generic | `aio()` | Auto-detect |
|
||||||
|
|
||||||
|
### Response Format
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface DownloadResult {
|
||||||
|
url: string; // Direct download URL
|
||||||
|
title?: string; // Video title
|
||||||
|
thumbnail?: string; // Thumbnail URL
|
||||||
|
developer?: string; // "AbroCodes"
|
||||||
|
contactme?: string; // "Telegram: abrocodes"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Örnek Kullanım
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { igdl } from 'ab-downloader';
|
||||||
|
|
||||||
|
const result = await igdl('https://www.instagram.com/reel/xxx/');
|
||||||
|
// result bir array: [{ url, thumbnail, ... }]
|
||||||
|
const downloadUrl = result[0].url;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. API Endpoint
|
||||||
|
|
||||||
|
### POST /api/download
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"url": "https://www.instagram.com/reel/xxx/"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (Success):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"downloadUrl": "https://d.rapidcdn.app/...",
|
||||||
|
"filename": "instagram_1772720934409.mp4",
|
||||||
|
"platform": "instagram",
|
||||||
|
"thumbnail": "https://d.rapidcdn.app/thumb?..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (Error):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"error": {
|
||||||
|
"code": "error.download.failed",
|
||||||
|
"message": "Video indirilemedi"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Güvenlik Önlemleri
|
||||||
|
|
||||||
|
- **URL Validation:** Sadece desteklenen platformlardan URL'lere izin verilir
|
||||||
|
- **Rate Limiting:** IP başına 10 istek/saat limiti
|
||||||
|
- **HTTPS Only:** Sadece HTTPS URL'ler kabul edilir
|
||||||
|
- **Request Timeout:** 30 saniye timeout
|
||||||
|
|
||||||
|
## 6. Test Sonuçları
|
||||||
|
|
||||||
|
### Instagram Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3001/api/download \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"url":"https://www.instagram.com/reel/DVejgkGkUPU/"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sonuç:** ✅ Başarılı - Download URL ve thumbnail döndü
|
||||||
|
|
||||||
|
## 7. Önemli Notlar
|
||||||
|
|
||||||
|
1. **Cobalt API Kapatıldı:** Cobalt API v7 Kasım 2024'te kapatıldığı için `ab-downloader` paketine geçildi
|
||||||
|
2. **Array Response:** `igdl()` fonksiyonu array döndürüyor, bu yüzden `getFirstResult()` helper fonksiyonu kullanılıyor
|
||||||
|
3. **Type Declarations:** Paket TypeScript support içermiyor, bu yüzden `src/types/ab-downloader.d.ts` oluşturuldu
|
||||||
|
|
||||||
|
## 8. Gelecek İyileştirmeler
|
||||||
|
|
||||||
|
1. Video kalite seçimi
|
||||||
|
2. Sadece ses indirme seçeneği
|
||||||
|
3. Download progress indicator
|
||||||
|
4. Video thumbnail gösterimi (önbellek ile)
|
||||||
|
5. Çoklu dil desteği genişletme
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Sosyal medya indirici özelliği başarıyla implemente edildi. Cobalt API'nin kapatılması nedeniyle `ab-downloader` npm paketi kullanıldı. Instagram videosu başarıyla indirildi ve test edildi. Tüm kod TypeScript ile strict modda yazıldı ve projenin mevcut mimarisine uygun şekilde geliştirildi.
|
||||||
@@ -28,5 +28,28 @@
|
|||||||
"name": "Name",
|
"name": "Name",
|
||||||
"low": "Low",
|
"low": "Low",
|
||||||
"medium": "Medium",
|
"medium": "Medium",
|
||||||
"high": "High"
|
"high": "High",
|
||||||
}
|
"download": {
|
||||||
|
"title": "Video Downloader",
|
||||||
|
"subtitle": "Download videos from YouTube, Instagram, TikTok, X, and Facebook",
|
||||||
|
"placeholder": "Paste video URL here...",
|
||||||
|
"download": "Download",
|
||||||
|
"downloading": "Downloading...",
|
||||||
|
"error": "Download Failed",
|
||||||
|
"unknownError": "An unknown error occurred",
|
||||||
|
"readyToDownload": "Ready to Download",
|
||||||
|
"downloadFile": "Download File",
|
||||||
|
"newDownload": "New Download",
|
||||||
|
"downloadHint": "Click the button to start downloading. The file will be saved to your downloads folder.",
|
||||||
|
"supportedPlatforms": "Supported Platforms",
|
||||||
|
"disclaimer": "Please respect copyright laws. Only download content you have permission to use.",
|
||||||
|
"errors": {
|
||||||
|
"urlRequired": "Please enter a URL",
|
||||||
|
"invalidUrl": "Please enter a valid URL",
|
||||||
|
"unsupportedPlatform": "This platform is not supported",
|
||||||
|
"downloadFailed": "Download Failed",
|
||||||
|
"networkError": "Network Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"predictions": "Predictions"
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,5 +28,28 @@
|
|||||||
"name": "İsim",
|
"name": "İsim",
|
||||||
"low": "Düşük",
|
"low": "Düşük",
|
||||||
"medium": "Orta",
|
"medium": "Orta",
|
||||||
"high": "Yüksek"
|
"high": "Yüksek",
|
||||||
}
|
"download": {
|
||||||
|
"title": "Video İndirici",
|
||||||
|
"subtitle": "YouTube, Instagram, TikTok, X ve Facebook videolarını indirin",
|
||||||
|
"placeholder": "Video URL'sini buraya yapıştırın...",
|
||||||
|
"download": "İndir",
|
||||||
|
"downloading": "İndiriliyor...",
|
||||||
|
"error": "İndirme Başarısız",
|
||||||
|
"unknownError": "Bilinmeyen bir hata oluştu",
|
||||||
|
"readyToDownload": "İndirmeye Hazır",
|
||||||
|
"downloadFile": "Dosyayı İndir",
|
||||||
|
"newDownload": "Yeni İndirme",
|
||||||
|
"downloadHint": "İndirmeyi başlatmak için butona tıklayın. Dosya indirme klasörünüze kaydedilecek.",
|
||||||
|
"supportedPlatforms": "Desteklenen Platformlar",
|
||||||
|
"disclaimer": "Lütfen telif haklarına saygı duyulması gerektiğini unutmayın. Sadece izniniz olan içerikleri indirin.",
|
||||||
|
"errors": {
|
||||||
|
"urlRequired": "Lütfen bir URL girin",
|
||||||
|
"invalidUrl": "Lütfen geçerli bir URL girin",
|
||||||
|
"unsupportedPlatform": "Bu platform desteklenmiyor",
|
||||||
|
"downloadFailed": "İndirme Başarısız",
|
||||||
|
"networkError": "Ağ Hatası"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"predictions": "Tahminler"
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import createNextIntlPlugin from "next-intl/plugin";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
|
serverExternalPackages: ["cheerio", "@vreden/youtube_scraper", "yt-search"],
|
||||||
experimental: {
|
experimental: {
|
||||||
optimizePackageImports: ["@chakra-ui/react"],
|
optimizePackageImports: ["@chakra-ui/react"],
|
||||||
},
|
},
|
||||||
|
|||||||
175
openclaw_setup_summary.json
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
{
|
||||||
|
"project": "OpenClaw - Kişisel AI Asistanı",
|
||||||
|
"date": "2026-03-05",
|
||||||
|
"server": {
|
||||||
|
"hostname": "Raspberry Pi",
|
||||||
|
"ip": "95.70.252.214",
|
||||||
|
"ssh_port": 2222,
|
||||||
|
"ssh_user": "haruncan",
|
||||||
|
"ssh_command": "ssh -p 2222 haruncan@95.70.252.214"
|
||||||
|
},
|
||||||
|
"storage": {
|
||||||
|
"sd_card": {
|
||||||
|
"mount": "/",
|
||||||
|
"size": "57GB",
|
||||||
|
"used": "25GB",
|
||||||
|
"available": "30GB",
|
||||||
|
"usage_percent": "46%"
|
||||||
|
},
|
||||||
|
"external_disk": {
|
||||||
|
"mount": "/mnt/extreme",
|
||||||
|
"device": "/dev/sda1",
|
||||||
|
"filesystem": "ext4",
|
||||||
|
"size": "916GB",
|
||||||
|
"used": "15GB",
|
||||||
|
"available": "856GB",
|
||||||
|
"usage_percent": "2%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"openclaw": {
|
||||||
|
"name": "Open WebUI",
|
||||||
|
"container_name": "openclaw",
|
||||||
|
"image": "ghcr.io/open-webui/open-webui:main",
|
||||||
|
"port": 3001,
|
||||||
|
"status": "running",
|
||||||
|
"url_local": "http://127.0.0.1:3001",
|
||||||
|
"url_domain": "http://ai.bilgich.com",
|
||||||
|
"description": "ChatGPT benzeri web arayüzü"
|
||||||
|
},
|
||||||
|
"ollama": {
|
||||||
|
"port": 11434,
|
||||||
|
"api_url": "http://127.0.0.1:11434",
|
||||||
|
"models": [
|
||||||
|
"llama3.2:3b"
|
||||||
|
],
|
||||||
|
"models_path": "/mnt/extreme/ollama",
|
||||||
|
"status": "running"
|
||||||
|
},
|
||||||
|
"gitea": {
|
||||||
|
"url": "http://gitea.bilgich.com",
|
||||||
|
"port": 1224,
|
||||||
|
"ssh_port": 222,
|
||||||
|
"status": "running"
|
||||||
|
},
|
||||||
|
"portainer": {
|
||||||
|
"url": "http://95.70.252.214:9000",
|
||||||
|
"port": 9000,
|
||||||
|
"description": "Docker yönetim arayüzü"
|
||||||
|
},
|
||||||
|
"postgres": {
|
||||||
|
"gitea_db": "postgres:14",
|
||||||
|
"backend_db": "postgres:16-alpine"
|
||||||
|
},
|
||||||
|
"redis": {
|
||||||
|
"container": "apps_redis",
|
||||||
|
"image": "redis:alpine"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nginx": {
|
||||||
|
"config_path": "/etc/nginx/sites-available/openclaw.conf",
|
||||||
|
"server_name": "ai.bilgich.com",
|
||||||
|
"proxy_pass": "http://127.0.0.1:3001",
|
||||||
|
"ssl": false,
|
||||||
|
"ssl_note": "DNS kaydı eksik olduğu için Let's Encrypt sertifikası alınamadı"
|
||||||
|
},
|
||||||
|
"docker_containers": [
|
||||||
|
{
|
||||||
|
"name": "openclaw",
|
||||||
|
"status": "Up 27 minutes (healthy)",
|
||||||
|
"ports": "3001:8080"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "gitea",
|
||||||
|
"status": "Up 42 minutes",
|
||||||
|
"ports": "222:22, 1224:3000"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "gitea_db",
|
||||||
|
"status": "Up 42 minutes",
|
||||||
|
"ports": "5432"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "backend_db",
|
||||||
|
"status": "Up 42 minutes",
|
||||||
|
"ports": "5432"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "apps_redis",
|
||||||
|
"status": "Up 42 minutes",
|
||||||
|
"ports": "6379"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "portainer",
|
||||||
|
"status": "Up 42 minutes",
|
||||||
|
"ports": "9000:9000"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "gitea_runner",
|
||||||
|
"status": "Up About an hour",
|
||||||
|
"ports": "-"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ui-skript-container",
|
||||||
|
"status": "Up About an hour",
|
||||||
|
"ports": "1506:3000"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "backend-skript-container",
|
||||||
|
"status": "Up About an hour",
|
||||||
|
"ports": "1806:3000"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "backend-digicraft-container",
|
||||||
|
"status": "Up About an hour",
|
||||||
|
"ports": "1805:3001"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ui-digicraft-container",
|
||||||
|
"status": "Up About an hour",
|
||||||
|
"ports": "1505:80"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "frontend-container",
|
||||||
|
"status": "Up About an hour",
|
||||||
|
"ports": "1800:3000"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"data_paths": {
|
||||||
|
"docker_data": "/mnt/extreme/docker-data",
|
||||||
|
"docker_config": "/mnt/extreme/docker",
|
||||||
|
"projects": "/mnt/extreme/projects",
|
||||||
|
"ollama": "/mnt/extreme/ollama",
|
||||||
|
"openclaw_data": "/mnt/extreme/open-webui/data"
|
||||||
|
},
|
||||||
|
"pending_tasks": {
|
||||||
|
"dns_record": {
|
||||||
|
"description": "ai.bilgich.com için DNS A kaydı eklenmeli",
|
||||||
|
"type": "A",
|
||||||
|
"name": "ai",
|
||||||
|
"value": "95.70.252.214"
|
||||||
|
},
|
||||||
|
"ssl_certificate": {
|
||||||
|
"description": "DNS kaydı eklendikten sonra Let's Encrypt sertifikası alınmalı",
|
||||||
|
"command": "sudo certbot --nginx -d ai.bilgich.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"useful_commands": {
|
||||||
|
"openclaw_logs": "docker logs openclaw --tail 50",
|
||||||
|
"openclaw_restart": "docker restart openclaw",
|
||||||
|
"ollama_models": "ollama list",
|
||||||
|
"ollama_pull_model": "ollama pull <model_name>",
|
||||||
|
"docker_ps": "docker ps",
|
||||||
|
"nginx_test": "sudo nginx -t",
|
||||||
|
"nginx_reload": "sudo systemctl reload nginx",
|
||||||
|
"system_resources": "free -h && df -h"
|
||||||
|
},
|
||||||
|
"first_time_setup": {
|
||||||
|
"step1": "DNS A kaydı ekle: ai -> 95.70.252.214",
|
||||||
|
"step2": "DNS propagation bekle (5-10 dakika)",
|
||||||
|
"step3": "http://ai.bilgich.com adresine git",
|
||||||
|
"step4": "İlk hesabı oluştur (admin)",
|
||||||
|
"step5": "Model otomatik llama3.2:3b olacak",
|
||||||
|
"step6": "Kullanıma hazır!"
|
||||||
|
}
|
||||||
|
}
|
||||||
377
package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"@google/genai": "^1.35.0",
|
"@google/genai": "^1.35.0",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@tanstack/react-query": "^5.90.16",
|
"@tanstack/react-query": "^5.90.16",
|
||||||
|
"ab-downloader": "^1.0.2",
|
||||||
"axios": "^1.13.1",
|
"axios": "^1.13.1",
|
||||||
"i18next": "^25.6.0",
|
"i18next": "^25.6.0",
|
||||||
"next": "16.0.0",
|
"next": "16.0.0",
|
||||||
@@ -20,10 +21,14 @@
|
|||||||
"next-intl": "^4.4.0",
|
"next-intl": "^4.4.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nextjs-toploader": "^3.9.17",
|
"nextjs-toploader": "^3.9.17",
|
||||||
|
"ofetch": "^1.5.1",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-hook-form": "^7.65.0",
|
"react-hook-form": "^7.65.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
|
"snapsave-media-downloader": "^2.4.0",
|
||||||
|
"test-downloader": "^1.0.5",
|
||||||
|
"undici": "^7.22.0",
|
||||||
"yup": "^1.7.1"
|
"yup": "^1.7.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -146,7 +151,6 @@
|
|||||||
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.28.6",
|
"@babel/code-frame": "^7.28.6",
|
||||||
"@babel/generator": "^7.28.6",
|
"@babel/generator": "^7.28.6",
|
||||||
@@ -556,7 +560,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@chakra-ui/react/-/react-3.31.0.tgz",
|
"resolved": "https://registry.npmjs.org/@chakra-ui/react/-/react-3.31.0.tgz",
|
||||||
"integrity": "sha512-puvrZOfnfMA+DckDcz0UxO20l7TVhwsdQ9ksCv4nIUB430yuWzon0yo9fM10lEr3hd7BhjZARpMCVw5u280clw==",
|
"integrity": "sha512-puvrZOfnfMA+DckDcz0UxO20l7TVhwsdQ9ksCv4nIUB430yuWzon0yo9fM10lEr3hd7BhjZARpMCVw5u280clw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ark-ui/react": "^5.29.1",
|
"@ark-ui/react": "^5.29.1",
|
||||||
"@emotion/is-prop-valid": "^1.4.0",
|
"@emotion/is-prop-valid": "^1.4.0",
|
||||||
@@ -692,7 +695,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
|
||||||
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
|
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.18.3",
|
"@babel/runtime": "^7.18.3",
|
||||||
"@emotion/babel-plugin": "^11.13.5",
|
"@emotion/babel-plugin": "^11.13.5",
|
||||||
@@ -1986,7 +1988,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.0.tgz",
|
||||||
"integrity": "sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==",
|
"integrity": "sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@swc/helpers": "^0.5.0"
|
"@swc/helpers": "^0.5.0"
|
||||||
}
|
}
|
||||||
@@ -2063,6 +2064,17 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@jsdoc/salty": {
|
||||||
|
"version": "0.2.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@jsdoc/salty/-/salty-0.2.10.tgz",
|
||||||
|
"integrity": "sha512-VFHSsQAQp8y1NJvAJBpLs9I2shHE6hz9TwukocDObuUgGVAq62yZGbTgJg04Z3Fj0XSMWe0sJqGg5dhKGTV92A==",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash": "^4.17.23"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=v12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "0.2.12",
|
"version": "0.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||||
@@ -2905,7 +2917,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.18.tgz",
|
||||||
"integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
|
"integrity": "sha512-TXTnIcNJQEKwThMMqBXsZ4VGAza6bvN4pa41Rkqoio6QBKMvo+5lexeTMScGCIxtzgQJzElcvIltani+adC5PQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tslib": "^2.8.0"
|
"tslib": "^2.8.0"
|
||||||
}
|
}
|
||||||
@@ -3067,7 +3078,6 @@
|
|||||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
@@ -3127,7 +3137,6 @@
|
|||||||
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
|
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.54.0",
|
"@typescript-eslint/scope-manager": "8.54.0",
|
||||||
"@typescript-eslint/types": "8.54.0",
|
"@typescript-eslint/types": "8.54.0",
|
||||||
@@ -4549,13 +4558,21 @@
|
|||||||
"integrity": "sha512-KLm0pmOtf4ydALbaVLboL7W98TDVxwVVLvSuvtRgV53XTjlsVopTRA5/Xmzq2NhWujDZAXv7bRV603NDgDcjSw==",
|
"integrity": "sha512-KLm0pmOtf4ydALbaVLboL7W98TDVxwVVLvSuvtRgV53XTjlsVopTRA5/Xmzq2NhWujDZAXv7bRV603NDgDcjSw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ab-downloader": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ab-downloader/-/ab-downloader-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-82wWQhWd2DhO3a7InQKUnv8/X7yUfCJJElYHEn8pTN6h6SX2hW1r3F43fgMyX9Se/x6ERGssOK23f6JRMiP2lQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.4.0",
|
||||||
|
"docdash": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.15.0",
|
"version": "8.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -4921,7 +4938,6 @@
|
|||||||
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/types": "^7.26.0"
|
"@babel/types": "^7.26.0"
|
||||||
}
|
}
|
||||||
@@ -4984,6 +5000,11 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/boolbase": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.12",
|
"version": "1.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||||
@@ -5028,7 +5049,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -5155,6 +5175,46 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cheerio": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
|
||||||
|
"dependencies": {
|
||||||
|
"cheerio-select": "^2.1.0",
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.2.2",
|
||||||
|
"encoding-sniffer": "^0.2.1",
|
||||||
|
"htmlparser2": "^10.1.0",
|
||||||
|
"parse5": "^7.3.0",
|
||||||
|
"parse5-htmlparser2-tree-adapter": "^7.1.0",
|
||||||
|
"parse5-parser-stream": "^7.1.2",
|
||||||
|
"undici": "^7.19.0",
|
||||||
|
"whatwg-mimetype": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/cheeriojs/cheerio?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cheerio-select": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0",
|
||||||
|
"css-select": "^5.1.0",
|
||||||
|
"css-what": "^6.1.0",
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
@@ -5301,6 +5361,32 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-select": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0",
|
||||||
|
"css-what": "^6.1.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"domutils": "^3.0.1",
|
||||||
|
"nth-check": "^2.0.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/css-what": {
|
||||||
|
"version": "6.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
|
||||||
|
"integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
@@ -5452,6 +5538,11 @@
|
|||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/destr": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -5461,6 +5552,14 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/docdash": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/docdash/-/docdash-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-3SDDheh9ddrwjzf6dPFe1a16M6ftstqTNjik2+1fx46l24H9dD2osT2q9y+nBEC1wWz4GIqA48JmicOLQ0R8xA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@jsdoc/salty": "^0.2.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/doctrine": {
|
"node_modules/doctrine": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||||
@@ -5474,6 +5573,57 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-serializer": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"entities": "^4.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domelementtype": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/domhandler": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domutils": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||||
|
"dependencies": {
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.2.3",
|
"version": "17.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||||
@@ -5529,6 +5679,29 @@
|
|||||||
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/encoding-sniffer": {
|
||||||
|
"version": "0.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
|
||||||
|
"integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
|
||||||
|
"dependencies": {
|
||||||
|
"iconv-lite": "^0.6.3",
|
||||||
|
"whatwg-encoding": "^3.1.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/error-ex": {
|
"node_modules/error-ex": {
|
||||||
"version": "1.3.4",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
||||||
@@ -5781,7 +5954,6 @@
|
|||||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -5983,7 +6155,6 @@
|
|||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@@ -6960,6 +7131,35 @@
|
|||||||
"react-is": "^16.7.0"
|
"react-is": "^16.7.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/htmlparser2": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.2.2",
|
||||||
|
"entities": "^7.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/htmlparser2/node_modules/entities": {
|
||||||
|
"version": "7.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||||
|
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/https-proxy-agent": {
|
"node_modules/https-proxy-agent": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
@@ -7004,6 +7204,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/iconv-lite": {
|
||||||
|
"version": "0.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||||
|
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -7745,6 +7956,11 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.17.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||||
|
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="
|
||||||
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
@@ -7935,7 +8151,6 @@
|
|||||||
"integrity": "sha512-nYohiNdxGu4OmBzggxy9rczmjIGI+TpR5vbKTsE1HqYwNm1B+YSiugSrFguX6omMOKnDHAmBPY4+8TNJk0Idyg==",
|
"integrity": "sha512-nYohiNdxGu4OmBzggxy9rczmjIGI+TpR5vbKTsE1HqYwNm1B+YSiugSrFguX6omMOKnDHAmBPY4+8TNJk0Idyg==",
|
||||||
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.",
|
"deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "16.0.0",
|
"@next/env": "16.0.0",
|
||||||
"@swc/helpers": "0.5.15",
|
"@swc/helpers": "0.5.15",
|
||||||
@@ -8146,6 +8361,11 @@
|
|||||||
"url": "https://opencollective.com/node-fetch"
|
"url": "https://opencollective.com/node-fetch"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-fetch-native": {
|
||||||
|
"version": "1.6.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
||||||
|
"integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.27",
|
"version": "2.0.27",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||||
@@ -8169,6 +8389,17 @@
|
|||||||
"integrity": "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==",
|
"integrity": "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/nth-check": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||||
|
"dependencies": {
|
||||||
|
"boolbase": "^1.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/oauth": {
|
"node_modules/oauth": {
|
||||||
"version": "0.9.15",
|
"version": "0.9.15",
|
||||||
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
|
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
|
||||||
@@ -8306,6 +8537,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ofetch": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==",
|
||||||
|
"dependencies": {
|
||||||
|
"destr": "^2.0.5",
|
||||||
|
"node-fetch-native": "^1.6.7",
|
||||||
|
"ufo": "^1.6.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/oidc-token-hash": {
|
"node_modules/oidc-token-hash": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.2.0.tgz",
|
||||||
@@ -8459,6 +8700,51 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parse5": {
|
||||||
|
"version": "7.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||||
|
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^6.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5-htmlparser2-tree-adapter": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
|
||||||
|
"dependencies": {
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"parse5": "^7.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5-parser-stream": {
|
||||||
|
"version": "7.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
|
||||||
|
"integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
|
||||||
|
"dependencies": {
|
||||||
|
"parse5": "^7.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/parse5/node_modules/entities": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-exists": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@@ -8599,7 +8885,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz",
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz",
|
||||||
"integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==",
|
"integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/preact"
|
"url": "https://opencollective.com/preact"
|
||||||
@@ -8747,7 +9032,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -8757,7 +9041,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -8770,7 +9053,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz",
|
||||||
"integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==",
|
"integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
},
|
},
|
||||||
@@ -9045,6 +9327,11 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
@@ -9304,6 +9591,16 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/snapsave-media-downloader": {
|
||||||
|
"version": "2.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/snapsave-media-downloader/-/snapsave-media-downloader-2.4.0.tgz",
|
||||||
|
"integrity": "sha512-WSaC6rQ0SCCktbZ1i/UAZdZyzcULeq2jibp3YrU3W99rk4xh7c7apGVyzhNb9ksYOtkXZz/1A/5+mjChODGgmg==",
|
||||||
|
"dependencies": {
|
||||||
|
"cheerio": "^1.2.0",
|
||||||
|
"ofetch": "^1.5.1",
|
||||||
|
"undici": "^7.22.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map": {
|
"node_modules/source-map": {
|
||||||
"version": "0.5.7",
|
"version": "0.5.7",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
|
||||||
@@ -9629,6 +9926,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/test-downloader": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/test-downloader/-/test-downloader-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-SQnzje3VKIobMfmlqenW2GQsskXmgwqceT1sbwW+/7ODCO7h0lkE+AsTy3PXfqOhGTlbWWMB8jIcGvT2KWM+7w==",
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.4.0",
|
||||||
|
"cheerio": "^1.0.0",
|
||||||
|
"docdash": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tiny-case": {
|
"node_modules/tiny-case": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz",
|
||||||
@@ -9683,7 +9990,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -9864,7 +10170,6 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -9897,6 +10202,11 @@
|
|||||||
"typescript": ">=4.8.4 <6.0.0"
|
"typescript": ">=4.8.4 <6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ufo": {
|
||||||
|
"version": "1.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz",
|
||||||
|
"integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="
|
||||||
|
},
|
||||||
"node_modules/unbox-primitive": {
|
"node_modules/unbox-primitive": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||||
@@ -9916,6 +10226,14 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici": {
|
||||||
|
"version": "7.22.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
|
||||||
|
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
@@ -10049,6 +10367,26 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/whatwg-encoding": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
|
||||||
|
"deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
|
||||||
|
"dependencies": {
|
||||||
|
"iconv-lite": "0.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-mimetype": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -10319,7 +10657,6 @@
|
|||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"@google/genai": "^1.35.0",
|
"@google/genai": "^1.35.0",
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@tanstack/react-query": "^5.90.16",
|
"@tanstack/react-query": "^5.90.16",
|
||||||
|
"ab-downloader": "^1.0.2",
|
||||||
"axios": "^1.13.1",
|
"axios": "^1.13.1",
|
||||||
"i18next": "^25.6.0",
|
"i18next": "^25.6.0",
|
||||||
"next": "16.0.0",
|
"next": "16.0.0",
|
||||||
@@ -21,10 +22,14 @@
|
|||||||
"next-intl": "^4.4.0",
|
"next-intl": "^4.4.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"nextjs-toploader": "^3.9.17",
|
"nextjs-toploader": "^3.9.17",
|
||||||
|
"ofetch": "^1.5.1",
|
||||||
"react": "19.2.0",
|
"react": "19.2.0",
|
||||||
"react-dom": "19.2.0",
|
"react-dom": "19.2.0",
|
||||||
"react-hook-form": "^7.65.0",
|
"react-hook-form": "^7.65.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
|
"snapsave-media-downloader": "^2.4.0",
|
||||||
|
"test-downloader": "^1.0.5",
|
||||||
|
"undici": "^7.22.0",
|
||||||
"yup": "^1.7.1"
|
"yup": "^1.7.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -41,4 +46,4 @@
|
|||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
"description": "Generated by Frontend CLI"
|
"description": "Generated by Frontend CLI"
|
||||||
}
|
}
|
||||||
|
|||||||
8
parse-snapsave.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
const url = "https://snapsave.app/render.php?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ2aWRlb191cmwiOiJodHRwczovL3ZpZGVvLXpyaDEtMS54eC5mYmNkbi5uZXQvbzEvdi90Mi9mMi9tMzY2L0FRUEE1T3R4MWlRdFl6UzMwTUxhZFJ6ZzFxUkRtSS1DdDhLeHlGYXpaVC1QYjRhRVF1Q3U5eXozakIxSFBqTjg3eTVkX2VoMWd1ekZMVlo1RVZ2TDhFWHp6VzhRb1JQUU5Gd1BwMTgubXA0P19uY19jYXQ9MTA4Jl9uY19zaWQ9OWNhMDUyJl9uY19odD12aWRlby16cmgxLTEueHguZmJjZG4ubmV0Jl9uY19vaGM9NGtBSjhjMC04Qk1RN2tOdndFLXE2U1YmZWZnPWV5SjJaVzVqYjJSbFgzUmhaeUk2SW1SaGMyaGZjakpoZGpFdGNqRm5aVzR5ZG5BNVgzRTVNQ0lzSW5acFpHVnZYMmxrSWpveE5UWTRNakkyTURZM056TTJOelUxTENKdmFXeGZkWEpzWjJWdVgyRndjRjlwWkNJNk1Dd2lZMnhwWlc1MFgyNWhiV1VpT2lKMWJtdHViM2R1SWl3aWVIQjJYMkZ6YzJWMFgybGtJam94Tnprek9UazBOemMyTVRFMU56TTJNU3dpWVhOelpYUmZZV2RsWDJSaGVYTWlPakFzSW5acFgzVnpaV05oYzJWZmFXUWlPakV3TURrNUxDSmtkWEpoZEdsdmJsOXpJam94TXl3aVltbDBjbUYwWlNJNk1UUXpPVEF3TkN3aWRYSnNaMlZ1WDNOdmRYSmpaU0k2SW5kM2R5SjkmY2NiPTE3LTEmX25jX2dpZD1zaVJFVGhySHNIZnhEdW1NZ25aVXdRJl9uY19zcz04Jl9uY196dD0yOCZvaD0wMF9BZnlPcVp0VWxDdjNYcjdCa3lPRzlOdnh6Y1NZNjZsRlBBbTdOYnlGSGJDQmp3Jm9lPTY5QjBCMDlEIiwiYXVkaW9fdXJsIjoiaHR0cHM6Ly92aWRlby16cmgxLTEueHguZmJjZG4ubmV0L28xL3YvdDIvZjIvbTc4L0FRTmRmaExmTWV1Q0pzZnV2T0c0SF8wSzhQY3BSUU1iUDdpVmZYV2FhRmtxVE50eVBQaHBWSllyeEw1elhmU216YlN2OUh3eWJiYVRMdlpQWk55R2QxcnJZQ29iMzctS3NsRmcyN2MubXA0P19uY19jYXQ9MTA4Jl9uY19zaWQ9OWNhMDUyJl9uY19odD12aWRlby16cmgxLTEueHguZmJjZG4ubmV0Jl9uY19vaGM9U1JobDBDeFE4VW9RN2tOdndHYnlhUXQmZWZnPWV5SjJaVzVqYjJSbFgzUmhaeUk2SW1SaGMyaGZiRzVmYUdWaFlXTmZkbUp5TTE5aGRXUnBieUlzSW5acFpHVnZYMmxrSWpveE5UWTRNakkyTURZM056TTJOelUxTENKdmFXeGZkWEpzWjJWdVgyRndjRjlwWkNJNk1Dd2lZMnhwWlc1MFgyNWhiV1VpT2lKMWJtdHViM2R1SWl3aWVIQjJYMkZ6YzJWMFgybGtJam94Tnprek9UazBOemMyTVRFMU56TTJNU3dpWVhOelpYUmZZV2RsWDJSaGVYTWlPakFzSW5acFgzVnpaV05oYzJWZmFXUWlPakV3TURrNUxDSmtkWEpoZEdsdmJsOXpJam94TXl3aVltbDBjbUYwWlNJNk5qWXpPREFzSW5WeWJHZGxibDl6YjNWeVkyVWlPaUozZDNjaWZRJTNEJTNEJmNjYj0xNy0xJl9uY19naWQ9c2lSRVRockhzSGZ4RHVtTWduWlV3USZfbmNfc3M9OCZfbmNfenQ9Mjgmb2g9MDBfQWZ6R0JxM2I3bHpnTGg1Y1poWFpjazRvRU5DNTJHdG5mVTVmbUdUY2FYT0liUSZvZT02OUFDQkQ4NSIsImlkIjoiMTU2ODIyNjA2NzczNjc1NSIsImZpbGVuYW1lIjoiU25hcFNhdmVfQXBwXzE1NjgyMjYwNjc3MzY3NTVfMTA4MHAubXA0In0.3rUj4eUmqKLYJDFl1EfcJBxkZ0KpSlWVQm2JCdxJa0U";
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const tokenPayload = url.split("token=")[1].split(".")[1];
|
||||||
|
const decoded = Buffer.from(tokenPayload, 'base64').toString('utf8');
|
||||||
|
console.log(JSON.parse(decoded));
|
||||||
|
}
|
||||||
|
run();
|
||||||
521
plans/social-media-downloader-plan.md
Normal file
@@ -0,0 +1,521 @@
|
|||||||
|
# Sosyal Medya İndirici - Mimari Plan
|
||||||
|
|
||||||
|
> **Tarih:** 2026-03-05
|
||||||
|
> **Öncelik:** Yüksek
|
||||||
|
> **Karmaşıklık:** Düşük-Orta
|
||||||
|
> **Yaklaşım:** Basit ve Minimal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Özet
|
||||||
|
|
||||||
|
Sosyal medya platformlarından (Instagram, YouTube, TikTok, X, Facebook vb.) video/post indirme özelliği. Kullanıcı bir URL girer, sistem içeriği tespit eder ve indirir.
|
||||||
|
|
||||||
|
### Basitleştirilmiş Gereksinimler
|
||||||
|
|
||||||
|
- ❌ Auth/giriş sistemi **YOK** (herkes kullanabilir)
|
||||||
|
- ✅ Basit IP-based rate limiting (abuse önleme)
|
||||||
|
- ✅ Public Cobalt API kullanımı
|
||||||
|
- ✅ Minimal ve temiz UI
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Gereksinimler
|
||||||
|
|
||||||
|
### Fonksiyonel Gereksinimler
|
||||||
|
|
||||||
|
- URL input ile çoklu platform desteği
|
||||||
|
- Tek tıkla indirme
|
||||||
|
- Video kalite seçimi (mümkünse)
|
||||||
|
- İndirme ilerleme durumu
|
||||||
|
- Hata yönetimi ve kullanıcı bildirimleri
|
||||||
|
|
||||||
|
### Fonksiyonel Olmayan Gereksinimler
|
||||||
|
|
||||||
|
- **Güvenlik:** Rate limiting, input validation, abuse prevention
|
||||||
|
- **Performans:** Async processing, streaming downloads
|
||||||
|
- **UX:** Responsive design, dark/light mode, i18n support
|
||||||
|
- **Reliability:** Error recovery, timeout handling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ Mimari Tasarım
|
||||||
|
|
||||||
|
### High-Level Architecture
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TB
|
||||||
|
subgraph Frontend[Frontend - Next.js]
|
||||||
|
UI[Download Page]
|
||||||
|
Form[URL Input Form]
|
||||||
|
Progress[Progress Indicator]
|
||||||
|
Result[Download Result]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph API[Next.js API Routes]
|
||||||
|
Validate[Input Validation]
|
||||||
|
Detect[Platform Detection]
|
||||||
|
RateLimit[Rate Limiter]
|
||||||
|
Download[Download Handler]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph External[External Services]
|
||||||
|
Cobalt[Cobalt API]
|
||||||
|
YtDlp[yt-dlp wrapper]
|
||||||
|
Direct[Direct URL Fetch]
|
||||||
|
end
|
||||||
|
|
||||||
|
UI --> Form
|
||||||
|
Form --> Validate
|
||||||
|
Validate --> RateLimit
|
||||||
|
RateLimit --> Detect
|
||||||
|
Detect --> Download
|
||||||
|
Download --> Cobalt
|
||||||
|
Download --> YtDlp
|
||||||
|
Download --> Direct
|
||||||
|
Cobalt --> Result
|
||||||
|
YtDlp --> Result
|
||||||
|
Direct --> Result
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Güvenlik Katmanları
|
||||||
|
|
||||||
|
### 1. Input Validation & Sanitization
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/lib/utils/url-validator.ts
|
||||||
|
const ALLOWED_DOMAINS = [
|
||||||
|
"youtube.com",
|
||||||
|
"youtu.be",
|
||||||
|
"instagram.com",
|
||||||
|
"instagr.am",
|
||||||
|
"tiktok.com",
|
||||||
|
"vm.tiktok.com",
|
||||||
|
"twitter.com",
|
||||||
|
"x.com",
|
||||||
|
"facebook.com",
|
||||||
|
"fb.watch",
|
||||||
|
"vimeo.com",
|
||||||
|
"pinterest.com",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function validateUrl(url: string): ValidationResult {
|
||||||
|
// 1. URL format check
|
||||||
|
// 2. Protocol check (https only)
|
||||||
|
// 3. Domain whitelist check
|
||||||
|
// 4. XSS prevention
|
||||||
|
// 5. SQL injection prevention
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Rate Limiting
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/lib/security/rate-limiter.ts
|
||||||
|
const RATE_LIMIT_CONFIG = {
|
||||||
|
anonymous: { requests: 5, window: 3600 }, // 5 requests/hour
|
||||||
|
authenticated: { requests: 20, window: 3600 }, // 20 requests/hour
|
||||||
|
premium: { requests: 100, window: 3600 }, // 100 requests/hour
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stratejiler:**
|
||||||
|
|
||||||
|
- IP-based limiting (Redis/Memory store)
|
||||||
|
- User-based limiting (auth token)
|
||||||
|
- Global rate limit (DDoS prevention)
|
||||||
|
|
||||||
|
### 3. Abuse Prevention
|
||||||
|
|
||||||
|
- **URL Pattern Blocking:** Malicious patterns engelleme
|
||||||
|
- **Bot Detection:** User-agent analysis
|
||||||
|
- **Request Fingerprinting:** Browser fingerprinting
|
||||||
|
- **CAPTCHA:** Şüpheli aktivitede CAPTCHA tetikleme
|
||||||
|
|
||||||
|
### 4. Content Security
|
||||||
|
|
||||||
|
- **Virus Scanning:** İndirilen dosyaları tara
|
||||||
|
- **File Size Limits:** Maksimum dosya boyutu
|
||||||
|
- **Content-Type Validation:** Sadece izin verilen formatlar
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Dosya Yapısı
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/
|
||||||
|
│ ├── [locale]/
|
||||||
|
│ │ └── (site)/
|
||||||
|
│ │ └── download/
|
||||||
|
│ │ └── page.tsx # Download page
|
||||||
|
│ └── api/
|
||||||
|
│ └── download/
|
||||||
|
│ ├── route.ts # Main download endpoint
|
||||||
|
│ └── info/
|
||||||
|
│ └── route.ts # Get video info endpoint
|
||||||
|
├── components/
|
||||||
|
│ └── site/
|
||||||
|
│ └── download/
|
||||||
|
│ ├── download-form.tsx # URL input form
|
||||||
|
│ ├── download-result.tsx # Result display
|
||||||
|
│ ├── platform-badge.tsx # Platform indicator
|
||||||
|
│ ├── quality-selector.tsx # Quality options
|
||||||
|
│ └── download-progress.tsx # Progress indicator
|
||||||
|
├── lib/
|
||||||
|
│ ├── api/
|
||||||
|
│ │ └── download/
|
||||||
|
│ │ ├── types.ts # TypeScript interfaces
|
||||||
|
│ │ ├── service.ts # API service functions
|
||||||
|
│ │ └── use-hooks.ts # React Query hooks
|
||||||
|
│ ├── download/
|
||||||
|
│ │ ├── platform-detector.ts # URL -> Platform mapping
|
||||||
|
│ │ ├── cobalt-client.ts # Cobalt API client
|
||||||
|
│ │ └── download-manager.ts # Download orchestration
|
||||||
|
│ └── security/
|
||||||
|
│ ├── rate-limiter.ts # Rate limiting logic
|
||||||
|
│ ├── url-validator.ts # URL validation
|
||||||
|
│ └── abuse-detector.ts # Abuse detection
|
||||||
|
└── types/
|
||||||
|
└── download.ts # Shared types
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔌 API Endpoints
|
||||||
|
|
||||||
|
### POST /api/download
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface DownloadRequest {
|
||||||
|
url: string; // Sosyal medya URL
|
||||||
|
quality?: "high" | "medium" | "low";
|
||||||
|
format?: "mp4" | "mp3" | "webm";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface DownloadResponse {
|
||||||
|
success: boolean;
|
||||||
|
data?: {
|
||||||
|
downloadUrl: string;
|
||||||
|
filename: string;
|
||||||
|
fileSize: number;
|
||||||
|
platform: Platform;
|
||||||
|
metadata: VideoMetadata;
|
||||||
|
};
|
||||||
|
error?: {
|
||||||
|
code: ErrorCode;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/download/info
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface InfoRequest {
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface InfoResponse {
|
||||||
|
success: boolean;
|
||||||
|
data?: {
|
||||||
|
platform: Platform;
|
||||||
|
title: string;
|
||||||
|
thumbnail: string;
|
||||||
|
duration: number;
|
||||||
|
qualities: QualityOption[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 Platform Detection Algorithm
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/lib/download/platform-detector.ts
|
||||||
|
|
||||||
|
interface PlatformConfig {
|
||||||
|
name: Platform;
|
||||||
|
domains: string[];
|
||||||
|
patterns: RegExp[];
|
||||||
|
extractor: ExtractorType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLATFORM_CONFIGS: PlatformConfig[] = [
|
||||||
|
{
|
||||||
|
name: "youtube",
|
||||||
|
domains: ["youtube.com", "youtu.be", "music.youtube.com"],
|
||||||
|
patterns: [
|
||||||
|
/youtube\.com\/watch\?v=[\w-]+/,
|
||||||
|
/youtu\.be\/[\w-]+/,
|
||||||
|
/youtube\.com\/shorts\/[\w-]+/,
|
||||||
|
],
|
||||||
|
extractor: "cobalt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "instagram",
|
||||||
|
domains: ["instagram.com", "instagr.am"],
|
||||||
|
patterns: [/instagram\.com\/(p|reel|tv)\/[\w-]+/],
|
||||||
|
extractor: "cobalt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tiktok",
|
||||||
|
domains: ["tiktok.com", "vm.tiktok.com"],
|
||||||
|
patterns: [/tiktok\.com\/@[\w.]+\/video\/\d+/, /vm\.tiktok\.com\/[\w-]+/],
|
||||||
|
extractor: "cobalt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "twitter",
|
||||||
|
domains: ["twitter.com", "x.com"],
|
||||||
|
patterns: [/twitter\.com\/\w+\/status\/\d+/, /x\.com\/\w+\/status\/\d+/],
|
||||||
|
extractor: "cobalt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "facebook",
|
||||||
|
domains: ["facebook.com", "fb.watch"],
|
||||||
|
patterns: [/facebook\.com\/.*\/videos\/\d+/, /fb\.watch\/[\w-]+/],
|
||||||
|
extractor: "cobalt",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function detectPlatform(url: string): Platform | null {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
|
||||||
|
for (const config of PLATFORM_CONFIGS) {
|
||||||
|
if (config.domains.some((d) => urlObj.hostname.includes(d))) {
|
||||||
|
return config.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 UI Component Hierarchy
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Page[DownloadPage] --> Form[DownloadForm]
|
||||||
|
Page --> Result[DownloadResult]
|
||||||
|
|
||||||
|
Form --> Input[URLInput]
|
||||||
|
Form --> PlatformBadge[PlatformBadge]
|
||||||
|
Form --> QualitySelector[QualitySelector]
|
||||||
|
Form --> SubmitButton[SubmitButton]
|
||||||
|
|
||||||
|
Result --> Progress[DownloadProgress]
|
||||||
|
Result --> SuccessResult[SuccessResult]
|
||||||
|
Result --> ErrorResult[ErrorResult]
|
||||||
|
|
||||||
|
Progress --> ProgressBar[ProgressBar]
|
||||||
|
Progress --> StatusText[StatusText]
|
||||||
|
|
||||||
|
SuccessResult --> FilePreview[FilePreview]
|
||||||
|
SuccessResult --> DownloadButton[DownloadButton]
|
||||||
|
|
||||||
|
ErrorResult --> ErrorIcon[ErrorIcon]
|
||||||
|
ErrorResult --> ErrorMessage[ErrorMessage]
|
||||||
|
ErrorResult --> RetryButton[RetryButton]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Veri Akışı
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant User
|
||||||
|
participant Frontend
|
||||||
|
participant API as Next.js API
|
||||||
|
participant Validator
|
||||||
|
participant RateLimiter
|
||||||
|
participant Cobalt as Cobalt API
|
||||||
|
|
||||||
|
User->>Frontend: URL Gir + İndir
|
||||||
|
Frontend->>Frontend: URL Format Validation
|
||||||
|
Frontend->>API: POST /api/download/info
|
||||||
|
API->>Validator: Validate URL
|
||||||
|
Validator-->>API: Valid
|
||||||
|
API->>RateLimiter: Check Rate Limit
|
||||||
|
RateLimiter-->>API: Allowed
|
||||||
|
API->>Cobalt: Get Video Info
|
||||||
|
Cobalt-->>API: Video Metadata
|
||||||
|
API-->>Frontend: Info Response
|
||||||
|
Frontend->>User: Show Options
|
||||||
|
|
||||||
|
User->>Frontend: Select Quality
|
||||||
|
Frontend->>API: POST /api/download
|
||||||
|
API->>Cobalt: Request Download
|
||||||
|
Cobalt-->>API: Download URL
|
||||||
|
API-->>Frontend: Download Response
|
||||||
|
Frontend->>User: Download File
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛡️ Güvenlik Checklist
|
||||||
|
|
||||||
|
### Input Security
|
||||||
|
|
||||||
|
- [ ] URL format validation
|
||||||
|
- [ ] Protocol whitelist (https only)
|
||||||
|
- [ ] Domain whitelist
|
||||||
|
- [ ] XSS prevention (DOMPurify)
|
||||||
|
- [ ] SQL injection prevention
|
||||||
|
- [ ] Path traversal prevention
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
- [ ] IP-based limiting
|
||||||
|
- [ ] User-based limiting (authenticated)
|
||||||
|
- [ ] Global request limiting
|
||||||
|
- [ ] Sliding window algorithm
|
||||||
|
|
||||||
|
### Content Security
|
||||||
|
|
||||||
|
- [ ] File type validation
|
||||||
|
- [ ] File size limits
|
||||||
|
- [ ] Virus/malware scanning (optional)
|
||||||
|
- [ ] Content-Disposition headers
|
||||||
|
|
||||||
|
### API Security
|
||||||
|
|
||||||
|
- [ ] CSRF protection
|
||||||
|
- [ ] Content-Type validation
|
||||||
|
- [ ] Request size limits
|
||||||
|
- [ ] Timeout handling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Error Handling Strategy
|
||||||
|
|
||||||
|
| Error Code | Message | User Action |
|
||||||
|
| ---------------------- | -------------------------- | ---------------------------- |
|
||||||
|
| `INVALID_URL` | Geçersiz URL formatı | URL'yi kontrol et |
|
||||||
|
| `UNSUPPORTED_PLATFORM` | Bu platform desteklenmiyor | Desteklenen platformları gör |
|
||||||
|
| `RATE_LIMIT_EXCEEDED` | Çok fazla istek | Bekle veya giriş yap |
|
||||||
|
| `VIDEO_NOT_FOUND` | Video bulunamadı | URL'yi kontrol et |
|
||||||
|
| `VIDEO_PRIVATE` | Video özel/gizli | Herkese açık video kullan |
|
||||||
|
| `DOWNLOAD_FAILED` | İndirme başarısız | Tekrar dene |
|
||||||
|
| `FILE_TOO_LARGE` | Dosya çok büyük | Düşük kalite seç |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Test Strategy
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
- URL validator fonksiyonları
|
||||||
|
- Platform detector logic
|
||||||
|
- Rate limiter calculations
|
||||||
|
- API service functions
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
- API endpoint tests
|
||||||
|
- External API mock tests
|
||||||
|
- Error scenario tests
|
||||||
|
|
||||||
|
### E2E Tests
|
||||||
|
|
||||||
|
- Complete download flow
|
||||||
|
- Error handling UI
|
||||||
|
- Rate limit scenarios
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Dependencies
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"axios": "^1.x",
|
||||||
|
"zod": "^3.x"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"@types/node": "^20",
|
||||||
|
"vitest": "^1.x",
|
||||||
|
"msw": "^2.x"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Core Infrastructure
|
||||||
|
|
||||||
|
1. URL validation ve platform detection
|
||||||
|
2. API route structure
|
||||||
|
3. Basic UI components
|
||||||
|
|
||||||
|
### Phase 2: External API Integration
|
||||||
|
|
||||||
|
1. Cobalt API client
|
||||||
|
2. Download orchestration
|
||||||
|
3. Error handling
|
||||||
|
|
||||||
|
### Phase 3: Security Layer
|
||||||
|
|
||||||
|
1. Rate limiting implementation
|
||||||
|
2. Abuse detection
|
||||||
|
3. Input sanitization
|
||||||
|
|
||||||
|
### Phase 4: UI/UX Polish
|
||||||
|
|
||||||
|
1. Progress indicators
|
||||||
|
2. Responsive design
|
||||||
|
3. Dark/light mode
|
||||||
|
4. i18n translations
|
||||||
|
|
||||||
|
### Phase 5: Testing & Optimization
|
||||||
|
|
||||||
|
1. Unit tests
|
||||||
|
2. Integration tests
|
||||||
|
3. Performance optimization
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 Önemli Notlar
|
||||||
|
|
||||||
|
1. **Cobalt API:** Açık kaynaklı, self-hosted bir sosyal medya indirici API. Kullanımı için:
|
||||||
|
- Self-hosted instance (önerilen)
|
||||||
|
- Public instance (rate limit riski)
|
||||||
|
|
||||||
|
2. **Copyright Uyarısı:** Kullanıcılara telif hakkı uyarısı gösterilmeli
|
||||||
|
|
||||||
|
3. **Terms of Service:** Her platformun ToS'u kontrol edilmeli
|
||||||
|
|
||||||
|
4. **Monitoring:** API kullanımı ve hata oranları izlenmeli
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ❓ Açık Sorular
|
||||||
|
|
||||||
|
1. Cobalt API self-hosted mu kullanılacak yoksa public instance mi?
|
||||||
|
2. Video maksimum boyutu ne olacak?
|
||||||
|
3. Oturum açmamış kullanıcılar için limit ne kadar?
|
||||||
|
4. İndirilen dosyalar sunucuda saklanacak mı (cache)?
|
||||||
BIN
public/favicon/.DS_Store
vendored
Normal file
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 9.1 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 558 B |
|
Before Width: | Height: | Size: 1.2 KiB |
BIN
public/favicon/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
1
public/favicon/favicon.svg
Normal file
|
After Width: | Height: | Size: 654 KiB |
BIN
public/favicon/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
public/favicon/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
@@ -27,9 +27,10 @@ export default async function RootLayout({
|
|||||||
<html lang={locale} dir={dir(locale)} suppressHydrationWarning data-scroll-behavior='smooth'>
|
<html lang={locale} dir={dir(locale)} suppressHydrationWarning data-scroll-behavior='smooth'>
|
||||||
<head>
|
<head>
|
||||||
<link rel='apple-touch-icon' sizes='180x180' href='/favicon/apple-touch-icon.png' />
|
<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='icon' type='image/png' sizes='16x16' href='/favicon/favicon-16x16.png' />
|
||||||
<link rel='manifest' href='/favicon/site.webmanifest' />
|
<link rel='manifest' href='/favicon/site.webmanifest' />
|
||||||
|
<title>Sosyal Medya Post İndirme</title>
|
||||||
</head>
|
</head>
|
||||||
<body className={bricolage.variable}>
|
<body className={bricolage.variable}>
|
||||||
<NextIntlClientProvider>
|
<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() {
|
'use client';
|
||||||
redirect('/home');
|
|
||||||
|
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
@@ -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
@@ -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
@@ -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
@@ -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[] = [
|
export const NAV_ITEMS: NavItem[] = [
|
||||||
{ label: "home", href: "/home", public: true },
|
{ label: "home", href: "/", public: true },
|
||||||
{ label: "predictions", href: "/predictions", public: true },
|
|
||||||
];
|
];
|
||||||
|
|||||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -1,55 +1,19 @@
|
|||||||
import { NAV_ITEMS } from "@/config/navigation";
|
|
||||||
import { withAuth } from "next-auth/middleware";
|
|
||||||
import createMiddleware from "next-intl/middleware";
|
import createMiddleware from "next-intl/middleware";
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { routing } from "./i18n/routing";
|
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 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) {
|
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/")) {
|
if (req.nextUrl.pathname.startsWith("/api/")) {
|
||||||
return; // Return undefined to pass through without modification
|
return; // Return undefined to pass through without modification
|
||||||
}
|
}
|
||||||
|
|
||||||
const publicPathnameRegex = RegExp(
|
// All pages are public - no auth required
|
||||||
`^(/(${routing.locales.join("|")}))?(${publicPages.flatMap((p) => (p === "/" ? ["", "/"] : p)).join("|")})/?$`,
|
return handleI18nRouting(req);
|
||||||
"i",
|
|
||||||
);
|
|
||||||
const isPublicPage = publicPathnameRegex.test(req.nextUrl.pathname);
|
|
||||||
|
|
||||||
if (isPublicPage) {
|
|
||||||
return handleI18nRouting(req);
|
|
||||||
} else {
|
|
||||||
return (authMiddleware as any)(req);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: "/((?!api|trpc|_next|_vercel|.*\\..*).*)",
|
matcher: "/((?!api|trpc|_next|_vercel|.*\\..*).*)",
|
||||||
// matcher: ['/', '/(de|en|tr)/:path*'],
|
|
||||||
};
|
};
|
||||||
|
|||||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>;
|
||||||
|
}
|
||||||