feat(rabbit): PH video indirme ve yönetim özelliği ekle
PH video indirme, yönetim ve oynatma özelliği eklendi. Yeni Rabbit sayfası ile indirilen videolar listelenebilir ve oynatılabilir. Kenar menüye Rabbit sekmesi eklendi, dinamik olarak göster/gizle. Transferler sayfasına PH URL desteği eklendi. WebSocket üzerinden Rabbit sayısı güncellemeleri sağlandı. Dosya görünümü Rabbit içeriklerini filtreleyecek şekilde güncellendi. Arka planda Rabbit metadata yönetimi ve dosya sistemi entegrasyonu.
This commit is contained in:
351
server/server.js
351
server/server.js
@@ -43,6 +43,7 @@ const IMAGE_THUMB_ROOT = path.join(THUMBNAIL_DIR, "images");
|
||||
const MOVIE_DATA_ROOT = path.join(CACHE_DIR, "movie_data");
|
||||
const TV_DATA_ROOT = path.join(CACHE_DIR, "tv_data");
|
||||
const YT_DATA_ROOT = path.join(CACHE_DIR, "yt_data");
|
||||
const RABBIT_DATA_ROOT = path.join(CACHE_DIR, "rabbit_data");
|
||||
const MUSIC_EXTENSIONS = new Set([
|
||||
".mp3",
|
||||
".m4a",
|
||||
@@ -61,7 +62,8 @@ for (const dir of [
|
||||
IMAGE_THUMB_ROOT,
|
||||
MOVIE_DATA_ROOT,
|
||||
TV_DATA_ROOT,
|
||||
YT_DATA_ROOT
|
||||
YT_DATA_ROOT,
|
||||
RABBIT_DATA_ROOT
|
||||
]) {
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
@@ -695,6 +697,24 @@ function normalizeYoutubeWatchUrl(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePornhubUrl(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 !== "www.pornhub.com" && host !== "pornhub.com") return null;
|
||||
if (urlObj.pathname !== "/view_video.php") return null;
|
||||
const viewkey = urlObj.searchParams.get("viewkey");
|
||||
if (!viewkey) return null;
|
||||
return `https://www.pornhub.com/view_video.php?viewkey=${encodeURIComponent(
|
||||
viewkey
|
||||
)}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function startYoutubeDownload(url) {
|
||||
const normalized = normalizeYoutubeWatchUrl(url);
|
||||
if (!normalized) return null;
|
||||
@@ -739,6 +759,47 @@ function startYoutubeDownload(url) {
|
||||
return job;
|
||||
}
|
||||
|
||||
function startPornhubDownload(url) {
|
||||
const normalized = normalizePornhubUrl(url);
|
||||
if (!normalized) return null;
|
||||
const viewkey = new URL(normalized).searchParams.get("viewkey");
|
||||
const folderId = `ph_${viewkey}_${Date.now().toString(36)}`;
|
||||
const savePath = path.join(DOWNLOAD_DIR, folderId);
|
||||
fs.mkdirSync(savePath, { recursive: true });
|
||||
|
||||
const job = {
|
||||
id: folderId,
|
||||
infoHash: folderId,
|
||||
type: "pornhub",
|
||||
url: normalized,
|
||||
videoId: viewkey,
|
||||
folderId,
|
||||
savePath,
|
||||
added: Date.now(),
|
||||
title: null,
|
||||
state: "downloading",
|
||||
progress: 0,
|
||||
downloaded: 0,
|
||||
totalBytes: 0,
|
||||
downloadSpeed: 0,
|
||||
stages: [],
|
||||
currentStage: null,
|
||||
completedBytes: 0,
|
||||
files: [],
|
||||
selectedIndex: 0,
|
||||
thumbnail: null,
|
||||
process: null,
|
||||
error: null,
|
||||
debug: { binary: null, args: null, logs: [] }
|
||||
};
|
||||
|
||||
youtubeJobs.set(job.id, job);
|
||||
launchPornhubJob(job);
|
||||
console.log(`▶️ Pornhub indirmesi başlatıldı: ${job.url}`);
|
||||
broadcastSnapshot();
|
||||
return job;
|
||||
}
|
||||
|
||||
function appendYoutubeLog(job, line) {
|
||||
if (!job?.debug) return;
|
||||
const lines = Array.isArray(job.debug.logs) ? job.debug.logs : [];
|
||||
@@ -870,13 +931,88 @@ function launchYoutubeJob(job) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
processYoutubeOutput(job, line);
|
||||
if (job.type === "pornhub") {
|
||||
console.log(`[yt-dlp ph:${job.id}] ${line}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout.on("data", handleChunk);
|
||||
child.stderr.on("data", handleChunk);
|
||||
|
||||
child.on("close", (code) => finalizeYoutubeJob(job, code));
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
console.warn(`❌ yt-dlp exit code ${code} (${job.type || "youtube"}): ${job.url}`);
|
||||
}
|
||||
finalizeYoutubeJob(job, code);
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
job.state = "error";
|
||||
job.downloadSpeed = 0;
|
||||
appendYoutubeLog(job, `spawn error: ${err?.message || err}`);
|
||||
job.error = err?.message || "yt-dlp çalıştırılamadı";
|
||||
console.error("❌ yt-dlp spawn error:", {
|
||||
jobId: job.id,
|
||||
message: err?.message || err,
|
||||
binary,
|
||||
args
|
||||
});
|
||||
broadcastSnapshot();
|
||||
});
|
||||
}
|
||||
|
||||
function launchPornhubJob(job) {
|
||||
const binary = getYtDlpBinary();
|
||||
const args = [
|
||||
"-f",
|
||||
"bestvideo+bestaudio/best",
|
||||
"--write-thumbnail",
|
||||
"--convert-thumbnails",
|
||||
"jpg",
|
||||
"--write-info-json",
|
||||
"--concurrent-fragments",
|
||||
"10",
|
||||
job.url
|
||||
];
|
||||
|
||||
job.debug = {
|
||||
binary,
|
||||
args,
|
||||
logs: [],
|
||||
jsRuntime: null,
|
||||
cookies: null,
|
||||
extractorArgs: null,
|
||||
resolution: null,
|
||||
onlyAudio: false,
|
||||
format: "bestvideo+bestaudio/best"
|
||||
};
|
||||
|
||||
const child = spawn(binary, args, {
|
||||
cwd: job.savePath,
|
||||
env: process.env
|
||||
});
|
||||
job.process = child;
|
||||
|
||||
const handleChunk = (chunk) => {
|
||||
const text = chunk.toString();
|
||||
appendYoutubeLog(job, text);
|
||||
for (const raw of text.split(/\r?\n/)) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
processYoutubeOutput(job, line);
|
||||
if (job.type === "pornhub") {
|
||||
console.log(`[yt-dlp ph:${job.id}] ${line}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout.on("data", handleChunk);
|
||||
child.stderr.on("data", handleChunk);
|
||||
|
||||
child.on("close", (code) => {
|
||||
console.log(`ℹ️ yt-dlp kapandı (${job.type || "youtube"}): exit ${code}`);
|
||||
finalizeYoutubeJob(job, code);
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
job.state = "error";
|
||||
job.downloadSpeed = 0;
|
||||
@@ -900,7 +1036,7 @@ function processYoutubeOutput(job, line) {
|
||||
}
|
||||
|
||||
const progressMatch = line.match(
|
||||
/^\[download\]\s+([\d.]+)%\s+of\s+([\d.]+)\s*([KMGTP]?i?B)(?:\s+at\s+([\d.]+)\s*([KMGTP]?i?B)\/s)?/i
|
||||
/^\[download\]\s+([\d.]+)%\s+of\s+~?\s*([\d.]+)\s*([KMGTP]?i?B)(?:\s+at\s+([\d.]+)\s*([KMGTP]?i?B)\/s)?/i
|
||||
);
|
||||
if (progressMatch) {
|
||||
updateYoutubeProgress(job, progressMatch);
|
||||
@@ -964,28 +1100,29 @@ function updateYoutubeProgress(job, match) {
|
||||
|
||||
async function finalizeYoutubeJob(job, exitCode) {
|
||||
job.downloadSpeed = 0;
|
||||
const tailLines = job.debug?.logs ? job.debug.logs.slice(-8) : [];
|
||||
const fallbackMedia = findYoutubeMediaFile(job.savePath, Boolean(job.onlyAudio));
|
||||
if (exitCode !== 0 && !fallbackMedia) {
|
||||
job.state = "error";
|
||||
const tail = job.debug?.logs ? job.debug.logs.slice(-8) : [];
|
||||
job.error = `yt-dlp ${exitCode} kodu ile sonlandı`;
|
||||
if (tail.length) {
|
||||
job.error += ` | ${tail.join(" | ")}`;
|
||||
if (tailLines.length) {
|
||||
job.error += ` | ${tailLines.join(" | ")}`;
|
||||
}
|
||||
console.warn("❌ yt-dlp çıkış kodu hata:", {
|
||||
jobId: job.id,
|
||||
exitCode,
|
||||
binary: job.debug?.binary,
|
||||
args: job.debug?.args,
|
||||
lastLines: tail
|
||||
lastLines: tailLines
|
||||
});
|
||||
broadcastSnapshot();
|
||||
return;
|
||||
}
|
||||
if (exitCode !== 0 && fallbackMedia) {
|
||||
console.warn(
|
||||
`⚠️ yt-dlp çıkış kodu ${exitCode} ancak medya bulundu, devam ediliyor: ${fallbackMedia}`
|
||||
);
|
||||
console.warn(`⚠️ yt-dlp çıkış kodu ${exitCode}, medya bulundu: ${fallbackMedia}`, {
|
||||
jobId: job.id,
|
||||
lastLines: tailLines
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -1012,7 +1149,36 @@ async function finalizeYoutubeJob(job, exitCode) {
|
||||
}
|
||||
|
||||
const absMedia = path.join(job.savePath, mediaFile);
|
||||
const stats = fs.statSync(absMedia);
|
||||
let mediaPath = absMedia;
|
||||
if (!fs.existsSync(mediaPath) && mediaFile.endsWith(".temp.mp4")) {
|
||||
const alt = mediaFile.replace(".temp.mp4", ".mp4");
|
||||
const altPath = path.join(job.savePath, alt);
|
||||
if (fs.existsSync(altPath)) {
|
||||
mediaPath = altPath;
|
||||
}
|
||||
}
|
||||
if (!fs.existsSync(mediaPath)) {
|
||||
const retry = findYoutubeMediaFile(job.savePath, Boolean(job.onlyAudio));
|
||||
if (retry && retry !== mediaFile) {
|
||||
const retryPath = path.join(job.savePath, retry);
|
||||
if (fs.existsSync(retryPath)) {
|
||||
mediaPath = retryPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!fs.existsSync(mediaPath)) {
|
||||
job.state = "error";
|
||||
job.error = "Video dosyası bulunamadı";
|
||||
console.warn("❌ yt-dlp çıktı video bulunamadı (fs):", {
|
||||
jobId: job.id,
|
||||
savePath: job.savePath,
|
||||
fileTried: mediaFile
|
||||
});
|
||||
broadcastSnapshot();
|
||||
return;
|
||||
}
|
||||
|
||||
const stats = fs.statSync(mediaPath);
|
||||
const mediaInfo = await extractMediaInfo(absMedia).catch(() => null);
|
||||
const relativeName = mediaFile.replace(/\\/g, "/");
|
||||
job.files = [
|
||||
@@ -1032,7 +1198,7 @@ async function finalizeYoutubeJob(job, exitCode) {
|
||||
|
||||
const metadataPayload = await writeYoutubeMetadata(
|
||||
job,
|
||||
absMedia,
|
||||
mediaPath,
|
||||
mediaInfo,
|
||||
infoJson
|
||||
);
|
||||
@@ -1040,6 +1206,28 @@ async function finalizeYoutubeJob(job, exitCode) {
|
||||
updateYoutubeThumbnail(job, metadataPayload) || metadataPayload;
|
||||
const mediaType = payloadWithThumb?.type || "video";
|
||||
const categories = payloadWithThumb?.categories || null;
|
||||
if (job.type === "pornhub") {
|
||||
let parsedInfo = null;
|
||||
if (infoJson) {
|
||||
try {
|
||||
parsedInfo = JSON.parse(
|
||||
fs.readFileSync(path.join(job.savePath, infoJson), "utf-8")
|
||||
);
|
||||
} catch (err) {
|
||||
console.warn("⚠️ Rabbit info.json okunamadı:", err.message);
|
||||
}
|
||||
}
|
||||
writeRabbitMetadata(job, mediaPath, mediaInfo, parsedInfo);
|
||||
broadcastRabbitCount();
|
||||
}
|
||||
// Pornhub tamamlandı işareti
|
||||
if (job.type === "pornhub") {
|
||||
try {
|
||||
fs.writeFileSync(path.join(job.savePath, ".ph_complete"), `${Date.now()}`);
|
||||
} catch (err) {
|
||||
console.warn("⚠️ Pornhub tamam işareti yazılamadı:", err.message);
|
||||
}
|
||||
}
|
||||
upsertInfoFile(job.savePath, {
|
||||
infoHash: job.id,
|
||||
name: job.title,
|
||||
@@ -1220,6 +1408,65 @@ function updateYoutubeThumbnail(job, metadataPayload = null) {
|
||||
return metadataPayload || null;
|
||||
}
|
||||
|
||||
function writeRabbitMetadata(job, absMedia, mediaInfo, infoJson) {
|
||||
try {
|
||||
const targetDir = path.join(RABBIT_DATA_ROOT, job.folderId);
|
||||
fs.mkdirSync(targetDir, { recursive: true });
|
||||
|
||||
let thumbnailPath = null;
|
||||
const thumbs = fs
|
||||
.readdirSync(job.savePath, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".jpg"));
|
||||
if (thumbs.length) {
|
||||
const source = path.join(job.savePath, thumbs[0].name);
|
||||
const target = path.join(targetDir, "thumbnail.jpg");
|
||||
fs.copyFileSync(source, target);
|
||||
thumbnailPath = `/rabbit-data/${job.folderId}/thumbnail.jpg`;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
id: job.folderId,
|
||||
title: job.title,
|
||||
url: job.url,
|
||||
added: job.added,
|
||||
folderId: job.folderId,
|
||||
file: job.files?.[0]?.name || path.basename(absMedia),
|
||||
size: mediaInfo?.format?.size || null,
|
||||
mediaInfo,
|
||||
thumbnail: thumbnailPath,
|
||||
source: "pornhub",
|
||||
infoJson: infoJson || null
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(targetDir, "metadata.json"),
|
||||
JSON.stringify(payload, null, 2),
|
||||
"utf-8"
|
||||
);
|
||||
return payload;
|
||||
} catch (err) {
|
||||
console.warn("⚠️ Rabbit metadata yazılamadı:", err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function countRabbitItems() {
|
||||
try {
|
||||
const entries = fs
|
||||
.readdirSync(RABBIT_DATA_ROOT, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory());
|
||||
return entries.length;
|
||||
} catch (err) {
|
||||
console.warn("⚠️ Rabbit sayısı okunamadı:", err.message);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastRabbitCount() {
|
||||
if (!wss) return;
|
||||
const count = countRabbitItems();
|
||||
broadcastJson(wss, { type: "rabbitCount", count });
|
||||
}
|
||||
|
||||
function removeYoutubeJob(jobId, { removeFiles = true } = {}) {
|
||||
const job = youtubeJobs.get(jobId);
|
||||
if (!job) return false;
|
||||
@@ -1264,7 +1511,7 @@ function youtubeSnapshot(job) {
|
||||
}));
|
||||
return {
|
||||
infoHash: job.id,
|
||||
type: "youtube",
|
||||
type: job.type || "youtube",
|
||||
name: job.title || job.url,
|
||||
progress: Math.min(1, job.progress || 0),
|
||||
downloaded: job.downloaded || 0,
|
||||
@@ -1953,6 +2200,18 @@ function resolveYoutubeDataAbsolute(relPath) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function resolveRabbitDataAbsolute(relPath) {
|
||||
const normalized = sanitizeRelative(relPath);
|
||||
const resolved = path.resolve(RABBIT_DATA_ROOT, normalized);
|
||||
if (
|
||||
resolved !== RABBIT_DATA_ROOT &&
|
||||
!resolved.startsWith(RABBIT_DATA_ROOT + path.sep)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function removeAllThumbnailsForRoot(rootFolder) {
|
||||
const safe = sanitizeRelative(rootFolder);
|
||||
if (!safe) return;
|
||||
@@ -4675,6 +4934,13 @@ app.get("/yt-data/:path(*)", requireAuth, (req, res) => {
|
||||
return serveCachedFile(req, res, fullPath, { maxAgeSeconds: 60 * 60 * 24 });
|
||||
});
|
||||
|
||||
app.get("/rabbit-data/:path(*)", requireAuth, (req, res) => {
|
||||
const relPath = req.params.path || "";
|
||||
const fullPath = resolveRabbitDataAbsolute(relPath);
|
||||
if (!fullPath) return res.status(400).send("Geçersiz rabbit data yolu");
|
||||
return serveCachedFile(req, res, fullPath, { maxAgeSeconds: 60 * 60 * 24 });
|
||||
});
|
||||
|
||||
app.get("/tv-data/:path(*)", requireAuth, (req, res) => {
|
||||
const relPath = req.params.path || "";
|
||||
const fullPath = resolveTvDataAbsolute(relPath);
|
||||
@@ -6009,6 +6275,59 @@ app.post("/api/youtube/download", requireAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/api/pornhub/download", requireAuth, async (req, res) => {
|
||||
try {
|
||||
const rawUrl = req.body?.url;
|
||||
const job = startPornhubDownload(rawUrl);
|
||||
if (!job) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ ok: false, error: "Geçerli bir Pornhub URL'si gerekli." });
|
||||
}
|
||||
res.json({ ok: true, jobId: job.id });
|
||||
} catch (err) {
|
||||
res.status(500).json({
|
||||
ok: false,
|
||||
error: err?.message || "Pornhub indirimi başarısız oldu."
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/rabbit", requireAuth, (req, res) => {
|
||||
try {
|
||||
const entries = fs
|
||||
.readdirSync(RABBIT_DATA_ROOT, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory())
|
||||
.map((d) => d.name);
|
||||
const items = [];
|
||||
for (const folder of entries) {
|
||||
const metaPath = path.join(RABBIT_DATA_ROOT, folder, "metadata.json");
|
||||
if (!fs.existsSync(metaPath)) continue;
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(metaPath, "utf-8"));
|
||||
items.push({
|
||||
id: data.id || folder,
|
||||
title: data.title || folder,
|
||||
url: data.url || null,
|
||||
added: data.added || null,
|
||||
thumbnail: data.thumbnail || null,
|
||||
file: data.file || null,
|
||||
size: data.size || null,
|
||||
mediaInfo: data.mediaInfo || null,
|
||||
source: data.source || "pornhub"
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ Rabbit metadata okunamadı (${folder}): ${err.message}`);
|
||||
}
|
||||
}
|
||||
items.sort((a, b) => (b.added || 0) - (a.added || 0));
|
||||
res.json({ ok: true, items, count: items.length });
|
||||
} catch (err) {
|
||||
console.error("❌ Rabbit listesi alınamadı:", err.message);
|
||||
res.status(500).json({ ok: false, error: "Rabbit listesi alınamadı." });
|
||||
}
|
||||
});
|
||||
|
||||
// --- 🎫 YouTube cookies yönetimi ---
|
||||
app.get("/api/youtube/cookies", requireAuth, (req, res) => {
|
||||
try {
|
||||
@@ -6955,6 +7274,12 @@ wss.on("connection", (ws) => {
|
||||
ws.send(JSON.stringify({ type: "progress", torrents: snapshot() }));
|
||||
// Bağlantı kurulduğunda disk space bilgisi gönder
|
||||
broadcastDiskSpace();
|
||||
try {
|
||||
const count = countRabbitItems();
|
||||
ws.send(JSON.stringify({ type: "rabbitCount", count }));
|
||||
} catch (err) {
|
||||
console.warn("⚠️ Rabbit count gönderilemedi:", err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// --- ⏱️ Her 2 saniyede bir aktif torrent durumu yayınla ---
|
||||
|
||||
Reference in New Issue
Block a user