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:
507
server/server.js
507
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 {
|
||||
|
||||
Reference in New Issue
Block a user