From 0b99fce5a968b7094276a4fe118e77ea5d7f1e7b Mon Sep 17 00:00:00 2001 From: wisecolt Date: Mon, 26 Jan 2026 20:04:41 +0300 Subject: [PATCH] =?UTF-8?q?feat(transfers):=20mail.ru=20indirme=20deste?= =?UTF-8?q?=C4=9Fi=20ekle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mail.ru video URL'lerini desteklemek için sunucu ve istemci tarafında gerekli değişiklikler yapıldı. - Sunucu tarafında Mail.ru URL çözümleme (yt-dlp) ve indirme (aria2c) işlevselliği eklendi. - /api/mailru/download uç noktası oluşturuldu. - Dockerfile'a aria2c bağımlılığı eklendi. - Kullanıcı arayüzü Mail.ru URL'lerini kabul edecek ve indirme ilerlemesini gösterecek şekilde güncellendi. - İndirilen dosyalar için otomatik küçük resim oluşturma eklendi. --- Dockerfile | 2 +- client/src/routes/Transfers.svelte | 44 ++- server/server.js | 507 ++++++++++++++++++++++++++++- 3 files changed, 542 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index e6df726..8cc139e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ RUN npm run build # Build server FROM node:22-slim -RUN apt-get update && apt-get install -y ffmpeg curl && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y ffmpeg curl aria2 && rm -rf /var/lib/apt/lists/* RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp \ && chmod a+rx /usr/local/bin/yt-dlp WORKDIR /app/server diff --git a/client/src/routes/Transfers.svelte b/client/src/routes/Transfers.svelte index b6e6c14..b5c7884 100644 --- a/client/src/routes/Transfers.svelte +++ b/client/src/routes/Transfers.svelte @@ -98,8 +98,21 @@ } } + function normalizeMailRuUrl(value) { + if (!value || typeof value !== "string") return null; + try { + const url = new URL(value.trim()); + if (url.protocol !== "https:") return null; + const host = url.hostname.toLowerCase(); + if (!host.endsWith("mail.ru")) return null; + return url.toString(); + } catch { + return null; + } + } + async function handleUrlInput() { - const input = prompt("Magnet veya YouTube URL girin:"); + const input = prompt("Magnet, YouTube veya Mail.ru URL girin:"); if (!input) return; if (isMagnetLink(input)) { await apiFetch("/api/transfer", { @@ -125,8 +138,23 @@ await list(); return; } + const normalizedMailRu = normalizeMailRuUrl(input); + if (normalizedMailRu) { + const resp = await apiFetch("/api/mailru/download", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: normalizedMailRu }) + }); + if (!resp.ok) { + const data = await resp.json().catch(() => null); + alert(data?.error || "Mail.ru indirmesi başlatılamadı"); + return; + } + await list(); + return; + } alert( - "Yalnızca magnet linkleri veya https://www.youtube.com/watch?v=... formatındaki YouTube URL'leri destekleniyor." + "Yalnızca magnet linkleri, https://www.youtube.com/watch?v=... formatındaki YouTube URL'leri veya mail.ru linkleri destekleniyor." ); } @@ -556,7 +584,7 @@ class="thumb" on:load={(e) => e.target.classList.add("loaded")} /> - {:else if t.type === "youtube" && (!t.progress || t.progress <= 0)} + {:else if (t.type === "youtube" || t.type === "mailru") && (!t.progress || t.progress <= 0)}
@@ -570,9 +598,9 @@
{t.name}
- {#if t.type === "youtube"} + {#if t.type === "youtube" || t.type === "mailru"}
- Source: YouTube + Source: {t.type === "mailru" ? "Mail.ru" : "YouTube"}
Added: {formatDate(t.added)} @@ -580,7 +608,7 @@ {/if}
- {#if t.type !== "youtube"} + {#if t.type === "torrent" || !t.type}
- {#if t.type !== "youtube"} + {#if t.type === "torrent" || !t.type}
Hash: {t.infoHash} | Tracker: {t.tracker ?? "Unknown"} | Added: {t.added ? formatDate(t.added) : "Unknown"} @@ -639,7 +667,7 @@ {(t.progress * 100).toFixed(1)}% • {t.downloaded ? (t.downloaded / 1e6).toFixed(1) : 0} MB • {formatSpeed(t.downloadSpeed)} ↓ - {#if t.type !== "youtube"} + {#if t.type === "torrent" || !t.type} • {t.numPeers ?? 0} peers {/if} {:else} diff --git a/server/server.js b/server/server.js index a37bb75..5b96956 100644 --- a/server/server.js +++ b/server/server.js @@ -22,6 +22,7 @@ const upload = multer({ dest: path.join(__dirname, "uploads") }); const client = new WebTorrent(); const torrents = new Map(); const youtubeJobs = new Map(); +const mailruJobs = new Map(); let wss; const PORT = process.env.PORT || 3001; const DEBUG_CPU = process.env.DEBUG_CPU === "1"; @@ -92,6 +93,8 @@ const YT_ALLOWED_RESOLUTIONS = new Set([ const YT_EXTRACTOR_ARGS = process.env.YT_DLP_EXTRACTOR_ARGS || null; let resolvedYtDlpBinary = null; +const ARIA2C_BIN = process.env.ARIA2C_BIN || null; +let resolvedAria2cBinary = null; const TMDB_API_KEY = process.env.TMDB_API_KEY; const TMDB_BASE_URL = "https://api.themoviedb.org/3"; const TMDB_IMG_BASE = @@ -712,6 +715,30 @@ function getYtDlpBinary() { return resolvedYtDlpBinary; } +function getAria2cBinary() { + if (resolvedAria2cBinary) return resolvedAria2cBinary; + const candidates = [ + ARIA2C_BIN, + "/usr/bin/aria2c", + "/usr/local/bin/aria2c", + "aria2c" + ].filter(Boolean); + + for (const candidate of candidates) { + if (candidate.includes(path.sep) || candidate.startsWith("/")) { + if (fs.existsSync(candidate)) { + resolvedAria2cBinary = candidate; + return resolvedAria2cBinary; + } + continue; + } + resolvedAria2cBinary = candidate; + return resolvedAria2cBinary; + } + resolvedAria2cBinary = "aria2c"; + return resolvedAria2cBinary; +} + function normalizeYoutubeWatchUrl(value) { if (!value || typeof value !== "string") return null; try { @@ -1141,6 +1168,415 @@ function findYoutubeMediaFile(savePath, preferAudio = false) { return videos[0]; } +function normalizeMailRuUrl(value) { + if (!value || typeof value !== "string") return null; + try { + const urlObj = new URL(value.trim()); + if (urlObj.protocol !== "https:") return null; + const host = urlObj.hostname.toLowerCase(); + if (!host.endsWith("mail.ru")) return null; + return urlObj.toString(); + } catch (err) { + return null; + } +} + +function sanitizeFileName(name) { + const cleaned = String(name || "").trim(); + if (!cleaned) return "mailru_video.mp4"; + const replaced = cleaned.replace(/[\\/:*?"<>|]+/g, "_"); + return replaced || "mailru_video.mp4"; +} + +async function resolveMailRuDirectUrl(rawUrl) { + const normalized = normalizeMailRuUrl(rawUrl); + if (!normalized) return null; + try { + const urlObj = new URL(normalized); + const lowerPath = urlObj.pathname.toLowerCase(); + if (lowerPath.endsWith(".mp4")) return normalized; + } catch (err) { + return null; + } + + const binary = getYtDlpBinary(); + return await new Promise((resolve, reject) => { + const args = ["-g", "--no-playlist", normalized]; + const child = spawn(binary, args, { env: process.env }); + let stdout = ""; + let stderr = ""; + + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", (err) => { + reject(err?.message || "yt-dlp çalıştırılamadı"); + }); + child.on("close", (code) => { + if (code !== 0) { + return reject(stderr || `yt-dlp ${code} kodu ile sonlandı`); + } + const lines = stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const direct = lines.find((line) => line.startsWith("http")); + if (!direct) { + return reject("Mail.ru video URL'si çözümlenemedi"); + } + resolve(direct); + }); + }); +} + +function mailruSnapshot(job) { + const files = (job.files || []).map((file, index) => ({ + index, + name: file.name, + length: file.length + })); + return { + infoHash: job.id, + type: "mailru", + name: job.title || job.url, + progress: Math.min(1, job.progress || 0), + downloaded: job.downloaded || 0, + downloadSpeed: job.state === "downloading" ? job.downloadSpeed || 0 : 0, + uploadSpeed: 0, + numPeers: 0, + tracker: "mail.ru", + added: job.added, + savePath: job.savePath, + paused: false, + files, + selectedIndex: job.selectedIndex || 0, + thumbnail: job.thumbnail || null, + status: job.state + }; +} + +function appendMailRuLog(job, line) { + if (!job?.debug) return; + const lines = Array.isArray(job.debug.logs) ? job.debug.logs : []; + const split = String(line || "").split(/\r?\n/); + for (const l of split) { + if (!l.trim()) continue; + lines.push(l.trim()); + } + while (lines.length > 80) { + lines.shift(); + } + job.debug.logs = lines; +} + +function parseAria2cProgress(job, line) { + if (!line) return; + const cleaned = line.replace(/\u001b\[[0-9;]*m/g, "").trim(); + const primaryMatch = cleaned.match( + /\[#\d+\s+([\d.]+)([KMGTP]?i?B)\/([\d.]+)([KMGTP]?i?B)\((\d+)%\)\s+CN:\d+\s+DL:([\d.]+)([KMGTP]?i?B)/i + ); + const sizeMatch = cleaned.match( + /SIZE:([\d.]+)([KMGTP]?i?B)\/([\d.]+)([KMGTP]?i?B)\((\d+)%\).*DL:([\d.]+)([KMGTP]?i?B)/i + ); + const match = primaryMatch || sizeMatch; + if (!match) return; + + const downloadedValue = Number(match[1]) || 0; + const downloadedUnit = match[2]; + const totalValue = Number(match[3]) || 0; + const totalUnit = match[4]; + const percent = Number(match[5]) || 0; + const speedValue = Number(match[6]) || 0; + const speedUnit = match[7]; + + const totalBytes = bytesFromHuman(totalValue, totalUnit); + const downloadedBytes = Math.min( + totalBytes || Number.MAX_SAFE_INTEGER, + bytesFromHuman(downloadedValue, downloadedUnit) + ); + + job.totalBytes = totalBytes || job.totalBytes || 0; + job.downloaded = downloadedBytes || job.downloaded || 0; + job.progress = totalBytes > 0 ? downloadedBytes / totalBytes : percent / 100; + if (speedUnit) { + job.downloadSpeed = bytesFromHuman(speedValue, speedUnit); + } + scheduleSnapshotBroadcast(); +} + +function startMailRuProgressPolling(job) { + if (!job || job.pollTimer) return; + job.lastPollBytes = 0; + job.lastPollAt = Date.now(); + job.pollTimer = setInterval(() => { + if (!job || job.state !== "downloading") { + clearInterval(job.pollTimer); + job.pollTimer = null; + return; + } + const filePath = path.join(job.savePath, job.fileName || ""); + if (!job.fileName || !fs.existsSync(filePath)) return; + try { + const stats = fs.statSync(filePath); + const now = Date.now(); + const elapsed = Math.max(1, now - (job.lastPollAt || now)); + const delta = Math.max(0, stats.size - (job.lastPollBytes || 0)); + job.lastPollBytes = stats.size; + job.lastPollAt = now; + job.downloaded = stats.size; + if (job.totalBytes > 0) { + job.progress = Math.min(1, job.downloaded / job.totalBytes); + } + job.downloadSpeed = delta / (elapsed / 1000); + scheduleSnapshotBroadcast(); + } catch (err) { + /* no-op */ + } + }, 1000); +} + +function stopMailRuProgressPolling(job) { + if (job?.pollTimer) { + clearInterval(job.pollTimer); + job.pollTimer = null; + } +} + +function attachMailRuThumbnail(job, filePath, relPath) { + const { relThumb, absThumb } = getVideoThumbnailPaths(relPath); + if (fs.existsSync(absThumb)) { + job.thumbnail = thumbnailUrl(relThumb); + scheduleSnapshotBroadcast(); + return; + } + queueVideoThumbnail(filePath, relPath); + let attempts = 0; + const maxAttempts = 12; + const timer = setInterval(() => { + attempts += 1; + if (fs.existsSync(absThumb)) { + job.thumbnail = thumbnailUrl(relThumb); + scheduleSnapshotBroadcast(); + clearInterval(timer); + return; + } + if (attempts >= maxAttempts) { + clearInterval(timer); + } + }, 1000); +} + +function finalizeMailRuJob(job, exitCode) { + job.downloadSpeed = 0; + stopMailRuProgressPolling(job); + if (exitCode !== 0) { + job.state = "error"; + const tail = job.debug?.logs ? job.debug.logs.slice(-8) : []; + job.error = `aria2c ${exitCode} kodu ile sonlandı`; + if (tail.length) { + job.error += ` | ${tail.join(" | ")}`; + } + console.warn("❌ Mail.ru indirmesi hata:", { + jobId: job.id, + exitCode, + lastLines: tail + }); + scheduleSnapshotBroadcast(); + return; + } + + try { + const filePath = path.join(job.savePath, job.fileName); + if (!fs.existsSync(filePath)) { + job.state = "error"; + job.error = "Mail.ru dosyası bulunamadı"; + scheduleSnapshotBroadcast(); + return; + } + const stats = fs.statSync(filePath); + job.files = [ + { + index: 0, + name: job.fileName, + length: stats.size + } + ]; + job.selectedIndex = 0; + job.title = job.title || job.fileName; + job.downloaded = stats.size; + job.totalBytes = stats.size; + job.progress = 1; + job.state = "completed"; + job.error = null; + const relPath = path + .join(job.folderId, job.fileName) + .replace(/\\/g, "/"); + attachMailRuThumbnail(job, filePath, relPath); + broadcastFileUpdate(job.folderId); + scheduleSnapshotBroadcast(); + broadcastDiskSpace(); + console.log(`✅ Mail.ru indirmesi tamamlandı: ${job.title}`); + } catch (err) { + job.state = "error"; + job.error = err?.message || "Mail.ru indirimi tamamlanamadı"; + scheduleSnapshotBroadcast(); + } +} + +function launchMailRuJob(job) { + const binary = getAria2cBinary(); + const args = [ + "--enable-color=false", + "--summary-interval=1", + "--show-console-readout=true", + "--console-log-level=notice", + "--file-allocation=none", + "--allow-overwrite=true", + "-x", + "16", + "-s", + "16", + "-k", + "1M", + "-d", + job.savePath, + "-o", + job.fileName, + job.directUrl + ]; + + job.debug = { + binary, + args, + logs: [] + }; + + const child = spawn(binary, args, { + cwd: job.savePath, + env: process.env + }); + job.process = child; + + const handleChunk = (chunk) => { + const text = chunk.toString(); + appendMailRuLog(job, text); + const parts = text.split(/[\r\n]+/); + for (const raw of parts) { + const line = raw.trim(); + if (!line) continue; + parseAria2cProgress(job, line); + } + }; + + child.stdout.on("data", handleChunk); + child.stderr.on("data", handleChunk); + + child.on("close", (code) => finalizeMailRuJob(job, code)); + child.on("error", (err) => { + job.state = "error"; + job.downloadSpeed = 0; + appendMailRuLog(job, `spawn error: ${err?.message || err}`); + job.error = err?.message || "aria2c çalıştırılamadı"; + console.error("❌ Mail.ru aria2c spawn error:", { + jobId: job.id, + message: err?.message || err, + binary, + args + }); + scheduleSnapshotBroadcast(); + }); +} + +async function startMailRuDownload(url) { + const normalized = normalizeMailRuUrl(url); + if (!normalized) return null; + const folderId = `mailru_${Date.now().toString(36)}`; + const savePath = path.join(DOWNLOAD_DIR, folderId); + fs.mkdirSync(savePath, { recursive: true }); + + const job = { + id: folderId, + infoHash: folderId, + type: "mailru", + url: normalized, + directUrl: null, + folderId, + savePath, + added: Date.now(), + title: null, + fileName: null, + state: "resolving", + progress: 0, + downloaded: 0, + totalBytes: 0, + downloadSpeed: 0, + files: [], + selectedIndex: 0, + thumbnail: null, + process: null, + error: null, + debug: { binary: null, args: null, logs: [] } + }; + + mailruJobs.set(job.id, job); + scheduleSnapshotBroadcast(); + console.log(`▶️ Mail.ru indirmesi başlatıldı: ${job.url}`); + + try { + const directUrl = await resolveMailRuDirectUrl(normalized); + if (!directUrl) { + throw new Error("Mail.ru video URL'si çözümlenemedi"); + } + job.directUrl = directUrl; + const urlObj = new URL(directUrl); + const filename = sanitizeFileName(path.basename(urlObj.pathname)); + job.fileName = filename || `mailru_${Date.now()}.mp4`; + job.title = job.fileName; + job.state = "downloading"; + try { + const headResp = await fetch(directUrl, { method: "HEAD" }); + const length = Number(headResp.headers.get("content-length")) || 0; + if (length > 0) { + job.totalBytes = length; + } + } catch (err) { + /* no-op */ + } + if (!job.totalBytes) { + try { + const rangeResp = await fetch(directUrl, { + method: "GET", + headers: { Range: "bytes=0-0" } + }); + const contentRange = rangeResp.headers.get("content-range") || ""; + const total = contentRange.split("/")[1]; + const totalBytes = Number(total); + if (totalBytes > 0) { + job.totalBytes = totalBytes; + } + try { + await rangeResp.arrayBuffer(); + } catch { + /* no-op */ + } + } catch (err) { + /* no-op */ + } + } + startMailRuProgressPolling(job); + launchMailRuJob(job); + } catch (err) { + job.state = "error"; + job.error = err?.message || "Mail.ru indirimi başlatılamadı"; + scheduleSnapshotBroadcast(); + } + + return job; +} + function findYoutubeInfoJson(savePath) { const entries = fs.readdirSync(savePath, { withFileTypes: true }); const jsons = entries @@ -1289,6 +1725,34 @@ function removeYoutubeJob(jobId, { removeFiles = true } = {}) { return true; } +function removeMailRuJob(jobId, { removeFiles = true } = {}) { + const job = mailruJobs.get(jobId); + if (!job) return false; + if (job.process && !job.process.killed) { + try { + job.process.kill("SIGTERM"); + } catch (err) { + console.warn("Mail.ru job kill error:", err.message); + } + } + mailruJobs.delete(jobId); + let filesRemoved = false; + if (removeFiles && job.savePath && fs.existsSync(job.savePath)) { + try { + fs.rmSync(job.savePath, { recursive: true, force: true }); + filesRemoved = true; + } catch (err) { + console.warn("Mail.ru dosyası silinemedi:", err.message); + } + } + scheduleSnapshotBroadcast(); + if (filesRemoved) { + broadcastFileUpdate(job.folderId); + broadcastDiskSpace(); + } + return true; +} + function youtubeSnapshot(job) { const files = (job.files || []).map((file, index) => ({ index, @@ -4489,7 +4953,11 @@ function snapshot() { youtubeSnapshot(job) ); - const combined = [...torrentEntries, ...youtubeEntries]; + const mailruEntries = Array.from(mailruJobs.values()).map((job) => + mailruSnapshot(job) + ); + + const combined = [...torrentEntries, ...youtubeEntries, ...mailruEntries]; combined.sort((a, b) => (b.added || 0) - (a.added || 0)); return combined; } @@ -4913,7 +5381,20 @@ app.post("/api/torrents/:hash/select/:index", requireAuth, (req, res) => { const entry = torrents.get(req.params.hash); if (!entry) { const job = youtubeJobs.get(req.params.hash); - if (!job) return res.status(404).json({ error: "torrent bulunamadı" }); + if (!job) { + const mailJob = mailruJobs.get(req.params.hash); + if (!mailJob) { + return res.status(404).json({ error: "torrent bulunamadı" }); + } + const targetIndex = Number(req.params.index) || 0; + if (!mailJob.files?.[targetIndex]) { + return res + .status(400) + .json({ error: "Geçerli bir video dosyası bulunamadı" }); + } + mailJob.selectedIndex = targetIndex; + return res.json({ ok: true, selectedIndex: targetIndex }); + } const targetIndex = Number(req.params.index) || 0; if (!job.files?.[targetIndex]) { return res @@ -4935,6 +5416,10 @@ app.delete("/api/torrents/:hash", requireAuth, (req, res) => { if (ytRemoved) { return res.json({ ok: true, filesRemoved: true }); } + const mailRemoved = removeMailRuJob(req.params.hash, { removeFiles: true }); + if (mailRemoved) { + return res.json({ ok: true, filesRemoved: true }); + } return res.status(404).json({ error: "torrent bulunamadı" }); } @@ -6234,6 +6719,24 @@ app.post("/api/youtube/download", requireAuth, async (req, res) => { } }); +app.post("/api/mailru/download", requireAuth, async (req, res) => { + try { + const rawUrl = req.body?.url; + const job = await startMailRuDownload(rawUrl); + if (!job) { + return res + .status(400) + .json({ ok: false, error: "Geçerli bir Mail.ru URL'si gerekli." }); + } + res.json({ ok: true, jobId: job.id }); + } catch (err) { + res.status(500).json({ + ok: false, + error: err?.message || "Mail.ru indirimi başarısız oldu." + }); + } +}); + // --- 🎫 YouTube cookies yönetimi --- app.get("/api/youtube/cookies", requireAuth, (req, res) => { try {