feat(transfers): mail.ru indirme desteği ekle

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.
This commit is contained in:
2026-01-26 20:04:41 +03:00
parent 45e6ef3356
commit 0b99fce5a9
3 changed files with 542 additions and 11 deletions

View File

@@ -8,7 +8,7 @@ RUN npm run build
# Build server # Build server
FROM node:22-slim 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 \ 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 && chmod a+rx /usr/local/bin/yt-dlp
WORKDIR /app/server WORKDIR /app/server

View File

@@ -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() { 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 (!input) return;
if (isMagnetLink(input)) { if (isMagnetLink(input)) {
await apiFetch("/api/transfer", { await apiFetch("/api/transfer", {
@@ -125,8 +138,23 @@
await list(); await list();
return; 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( 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" class="thumb"
on:load={(e) => e.target.classList.add("loaded")} 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)}
<div class="thumb placeholder loading"> <div class="thumb placeholder loading">
<div class="spinner"></div> <div class="spinner"></div>
</div> </div>
@@ -570,9 +598,9 @@
<div class="torrent-header"> <div class="torrent-header">
<div class="torrent-title"> <div class="torrent-title">
<div class="torrent-name">{t.name}</div> <div class="torrent-name">{t.name}</div>
{#if t.type === "youtube"} {#if t.type === "youtube" || t.type === "mailru"}
<div class="torrent-subtitle"> <div class="torrent-subtitle">
Source: YouTube Source: {t.type === "mailru" ? "Mail.ru" : "YouTube"}
</div> </div>
<div class="torrent-subtitle"> <div class="torrent-subtitle">
Added: {formatDate(t.added)} Added: {formatDate(t.added)}
@@ -580,7 +608,7 @@
{/if} {/if}
</div> </div>
<div style="display:flex; gap:5px;"> <div style="display:flex; gap:5px;">
{#if t.type !== "youtube"} {#if t.type === "torrent" || !t.type}
<button <button
class="toggle-btn" class="toggle-btn"
on:click|stopPropagation={() => toggleSingleTorrent(t.infoHash)} on:click|stopPropagation={() => toggleSingleTorrent(t.infoHash)}
@@ -601,7 +629,7 @@
</div> </div>
</div> </div>
{#if t.type !== "youtube"} {#if t.type === "torrent" || !t.type}
<div class="torrent-hash"> <div class="torrent-hash">
Hash: {t.infoHash} | Tracker: {t.tracker ?? "Unknown"} | Added: Hash: {t.infoHash} | Tracker: {t.tracker ?? "Unknown"} | Added:
{t.added ? formatDate(t.added) : "Unknown"} {t.added ? formatDate(t.added) : "Unknown"}
@@ -639,7 +667,7 @@
{(t.progress * 100).toFixed(1)}% • {(t.progress * 100).toFixed(1)}% •
{t.downloaded ? (t.downloaded / 1e6).toFixed(1) : 0} MB • {t.downloaded ? (t.downloaded / 1e6).toFixed(1) : 0} MB •
{formatSpeed(t.downloadSpeed)} {formatSpeed(t.downloadSpeed)}
{#if t.type !== "youtube"} {#if t.type === "torrent" || !t.type}
{t.numPeers ?? 0} peers {t.numPeers ?? 0} peers
{/if} {/if}
{:else} {:else}

View File

@@ -22,6 +22,7 @@ const upload = multer({ dest: path.join(__dirname, "uploads") });
const client = new WebTorrent(); const client = new WebTorrent();
const torrents = new Map(); const torrents = new Map();
const youtubeJobs = new Map(); const youtubeJobs = new Map();
const mailruJobs = new Map();
let wss; let wss;
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
const DEBUG_CPU = process.env.DEBUG_CPU === "1"; const DEBUG_CPU = process.env.DEBUG_CPU === "1";
@@ -92,6 +93,8 @@ const YT_ALLOWED_RESOLUTIONS = new Set([
const YT_EXTRACTOR_ARGS = const YT_EXTRACTOR_ARGS =
process.env.YT_DLP_EXTRACTOR_ARGS || null; process.env.YT_DLP_EXTRACTOR_ARGS || null;
let resolvedYtDlpBinary = 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_API_KEY = process.env.TMDB_API_KEY;
const TMDB_BASE_URL = "https://api.themoviedb.org/3"; const TMDB_BASE_URL = "https://api.themoviedb.org/3";
const TMDB_IMG_BASE = const TMDB_IMG_BASE =
@@ -712,6 +715,30 @@ function getYtDlpBinary() {
return resolvedYtDlpBinary; 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) { function normalizeYoutubeWatchUrl(value) {
if (!value || typeof value !== "string") return null; if (!value || typeof value !== "string") return null;
try { try {
@@ -1141,6 +1168,415 @@ function findYoutubeMediaFile(savePath, preferAudio = false) {
return videos[0]; 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) { function findYoutubeInfoJson(savePath) {
const entries = fs.readdirSync(savePath, { withFileTypes: true }); const entries = fs.readdirSync(savePath, { withFileTypes: true });
const jsons = entries const jsons = entries
@@ -1289,6 +1725,34 @@ function removeYoutubeJob(jobId, { removeFiles = true } = {}) {
return 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) { function youtubeSnapshot(job) {
const files = (job.files || []).map((file, index) => ({ const files = (job.files || []).map((file, index) => ({
index, index,
@@ -4489,7 +4953,11 @@ function snapshot() {
youtubeSnapshot(job) 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)); combined.sort((a, b) => (b.added || 0) - (a.added || 0));
return combined; return combined;
} }
@@ -4913,7 +5381,20 @@ app.post("/api/torrents/:hash/select/:index", requireAuth, (req, res) => {
const entry = torrents.get(req.params.hash); const entry = torrents.get(req.params.hash);
if (!entry) { if (!entry) {
const job = youtubeJobs.get(req.params.hash); 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; const targetIndex = Number(req.params.index) || 0;
if (!job.files?.[targetIndex]) { if (!job.files?.[targetIndex]) {
return res return res
@@ -4935,6 +5416,10 @@ app.delete("/api/torrents/:hash", requireAuth, (req, res) => {
if (ytRemoved) { if (ytRemoved) {
return res.json({ ok: true, filesRemoved: true }); 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ı" }); 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 --- // --- 🎫 YouTube cookies yönetimi ---
app.get("/api/youtube/cookies", requireAuth, (req, res) => { app.get("/api/youtube/cookies", requireAuth, (req, res) => {
try { try {