commit a9d23441cbafe7cb3d571bdc1bd239d4bff4630f Author: szbk Date: Tue Oct 21 18:43:21 2025 +0300 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a280bb3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,69 @@ +# Node modules +/node_modules/ +jspm_packages/ + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Runtime data +pids *.pid *.seed *.pid.lock + +# Coverage directory +.nyc_output/ +coverage/ +lcov-report/ + +# Environment variables +.env +.env.*.local +!.env.example + +# Build / output directories +/dist/ +/build/ +/output/ +/. svelte-kit/ +.svelte-kit/ +.vite/ +.tmp/ +.cache/ + +# Docker files / volumes +docker-compose.override.yml +docker-compose.*.yml +docker/*-volume/ +docker/*-data/ +*.tar +*.img + +# OS / IDE stuff +.DS_Store +Thumbs.db +desktop.ini +*.swp +.vscode/ +.idea/ +*.sublime-project +*.sublime-workspace + +# Media / Download directories (depending on your setup) +downloads/ +movie/movieData/ +movie/movieData/**/subtitles/ +movie/movieData/**/poster.jpg +movie/movieData/**/backdrop.jpg + +# Torrent / upload temp files +/uploads/ +/uploads/* +*.torrent +*.part +*.temp + +# Other sensitive files +/key.pem +/cert.pem +*.log diff --git a/client/Dockerfile b/client/Dockerfile new file mode 100644 index 0000000..5ebae63 --- /dev/null +++ b/client/Dockerfile @@ -0,0 +1,7 @@ +FROM node:22-alpine +WORKDIR /app +COPY package*.json ./ +RUN npm ci || npm i +COPY . . +EXPOSE 5173 +CMD ["npm","run","dev","--","--host","0.0.0.0"] diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..aca9e85 --- /dev/null +++ b/client/index.html @@ -0,0 +1,18 @@ + + + + + + + du.pe + + +
+ + + \ No newline at end of file diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..629675c --- /dev/null +++ b/client/package.json @@ -0,0 +1,19 @@ +{ + "name": "dupe-client", + "version": "1.2.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "preview": "vite preview", + "dev": "vite --host 0.0.0.0 --port 5173" + }, + "dependencies": { + "svelte": "^4.2.18", + "svelte-routing": "^2.0.0" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^3.1.2", + "vite": "^5.4.10" + } +} \ No newline at end of file diff --git a/client/src/App.svelte b/client/src/App.svelte new file mode 100644 index 0000000..21fa000 --- /dev/null +++ b/client/src/App.svelte @@ -0,0 +1,35 @@ + + + +
+ +
+ + + + + +
+ {#if menuOpen} +
{ + menuOpen = false; + }} + >
+ {/if} +
+
diff --git a/client/src/components/Sidebar.svelte b/client/src/components/Sidebar.svelte new file mode 100644 index 0000000..3292ab3 --- /dev/null +++ b/client/src/components/Sidebar.svelte @@ -0,0 +1,22 @@ + + + diff --git a/client/src/components/Topbar.svelte b/client/src/components/Topbar.svelte new file mode 100644 index 0000000..913e44c --- /dev/null +++ b/client/src/components/Topbar.svelte @@ -0,0 +1,20 @@ + + +
+ + + + +
diff --git a/client/src/components/TorrentItem.svelte b/client/src/components/TorrentItem.svelte new file mode 100644 index 0000000..c573985 --- /dev/null +++ b/client/src/components/TorrentItem.svelte @@ -0,0 +1,11 @@ + + +
+
+
{t.name}
+
+
{Math.round(t.progress * 100)}% - {t.downloadSpeed} KB/s
+
+
\ No newline at end of file diff --git a/client/src/main.js b/client/src/main.js new file mode 100644 index 0000000..2c791a3 --- /dev/null +++ b/client/src/main.js @@ -0,0 +1,8 @@ +import App from "./App.svelte"; +import "./styles/main.css"; + +const app = new App({ + target: document.getElementById("app"), +}); + +export default app; \ No newline at end of file diff --git a/client/src/routes/Files.svelte b/client/src/routes/Files.svelte new file mode 100644 index 0000000..2a0ff17 --- /dev/null +++ b/client/src/routes/Files.svelte @@ -0,0 +1,541 @@ + + +
+

Media Library

+ + {#if files.length === 0} +
+
+
No media found
+
+ {:else} + + {/if} +
+ +{#if showModal && selectedVideo} + +{/if} + + diff --git a/client/src/routes/Sharing.svelte b/client/src/routes/Sharing.svelte new file mode 100644 index 0000000..a2bcb66 --- /dev/null +++ b/client/src/routes/Sharing.svelte @@ -0,0 +1,4 @@ +
+

Sharing

+

No shared files yet.

+
\ No newline at end of file diff --git a/client/src/routes/Transfers.svelte b/client/src/routes/Transfers.svelte new file mode 100644 index 0000000..048ff2f --- /dev/null +++ b/client/src/routes/Transfers.svelte @@ -0,0 +1,885 @@ + + +
+

Transfers

+ +
+ + +
+ + {#if torrents.length === 0} +
+
+
No files whatsoever!
+
+ {:else} +
+ {#each torrents as t (t.infoHash)} +
openModal(t)}> + {#if t.thumbnail} + thumb + {:else} +
📷
+ {/if} + +
+
+
{t.name}
+ +
+ +
+ Hash: {t.infoHash} | Tracker: {t.tracker ?? "Unknown"} | Added: + {t.added ? new Date(t.added).toLocaleString() : "Unknown"} +
+ +
+ {#each t.files as f} +
+ +
{f.name}
+
+ {(f.length / 1e6).toFixed(1)} MB +
+
+ {/each} +
+ +
+
+
+ +
+ {#if (t.progress || 0) < 1} + {(t.progress * 100).toFixed(1)}% • + {t.downloaded ? (t.downloaded / 1e6).toFixed(1) : 0} MB • + {formatSpeed(t.downloadSpeed)} ↓ • + {t.numPeers ?? 0} peers + {:else} + 100.0% • {(t.downloaded / 1e6).toFixed(1)} MB + {/if} +
+
+
+ {/each} +
+ {/if} +
+ +{#if showModal && selectedVideo} + +{/if} + + diff --git a/client/src/routes/Trash.svelte b/client/src/routes/Trash.svelte new file mode 100644 index 0000000..e380041 --- /dev/null +++ b/client/src/routes/Trash.svelte @@ -0,0 +1,4 @@ +
+

Trash

+

Trash is empty.

+
\ No newline at end of file diff --git a/client/src/styles/main.css b/client/src/styles/main.css new file mode 100644 index 0000000..b76d4a8 --- /dev/null +++ b/client/src/styles/main.css @@ -0,0 +1,322 @@ +:root { + --yellow: #f5b333; + --yellow-dark: #e2a62f; + --sidebar: #f4f4f4; + --border: #e5e5e5; + --muted: #666; + --green: #4caf50; +} +* { + box-sizing: border-box; +} +html, +body, +#app { + height: 100%; + margin: 0; + font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, + Arial, sans-serif; + color: #222; + background: #fff; +} +.app { + display: grid; + grid-template-columns: 220px 1fr; + height: 100%; +} +/* Sidebar */ +.sidebar { + background: var(--sidebar); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; +} +.sidebar .logo { + padding: 12px 16px; + font-weight: 900; + font-size: 28px; + letter-spacing: 0.5px; +} +.sidebar .menu { + padding-top: 6px; +} +.sidebar .menu .item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + color: #222; + cursor: pointer; + text-decoration: none; +} +.sidebar .menu .item.active { + background: #fff; + border-left: 3px solid var(--yellow); +} +.sidebar .menu .item .icon { + width: 18px; + text-align: center; + color: #333; +} +/* Content */ +.content { + display: flex; + flex-direction: column; + height: 100%; +} +.topbar { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--border); +} +.search { + flex: 1; + display: flex; + align-items: center; + gap: 8px; + background: #f8f8f8; + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px 12px; +} +.search input { + border: none; + outline: none; + background: transparent; + flex: 1; +} +.btn-primary { + background: var(--yellow); + border: 1px solid var(--yellow-dark); + color: #222; + font-weight: 700; + padding: 10px 14px; + border-radius: 6px; + cursor: pointer; +} +.btn-primary:active { + transform: translateY(1px); +} +/* Files */ +.files { + margin: 0 16px 16px 16px; + flex: 1; + border-top: 2px solid #f0c24d; + padding-top: 14px; +} +.files h2 { + margin: 0 0 10px 0; +} +.empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 260px; + gap: 10px; + border: 2px dashed var(--border); + border-radius: 8px; +} +.create-folder { + background: var(--yellow); + border: 1px solid var(--yellow-dark); + padding: 8px 12px; + border-radius: 6px; + font-weight: 700; + cursor: pointer; +} +/* Transfers Page */ +.torrent { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border-bottom: 1px solid var(--border); +} +.torrent:last-child { + border-bottom: none; +} +.progress { + height: 8px; + background: #eee; + border-radius: 99px; + overflow: hidden; + flex: 1; +} +.progress > div { + height: 100%; + background: var(--green); + transition: width 0.3s; +} +.small { + color: var(--muted); + font-size: 12px; +} +/* ====== Responsive & Off-Canvas Sidebar (EKLENDİ) ====== */ + +/* Hamburger butonunu varsayılan gizle; mobilde göstereceğiz */ +.menu-toggle { + display: none; + background: none; + border: none; + font-size: 20px; + color: #333; + cursor: pointer; +} + +/* Sidebar arkası için tıklanabilir backdrop (mobilde sidebar açıkken) */ +.backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease; + z-index: 999; /* sidebar’ın üstünde */ +} +.backdrop.show { + opacity: 1; + pointer-events: auto; +} + +/* Tablet ve aşağısında grid tek sütun; sidebar off-canvas olur */ +@media (max-width: 768px) { + .app { + grid-template-columns: 1fr; + } + + .menu-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + } + + .sidebar { + position: fixed; + top: 0; + left: -240px; + width: 220px; + height: 100%; + background: var(--sidebar); + border-right: 1px solid var(--border); + transition: left 0.25s ease; + z-index: 1000; + } + .sidebar.open { + left: 0; + } + + /* Genel içerik kenar boşluklarını sıkılaştır */ + .files { + margin: 0 10px 14px 10px; + padding-top: 12px; + } + + /* Transfers ve diğer sayfalar için liste öğelerini dikeyleştir */ + .torrent { + /* Transfers’teki kutular */ + grid-template-columns: 1fr !important; + gap: 10px; + } + .thumb { + width: 100% !important; + height: 180px !important; + } + .torrent-header { + flex-direction: column; + align-items: flex-start; + gap: 4px; + } + .torrent-hash { + word-break: break-word; + white-space: normal; + font-size: 12px; + line-height: 1.3; + } + .file-row { + flex-direction: column; + align-items: flex-start; + gap: 4px; + } + .progress-text { + text-align: left; + font-size: 12px; + } + + /* Butonlar eş görünsün ve kolay dokunulsun */ + .btn-primary { + flex: 1 1 auto; + justify-content: center; + height: 36px; + padding: 8px 12px; + font-size: 13px; + } + + /* Modal video oynatıcı mobil uyum */ + .modal-content { + width: 95% !important; + height: 72% !important; + border-radius: 10px; + } + .controls { + padding: 8px 12px; + gap: 8px; + } + .volume-slider { + width: 70px; + } + .time { + font-size: 12px; + min-width: 78px; + } +} + +/* === Sidebar Hover & Active Effects === */ + +/* Hover efekti: hafif gri arka plan */ +.sidebar .menu .item:hover { + background: #f0f0f0; + color: #000; + transition: background 0.2s ease, color 0.2s ease; +} + +/* Aktif item: Daha koyu gri arka plan */ +.sidebar .menu .item.active { + background: #e5e5e5; /* aktif olan menü item */ + font-weight: 600; + color: #000; +} + +/* Hover ve aktif durumlarda ikon da koyulaşsın */ +.sidebar .menu .item:hover .icon, +.sidebar .menu .item.active .icon { + color: #000; +} + +/* Daha küçük telefonlar */ +@media (max-width: 480px) { + .btn-primary { + font-size: 12px; + padding: 8px 10px; + height: 34px; + } + .torrent-hash { + font-size: 11px; + } + .modal-content { + width: 98% !important; + height: 76% !important; + } + .volume-slider { + width: 56px; + } + .bottom-controls { + flex-direction: column; + align-items: stretch; + gap: 6px; + } +} diff --git a/client/src/utils/api.js b/client/src/utils/api.js new file mode 100644 index 0000000..2854689 --- /dev/null +++ b/client/src/utils/api.js @@ -0,0 +1 @@ +export const API = import.meta.env.VITE_API || "http://localhost:3001"; \ No newline at end of file diff --git a/client/src/utils/filename.js b/client/src/utils/filename.js new file mode 100644 index 0000000..9bfb3c4 --- /dev/null +++ b/client/src/utils/filename.js @@ -0,0 +1,110 @@ +// utils/filename.js + +/** + * Dosya adını temizler ve sadeleştirir. + * Örnek: + * The.Astronaut.2025.1080p.WEBRip.x265-KONTRAST + * → "The Astronaut (2025)" + */ +export function cleanFileName(fullPath) { + if (!fullPath) return ""; + + // 1️⃣ Klasör yolunu kaldır + let name = fullPath.split("/").pop(); + + // 2️⃣ Uzantıyı kaldır + name = name.replace(/\.[^.]+$/, ""); + + // 3️⃣ Noktaları boşluğa çevir + name = name.replace(/\./g, " "); + + // 4️⃣ Gereksiz etiketleri kaldır + const trashWords = [ + "1080p", + "720p", + "2160p", + "4k", + "bluray", + "web-dl", + "webrip", + "hdrip", + "x264", + "x265", + "hevc", + "aac", + "h264", + "h265", + "dvdrip", + "brrip", + "remux", + "multi", + "sub", + "subs", + "turkce", + "dublado", + "dubbed", + "extended", + "unrated", + "repack", + "proper", + "kontrast", + "yify", + "ettv", + "rarbg", + "hdtv", + "amzn", + "nf", + "netflix" + ]; + const trashRegex = new RegExp(`\\b(${trashWords.join("|")})\\b`, "gi"); + name = name.replace(trashRegex, " "); + + // 5️⃣ Köşeli parantez içindekileri kaldır + name = name.replace(/\[[^\]]*\]/g, ""); + + // 6️⃣ Parantez içindeki tarihleri kaldır + name = name + .replace(/\(\d{2}\.\d{2}\.\d{2,4}\)/g, "") + .replace(/\(\d{4}(-\d{2})?(-\d{2})?\)/g, ""); + + // 7️⃣ Fazla boşlukları temizle + name = name.replace(/\s{2,}/g, " ").trim(); + + // 8️⃣ Yılı tespit et (ör. 2024, 1999) + const yearMatch = name.match(/\b(19|20)\d{2}\b/); + let year = ""; + if (yearMatch) { + year = yearMatch[0]; + name = name.replace(year, "").trim(); + } + + // 9️⃣ Dizi formatı (S03E01) varsa koru + const match = name.match(/(.+?)\s*-\s*(S\d{2}E\d{2})/i); + if (match) { + const formatted = `${match[1].trim()} - ${match[2].toUpperCase()}`; + return year ? `${formatted} (${year})` : formatted; + } + + // 🔟 Fazla tireleri ve tire + parantez boşluklarını düzelt + name = name + .replace(/[-_]+/g, " ") // birden fazla tireyi temizle + .replace(/\s-\s*\(/g, " (") // " - (" → " (" + .trim(); + + // 11️⃣ Baş harfleri büyüt + name = name + .split(" ") + .map( + (w) => + w.length > 1 + ? w[0].toUpperCase() + w.slice(1).toLowerCase() + : w.toUpperCase() + ) + .join(" ") + .trim(); + + // 12️⃣ Yıl varsa sonuna ekle + if (year) name += ` (${year})`; + + return name.trim(); +} diff --git a/client/vite.config.js b/client/vite.config.js new file mode 100644 index 0000000..b5740a4 --- /dev/null +++ b/client/vite.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; + +export default defineConfig({ + plugins: [svelte()], + server: { + host: '0.0.0.0', // dış erişim + port: 5173, + strictPort: true, + watch: { usePolling: true } // bazen hot reload için gerekir + } +}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bd15db5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +version: "3.9" + +networks: + dupe_network: + driver: bridge + +services: + server: + build: ./server + container_name: dupe-server + ports: + - "3001:3001" + networks: + - dupe_network + volumes: + - ./downloads:/app/downloads + restart: unless-stopped + + client: + build: ./client + container_name: dupe-client + depends_on: + - server + ports: + - "5173:5173" + networks: + - dupe_network + volumes: + - ./downloads:/app/downloads + environment: + - VITE_API=http://localhost:3001 + restart: unless-stopped diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..3ca2f3c --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,8 @@ +FROM node:22-slim +RUN apt-get update && apt-get install -y ffmpeg +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci || npm i +COPY . . +EXPOSE 3001 +CMD ["npm","start"] diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..62f7908 --- /dev/null +++ b/server/package.json @@ -0,0 +1,16 @@ +{ + "name": "dupe-server", + "version": "1.2.0", + "type": "module", + "scripts": { + "start": "node server.js" + }, + "dependencies": { + "cors": "^2.8.5", + "express": "^4.19.2", + "mime-types": "^2.1.35", + "multer": "^1.4.5-lts.1", + "webtorrent": "^1.9.7", + "ws": "^8.18.0" + } +} \ No newline at end of file diff --git a/server/server.js b/server/server.js new file mode 100644 index 0000000..2d5d94f --- /dev/null +++ b/server/server.js @@ -0,0 +1,316 @@ +import express from "express"; +import cors from "cors"; +import multer from "multer"; +import WebTorrent from "webtorrent"; +import fs from "fs"; +import path from "path"; +import mime from "mime-types"; +import { WebSocketServer } from "ws"; +import { fileURLToPath } from "url"; +import { exec } from "child_process"; // 🆕 ffmpeg çağırmak için + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const app = express(); +const upload = multer({ dest: path.join(__dirname, "uploads") }); +const client = new WebTorrent(); +const torrents = new Map(); +const PORT = process.env.PORT || 3001; + +// --- İndirilen dosyalar için klasör oluştur --- +const DOWNLOAD_DIR = path.join(__dirname, "downloads"); +if (!fs.existsSync(DOWNLOAD_DIR)) + fs.mkdirSync(DOWNLOAD_DIR, { recursive: true }); + +app.use(cors()); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); +app.use("/downloads", express.static(DOWNLOAD_DIR)); + +// --- En uygun video dosyasını seç --- +function pickBestVideoFile(torrent) { + const videoExts = [".mp4", ".webm", ".mkv", ".mov", ".m4v"]; + const videos = torrent.files + .map((f, i) => ({ i, f })) + .filter(({ f }) => videoExts.includes(path.extname(f.name).toLowerCase())); + if (!videos.length) return 0; + videos.sort((a, b) => b.f.length - a.f.length); + return videos[0].i; +} + +// --- Snapshot (thumbnail dahil, tracker + tarih eklendi) --- +function snapshot() { + return Array.from(torrents.values()).map( + ({ torrent, selectedIndex, savePath, added }) => { + const thumbPath = path.join(savePath, "thumbnail.jpg"); + const hasThumb = fs.existsSync(thumbPath); + return { + infoHash: torrent.infoHash, + name: torrent.name, + progress: torrent.progress, + downloaded: torrent.downloaded, + downloadSpeed: torrent.downloadSpeed, + uploadSpeed: torrent.uploadSpeed, + numPeers: torrent.numPeers, + tracker: torrent.announce?.[0] || null, // 🆕 ilk tracker + added, // 🆕 eklenme zamanı + files: torrent.files.map((f, i) => ({ + index: i, + name: f.name, + length: f.length + })), + selectedIndex, + thumbnail: hasThumb ? `/thumbnail/${torrent.infoHash}` : null + }; + } + ); +} + +// --- Torrent veya magnet ekleme --- +app.post("/api/transfer", upload.single("torrent"), (req, res) => { + try { + let source = req.body.magnet; + if (req.file) source = fs.readFileSync(req.file.path); + if (!source) + return res.status(400).json({ error: "magnet veya .torrent gerekli" }); + + // Her torrent için ayrı klasör + const savePath = path.join(DOWNLOAD_DIR, Date.now().toString()); + fs.mkdirSync(savePath, { recursive: true }); + + const torrent = client.add(source, { announce: [], path: savePath }); + + // 🆕 Torrent eklendiği anda tarih kaydedelim + const added = Date.now(); + + torrents.set(torrent.infoHash, { + torrent, + selectedIndex: 0, + savePath, + added + }); + + // --- Metadata geldiğinde --- + torrent.on("ready", () => { + const selectedIndex = pickBestVideoFile(torrent); + torrents.set(torrent.infoHash, { + torrent, + selectedIndex, + savePath, + added + }); + res.json({ + ok: true, + infoHash: torrent.infoHash, + name: torrent.name, + selectedIndex, + tracker: torrent.announce?.[0] || null, // 🆕 + added, // 🆕 + files: torrent.files.map((f, i) => ({ + index: i, + name: f.name, + length: f.length + })) + }); + }); + + // --- İndirme tamamlandığında thumbnail oluştur --- + torrent.on("done", () => { + const entry = torrents.get(torrent.infoHash); + if (!entry) return; + + const videoFile = torrent.files[entry.selectedIndex]; + const videoPath = path.join(entry.savePath, videoFile.path); + const thumbnailPath = path.join(entry.savePath, "thumbnail.jpg"); + + const cmd = `ffmpeg -ss 00:00:30 -i "${videoPath}" -frames:v 1 -q:v 2 "${thumbnailPath}"`; + exec(cmd, (err) => { + if (err) console.warn(`⚠️ Thumbnail oluşturulamadı: ${err.message}`); + else console.log(`📸 Thumbnail oluşturuldu: ${thumbnailPath}`); + }); + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// --- Thumbnail endpoint --- +app.get("/thumbnail/:hash", (req, res) => { + const entry = torrents.get(req.params.hash); + if (!entry) return res.status(404).end(); + + const thumbnailPath = path.join(entry.savePath, "thumbnail.jpg"); + if (!fs.existsSync(thumbnailPath)) + return res.status(404).send("Thumbnail yok"); + + res.sendFile(thumbnailPath); +}); + +// --- Torrentleri listele --- +app.get("/api/torrents", (req, res) => { + res.json(snapshot()); +}); + +// --- Seçili dosya değiştir --- +app.post("/api/torrents/:hash/select/:index", (req, res) => { + const entry = torrents.get(req.params.hash); + if (!entry) return res.status(404).json({ error: "torrent bulunamadı" }); + entry.selectedIndex = Number(req.params.index) || 0; + res.json({ ok: true, selectedIndex: entry.selectedIndex }); +}); + +// --- Torrent silme (disk dahil) --- +app.delete("/api/torrents/:hash", (req, res) => { + const entry = torrents.get(req.params.hash); + if (!entry) return res.status(404).json({ error: "torrent bulunamadı" }); + + const { torrent, savePath } = entry; + torrent.destroy(() => { + torrents.delete(req.params.hash); + if (savePath && fs.existsSync(savePath)) { + try { + fs.rmSync(savePath, { recursive: true, force: true }); + console.log(`🗑️ ${savePath} klasörü silindi`); + } catch (err) { + console.warn(`⚠️ ${savePath} silinemedi:`, err.message); + } + } + res.json({ ok: true }); + }); +}); + +app.get("/media/:path(*)", (req, res) => { + const fullPath = path.join(DOWNLOAD_DIR, req.params.path); + if (!fs.existsSync(fullPath)) return res.status(404).send("File not found"); + + const stat = fs.statSync(fullPath); + const fileSize = stat.size; + const range = req.headers.range; + + if (range) { + const [startStr, endStr] = range.replace(/bytes=/, "").split("-"); + const start = parseInt(startStr, 10); + const end = endStr ? parseInt(endStr, 10) : fileSize - 1; + const chunkSize = end - start + 1; + const file = fs.createReadStream(fullPath, { start, end }); + const head = { + "Content-Range": `bytes ${start}-${end}/${fileSize}`, + "Accept-Ranges": "bytes", + "Content-Length": chunkSize, + "Content-Type": "video/mp4" + }; + res.writeHead(206, head); + file.pipe(res); + } else { + const head = { + "Content-Length": fileSize, + "Content-Type": "video/mp4" + }; + res.writeHead(200, head); + fs.createReadStream(fullPath).pipe(res); + } +}); + +// --- 📁 Dosya gezgini: /downloads altındaki dosyaları listele --- +app.get("/api/files", (req, res) => { + const walk = (dir) => { + let result = []; + const list = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of list) { + const full = path.join(dir, entry.name); + const rel = path.relative(DOWNLOAD_DIR, full); + + if (entry.isDirectory()) { + result = result.concat(walk(full)); + } else { + // thumbnail.jpg dosyasını listeleme + if (entry.name.toLowerCase() === "thumbnail.jpg") continue; + const size = fs.statSync(full).size; + const parts = rel.split(path.sep); + const rootHash = parts[0]; // ilk klasör adı + const thumbPath = path.join(DOWNLOAD_DIR, rootHash, "thumbnail.jpg"); + + // ✅ Thumbnail dosyası gerçekten varsa ekle + const thumb = fs.existsSync(thumbPath) + ? `/downloads/${rootHash}/thumbnail.jpg` + : null; + + result.push({ + name: rel, + size, + thumbnail: thumb + }); + } + } + + return result; + }; + + try { + const files = walk(DOWNLOAD_DIR); + res.json(files); + } catch (err) { + console.error("📁 Files API error:", err); + res.status(500).json({ error: err.message }); + } +}); + +// --- Stream endpoint --- +app.get("/stream/:hash", (req, res) => { + const entry = torrents.get(req.params.hash); + if (!entry) return res.status(404).end(); + + const file = + entry.torrent.files[entry.selectedIndex] || entry.torrent.files[0]; + const total = file.length; + const type = mime.lookup(file.name) || "video/mp4"; + const range = req.headers.range; + + if (!range) { + res.writeHead(200, { + "Content-Length": total, + "Content-Type": type, + "Accept-Ranges": "bytes" + }); + return file.createReadStream().pipe(res); + } + + const [s, e] = range.replace(/bytes=/, "").split("-"); + const start = parseInt(s, 10); + const end = e ? parseInt(e, 10) : total - 1; + + res.writeHead(206, { + "Content-Range": `bytes ${start}-${end}/${total}`, + "Accept-Ranges": "bytes", + "Content-Length": end - start + 1, + "Content-Type": type + }); + + const stream = file.createReadStream({ start, end }); + stream.on("error", (err) => console.warn("Stream error:", err.message)); + res.on("close", () => stream.destroy()); + stream.pipe(res); +}); + +console.log("📂 Download path:", DOWNLOAD_DIR); + +// --- WebSocket: anlık durum yayını --- +const server = app.listen(PORT, () => + console.log(`✅ WebTorrent server ${PORT} portunda çalışıyor`) +); +const wss = new WebSocketServer({ server }); +wss.on("connection", (ws) => { + ws.send(JSON.stringify({ type: "progress", torrents: snapshot() })); +}); + +setInterval(() => { + const data = JSON.stringify({ type: "progress", torrents: snapshot() }); + wss.clients.forEach((c) => c.readyState === 1 && c.send(data)); +}, 1000); + +client.on("error", (err) => { + if (!String(err).includes("uTP")) + console.error("WebTorrent error:", err.message); +});