Youtube download özelliği eklendi

This commit is contained in:
2025-11-30 19:46:29 +03:00
parent 0b802ba55c
commit decf503297
2 changed files with 690 additions and 61 deletions

View File

@@ -56,19 +56,70 @@
await list();
}
async function addMagnet() {
const m = prompt("URL Girin:");
if (!m) return;
await apiFetch("/api/transfer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ magnet: m })
}); // ✅
await list();
const YT_VIDEO_ID_RE = /^[A-Za-z0-9_-]{11}$/;
function isMagnetLink(value) {
if (!value || typeof value !== "string") return false;
const normalized = value.trim().toLowerCase();
return normalized.startsWith("magnet:?xt=");
}
function selectFile(hash, index) {
ws?.send(JSON.stringify({ type: "select", infoHash: hash, index }));
function normalizeYoutubeUrl(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 !== "youtube.com" && host !== "www.youtube.com") return null;
if (url.pathname !== "/watch") return null;
const videoId = url.searchParams.get("v");
if (!videoId || !YT_VIDEO_ID_RE.test(videoId)) return null;
return `https://www.youtube.com/watch?v=${videoId}`;
} catch {
return null;
}
}
async function handleUrlInput() {
const input = prompt("Magnet veya YouTube URL girin:");
if (!input) return;
if (isMagnetLink(input)) {
await apiFetch("/api/transfer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ magnet: input })
});
await list();
return;
}
const normalizedYoutube = normalizeYoutubeUrl(input);
if (normalizedYoutube) {
const resp = await apiFetch("/api/youtube/download", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: normalizedYoutube })
});
if (!resp.ok) {
const data = await resp.json().catch(() => null);
alert(data?.error || "YouTube 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."
);
}
async function selectFile(hash, index) {
try {
await apiFetch(`/api/torrents/${hash}/select/${index}`, {
method: "POST"
});
} catch (err) {
console.error("Select file error:", err);
}
}
async function removeTorrent(hash) {
@@ -116,13 +167,14 @@
}
function updateAllPausedState() {
if (torrents.length === 0) {
const torrentOnly = torrents.filter((t) => !t.type || t.type === "torrent");
if (torrentOnly.length === 0) {
isAllPaused = false;
return;
}
// Eğer tüm torrentler paused ise, global durumu paused yap
const allPaused = torrents.every(t => t.paused === true);
const allPaused = torrentOnly.every((t) => t.paused === true);
isAllPaused = allPaused;
}
@@ -166,18 +218,32 @@
return (bytesPerSec / 1e6).toFixed(2) + " MB/s";
}
function formatDate(value) {
if (!value) return "Unknown";
try {
return new Date(value).toLocaleString();
} catch {
return "Unknown";
}
}
function openModal(t) {
if (!t.files || !t.files.length) {
alert("Bu indirme için oynatılabilir video bulunamadı.");
return;
}
const selectedFile =
t.files?.find((f) => f.index === t.selectedIndex) || t.files?.[0];
t.files.find((f) => f.index === t.selectedIndex) || t.files[0];
if (!selectedFile) {
alert("Bu torrentte oynatılabilir video dosyası bulunamadı!");
alert("Bu indirmede oynatılabilir video dosyası bulunamadı!");
return;
}
selectedVideo = {
...t,
fileIndex: selectedFile.index,
fileName: selectedFile.name
fileName: selectedFile.name,
type: t.type || "torrent"
};
showModal = true;
}
@@ -403,7 +469,7 @@
style="display:none;"
/>
</label>
<label class="btn-primary" on:click={addMagnet}>
<label class="btn-primary" on:click={handleUrlInput}>
<i class="fa-solid fa-magnet btn-icon"></i> ADD URL
</label>
</div>
@@ -476,19 +542,28 @@
<div class="torrent-info">
<div class="torrent-header">
<div class="torrent-name">{t.name}</div>
<div class="torrent-title">
<div class="torrent-name">{t.name}</div>
{#if t.type === "youtube"}
<div class="torrent-subtitle">
Added: {formatDate(t.added)}
</div>
{/if}
</div>
<div style="display:flex; gap:5px;">
<button
class="toggle-btn"
on:click|stopPropagation={() => toggleSingleTorrent(t.infoHash)}
title={t.paused ? "Devam Ettir" : "Durdur"}
>
{#if t.paused}
<i class="fa-solid fa-play"></i>
{:else}
<i class="fa-solid fa-pause"></i>
{/if}
</button>
{#if t.type !== "youtube"}
<button
class="toggle-btn"
on:click|stopPropagation={() => toggleSingleTorrent(t.infoHash)}
title={t.paused ? "Devam Ettir" : "Durdur"}
>
{#if t.paused}
<i class="fa-solid fa-play"></i>
{:else}
<i class="fa-solid fa-pause"></i>
{/if}
</button>
{/if}
<button
class="remove-btn"
on:click|stopPropagation={() => removeTorrent(t.infoHash)}
@@ -498,26 +573,33 @@
</div>
<div class="torrent-hash">
Hash: {t.infoHash} | Tracker: {t.tracker ?? "Unknown"} | Added:
{t.added ? new Date(t.added).toLocaleString() : "Unknown"}
{#if t.type === "youtube"}
Source: YouTube | Added:
{t.added ? formatDate(t.added) : "Unknown"}
{:else}
Hash: {t.infoHash} | Tracker: {t.tracker ?? "Unknown"} | Added:
{t.added ? formatDate(t.added) : "Unknown"}
{/if}
</div>
<div class="torrent-files">
{#each t.files as f}
<div class="file-row">
<button
on:click|stopPropagation={() =>
selectFile(t.infoHash, f.index)}
>
{f.index === t.selectedIndex ? "Selected" : "Select"}
</button>
<div class="filename">{f.name}</div>
<div class="filesize">
{(f.length / 1e6).toFixed(1)} MB
{#if t.files && t.files.length}
<div class="torrent-files">
{#each t.files as f}
<div class="file-row">
<button
on:click|stopPropagation={() =>
selectFile(t.infoHash, f.index)}
>
{f.index === t.selectedIndex ? "Selected" : "Select"}
</button>
<div class="filename">{f.name}</div>
<div class="filesize">
{(f.length / 1e6).toFixed(1)} MB
</div>
</div>
</div>
{/each}
</div>
{/each}
</div>
{/if}
<div class="progress-bar">
<div
@@ -530,12 +612,17 @@
{#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
{formatSpeed(t.downloadSpeed)}
{#if t.type !== "youtube"}
{t.numPeers ?? 0} peers
{/if}
{:else}
100.0% • {(t.downloaded / 1e6).toFixed(1)} MB
{/if}
</div>
{#if t.status === "error"}
<div class="torrent-error">Download failed</div>
{/if}
</div>
</div>
{/each}
@@ -656,6 +743,7 @@
</div>
{/if}
<style>
/* --- Torrent Listeleme --- */
.torrent-list {
@@ -711,10 +799,22 @@
font-weight: 700;
}
.torrent-title {
display: flex;
flex-direction: column;
gap: 2px;
}
.torrent-name {
word-break: break-word;
}
.torrent-subtitle {
font-size: 12px;
font-weight: 400;
color: #666;
}
.toggle-btn {
background: transparent;
border: none;
@@ -799,6 +899,13 @@
transition: width 0.3s;
}
.torrent-error {
color: #e74c3c;
font-size: 12px;
margin-top: 4px;
}
.progress-text {
font-size: 12px;
color: #444;

View File

@@ -6,7 +6,7 @@ import fs from "fs";
import path from "path";
import mime from "mime-types";
import { fileURLToPath } from "url";
import { exec } from "child_process";
import { exec, spawn } from "child_process";
import crypto from "crypto"; // 🔒 basit token üretimi için
import { getSystemDiskInfo } from "./utils/diskSpace.js";
import { createAuth } from "./modules/auth.js";
@@ -21,6 +21,7 @@ const app = express();
const upload = multer({ dest: path.join(__dirname, "uploads") });
const client = new WebTorrent();
const torrents = new Map();
const youtubeJobs = new Map();
let wss;
const PORT = process.env.PORT || 3001;
@@ -41,13 +42,15 @@ const VIDEO_THUMB_ROOT = path.join(THUMBNAIL_DIR, "videos");
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");
for (const dir of [
THUMBNAIL_DIR,
VIDEO_THUMB_ROOT,
IMAGE_THUMB_ROOT,
MOVIE_DATA_ROOT,
TV_DATA_ROOT
TV_DATA_ROOT,
YT_DATA_ROOT
]) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
@@ -56,6 +59,9 @@ const VIDEO_THUMBNAIL_TIME = process.env.VIDEO_THUMBNAIL_TIME || "00:00:05";
const VIDEO_EXTS = [".mp4", ".webm", ".mkv", ".mov", ".m4v"];
const generatingThumbnails = new Set();
const INFO_FILENAME = "info.json";
const YT_ID_REGEX = /^[A-Za-z0-9_-]{11}$/;
const YT_DLP_BIN = process.env.YT_DLP_BIN || null;
let resolvedYtDlpBinary = null;
const TMDB_API_KEY = process.env.TMDB_API_KEY;
const TMDB_BASE_URL = "https://api.themoviedb.org/3";
const TMDB_IMG_BASE =
@@ -488,6 +494,389 @@ function sanitizeRelative(relPath) {
return relPath.replace(/^[\\/]+/, "");
}
function getYtDlpBinary() {
if (resolvedYtDlpBinary) return resolvedYtDlpBinary;
const candidates = [
YT_DLP_BIN,
"/usr/local/bin/yt-dlp",
path.join(__dirname, "..", ".pipx", "bin", "yt-dlp"),
"yt-dlp"
].filter(Boolean);
for (const candidate of candidates) {
if (candidate.includes(path.sep) || candidate.startsWith("/")) {
if (fs.existsSync(candidate)) {
resolvedYtDlpBinary = candidate;
return resolvedYtDlpBinary;
}
continue;
}
// bare command name, trust PATH
resolvedYtDlpBinary = candidate;
return resolvedYtDlpBinary;
}
resolvedYtDlpBinary = "yt-dlp";
return resolvedYtDlpBinary;
}
function normalizeYoutubeWatchUrl(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 !== "youtube.com" && host !== "www.youtube.com") return null;
if (urlObj.pathname !== "/watch") return null;
const videoId = urlObj.searchParams.get("v");
if (!videoId || !YT_ID_REGEX.test(videoId)) return null;
return `https://www.youtube.com/watch?v=${videoId}`;
} catch (err) {
return null;
}
}
function startYoutubeDownload(url) {
const normalized = normalizeYoutubeWatchUrl(url);
if (!normalized) return null;
const videoId = new URL(normalized).searchParams.get("v");
const folderId = `yt_${videoId}_${Date.now().toString(36)}`;
const savePath = path.join(DOWNLOAD_DIR, folderId);
fs.mkdirSync(savePath, { recursive: true });
const job = {
id: folderId,
infoHash: folderId,
type: "youtube",
url: normalized,
videoId,
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
};
youtubeJobs.set(job.id, job);
launchYoutubeJob(job);
console.log(`▶️ YouTube indirmesi başlatıldı: ${job.url}`);
broadcastSnapshot();
return job;
}
function launchYoutubeJob(job) {
const binary = getYtDlpBinary();
const args = [
"-f",
"bv+ba/b",
"--write-thumbnail",
"--convert-thumbnails",
"jpg",
job.url
];
const child = spawn(binary, args, {
cwd: job.savePath,
env: process.env
});
job.process = child;
const handleChunk = (chunk) => {
const text = chunk.toString();
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim();
if (!line) continue;
processYoutubeOutput(job, line);
}
};
child.stdout.on("data", handleChunk);
child.stderr.on("data", handleChunk);
child.on("close", (code) => finalizeYoutubeJob(job, code));
child.on("error", (err) => {
job.state = "error";
job.downloadSpeed = 0;
job.error = err?.message || "yt-dlp çalıştırılamadı";
broadcastSnapshot();
});
}
function processYoutubeOutput(job, line) {
const destMatch = line.match(/^\[download\]\s+Destination:\s+(.+)$/i);
if (destMatch) {
startYoutubeStage(job, destMatch[1].trim());
return;
}
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
);
if (progressMatch) {
updateYoutubeProgress(job, progressMatch);
}
}
function startYoutubeStage(job, fileName) {
if (job.currentStage && !job.currentStage.done) {
if (job.currentStage.totalBytes) {
job.completedBytes += job.currentStage.totalBytes;
}
job.currentStage.done = true;
}
const stage = {
name: fileName,
totalBytes: null,
downloadedBytes: 0,
done: false
};
job.currentStage = stage;
job.stages.push(stage);
broadcastSnapshot();
}
function updateYoutubeProgress(job, match) {
const percent = Number(match[1]) || 0;
const totalValue = Number(match[2]) || 0;
const totalUnit = (match[3] || "B").trim();
const totalBytes = bytesFromHuman(totalValue, totalUnit);
const downloadedBytes = Math.min(
totalBytes,
Math.round((percent / 100) * totalBytes)
);
const speedValue = Number(match[4]);
const speedUnit = match[5];
if (job.currentStage) {
job.currentStage.totalBytes = totalBytes;
job.currentStage.downloadedBytes = downloadedBytes;
if (percent >= 100 && !job.currentStage.done) {
job.currentStage.done = true;
job.completedBytes += totalBytes;
}
}
job.totalBytes = job.stages.reduce(
(sum, stage) => sum + (stage.totalBytes || 0),
0
);
const activeBytes = job.currentStage && !job.currentStage.done
? job.currentStage.downloadedBytes
: 0;
const denominator = job.totalBytes || totalBytes || 0;
job.downloaded = job.completedBytes + activeBytes;
job.progress = denominator > 0 ? job.downloaded / denominator : 0;
if (Number.isFinite(speedValue) && speedUnit) {
job.downloadSpeed = bytesFromHuman(speedValue, speedUnit);
}
broadcastSnapshot();
}
async function finalizeYoutubeJob(job, exitCode) {
job.downloadSpeed = 0;
if (exitCode !== 0) {
job.state = "error";
job.error = `yt-dlp ${exitCode} kodu ile sonlandı`;
broadcastSnapshot();
return;
}
try {
if (job.currentStage && !job.currentStage.done && job.currentStage.totalBytes) {
job.completedBytes += job.currentStage.totalBytes;
job.currentStage.done = true;
}
const videoFile = findYoutubeVideoFile(job.savePath);
if (!videoFile) {
job.state = "error";
job.error = "Video dosyası bulunamadı";
broadcastSnapshot();
return;
}
const absVideo = path.join(job.savePath, videoFile);
const stats = fs.statSync(absVideo);
const mediaInfo = await extractMediaInfo(absVideo).catch(() => null);
const relativeName = videoFile.replace(/\\/g, "/");
job.files = [
{
index: 0,
name: relativeName,
length: stats.size
}
];
job.selectedIndex = 0;
job.title = deriveYoutubeTitle(videoFile, job.videoId);
job.downloaded = stats.size;
job.totalBytes = stats.size;
job.progress = 1;
job.state = "completed";
await writeYoutubeMetadata(job, absVideo, mediaInfo);
updateYoutubeThumbnail(job);
upsertInfoFile(job.savePath, {
infoHash: job.id,
name: job.title,
tracker: "youtube",
added: job.added,
folder: job.folderId,
files: {
[relativeName]: {
size: stats.size,
extension: path.extname(relativeName).replace(/^\./, ""),
mediaInfo,
youtube: {
url: job.url,
videoId: job.videoId
}
}
},
primaryVideoPath: relativeName,
primaryMediaInfo: mediaInfo
});
broadcastFileUpdate(job.folderId);
broadcastSnapshot();
broadcastDiskSpace();
console.log(`✅ YouTube indirmesi tamamlandı: ${job.title}`);
} catch (err) {
job.state = "error";
job.error = err?.message || "YouTube indirimi tamamlanamadı";
broadcastSnapshot();
}
}
function findYoutubeVideoFile(savePath) {
const entries = fs.readdirSync(savePath, { withFileTypes: true });
const videos = entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.filter((name) => VIDEO_EXTS.includes(path.extname(name).toLowerCase()));
if (!videos.length) return null;
videos.sort((a, b) => {
const aSize = fs.statSync(path.join(savePath, a)).size;
const bSize = fs.statSync(path.join(savePath, b)).size;
return bSize - aSize;
});
return videos[0];
}
function deriveYoutubeTitle(fileName, videoId) {
const base = fileName.replace(path.extname(fileName), "");
const pattern = videoId ? new RegExp(`\\[${videoId}\\]`, "i") : null;
const cleaned = pattern ? base.replace(pattern, "") : base;
return cleaned.replace(/[-_.]+$/g, "").trim() || base;
}
async function writeYoutubeMetadata(job, videoPath, mediaInfo) {
const targetDir = path.join(YT_DATA_ROOT, job.folderId);
fs.mkdirSync(targetDir, { recursive: true });
const payload = {
id: job.id,
title: job.title,
url: job.url,
videoId: job.videoId,
added: job.added,
folderId: job.folderId,
file: job.files?.[0]?.name || null,
mediaInfo
};
fs.writeFileSync(
path.join(targetDir, "metadata.json"),
JSON.stringify(payload, null, 2),
"utf-8"
);
}
function updateYoutubeThumbnail(job) {
const thumbs = fs
.readdirSync(job.savePath, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".jpg"));
if (!thumbs.length) return;
const source = path.join(job.savePath, thumbs[0].name);
const targetDir = path.join(YT_DATA_ROOT, job.folderId);
fs.mkdirSync(targetDir, { recursive: true });
const target = path.join(targetDir, "thumbnail.jpg");
try {
fs.copyFileSync(source, target);
job.thumbnail = `/yt-data/${job.folderId}/thumbnail.jpg?t=${Date.now()}`;
} catch (err) {
console.warn("Thumbnail kopyalanamadı:", err.message);
}
}
function removeYoutubeJob(jobId, { removeFiles = true } = {}) {
const job = youtubeJobs.get(jobId);
if (!job) return false;
if (job.process && !job.process.killed) {
try {
job.process.kill("SIGTERM");
} catch (err) {
console.warn("YT job kill error:", err.message);
}
}
youtubeJobs.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("YT dosyası silinemedi:", err.message);
}
}
const cacheDir = path.join(YT_DATA_ROOT, job.folderId);
if (removeFiles && fs.existsSync(cacheDir)) {
try {
fs.rmSync(cacheDir, { recursive: true, force: true });
} catch (err) {
console.warn("YT cache silinemedi:", err.message);
}
}
broadcastSnapshot();
if (filesRemoved) {
broadcastFileUpdate(job.folderId);
broadcastDiskSpace();
}
return true;
}
function youtubeSnapshot(job) {
const files = (job.files || []).map((file, index) => ({
index,
name: file.name,
length: file.length
}));
return {
infoHash: job.id,
type: "youtube",
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: null,
added: job.added,
savePath: job.savePath,
paused: false,
files,
selectedIndex: job.selectedIndex || 0,
thumbnail: job.thumbnail,
status: job.state
};
}
function relPathToSegments(relPath) {
return sanitizeRelative(relPath).split(/[\\/]/).filter(Boolean);
}
@@ -553,6 +942,26 @@ function parseFrameRate(value) {
return Number.isFinite(num) ? num : null;
}
const HUMAN_SIZE_UNITS = {
B: 1,
KB: 1000,
MB: 1000 ** 2,
GB: 1000 ** 3,
TB: 1000 ** 4,
KIB: 1024,
MIB: 1024 ** 2,
GIB: 1024 ** 3,
TIB: 1024 ** 4
};
function bytesFromHuman(value, unit = "B") {
if (!Number.isFinite(value)) return 0;
if (!unit) return value;
const normalized = unit.replace(/\s+/g, "").toUpperCase();
const scale = HUMAN_SIZE_UNITS[normalized] || 1;
return value * scale;
}
async function extractMediaInfo(filePath, retryCount = 0) {
if (!filePath || !fs.existsSync(filePath)) return null;
@@ -1093,6 +1502,18 @@ function resolveTvDataAbsolute(relPath) {
return resolved;
}
function resolveYoutubeDataAbsolute(relPath) {
const normalized = sanitizeRelative(relPath);
const resolved = path.resolve(YT_DATA_ROOT, normalized);
if (
resolved !== YT_DATA_ROOT &&
!resolved.startsWith(YT_DATA_ROOT + path.sep)
) {
return null;
}
return resolved;
}
function removeAllThumbnailsForRoot(rootFolder) {
const safe = sanitizeRelative(rootFolder);
if (!safe) return;
@@ -3394,7 +3815,7 @@ function inferMediaFlagsFromTrashEntry(entry) {
// --- Snapshot (thumbnail dahil, tracker + tarih eklendi) ---
function snapshot() {
return Array.from(torrents.values()).map(
const torrentEntries = Array.from(torrents.values()).map(
({ torrent, selectedIndex, savePath, added, paused }) => {
const rootFolder = path.basename(savePath);
const bestVideoIndex = pickBestVideoFile(torrent);
@@ -3409,9 +3830,10 @@ function snapshot() {
queueVideoThumbnail(path.join(savePath, bestVideo.path), relPath);
}
return {
infoHash: torrent.infoHash,
name: torrent.name,
return {
infoHash: torrent.infoHash,
type: "torrent",
name: torrent.name,
progress: torrent.progress,
downloaded: torrent.downloaded,
downloadSpeed: paused ? 0 : torrent.downloadSpeed, // Pause durumunda hız 0
@@ -3431,6 +3853,14 @@ function snapshot() {
};
}
);
const youtubeEntries = Array.from(youtubeJobs.values()).map((job) =>
youtubeSnapshot(job)
);
const combined = [...torrentEntries, ...youtubeEntries];
combined.sort((a, b) => (b.added || 0) - (a.added || 0));
return combined;
}
function wireTorrent(torrent, { savePath, added, respond, restored = false }) {
@@ -3778,6 +4208,14 @@ app.get("/movie-data/:path(*)", requireAuth, (req, res) => {
res.sendFile(fullPath);
});
app.get("/yt-data/:path(*)", requireAuth, (req, res) => {
const relPath = req.params.path || "";
const fullPath = resolveYoutubeDataAbsolute(relPath);
if (!fullPath) return res.status(400).send("Geçersiz yt data yolu");
if (!fs.existsSync(fullPath)) return res.status(404).send("Dosya bulunamadı");
res.sendFile(fullPath);
});
app.get("/tv-data/:path(*)", requireAuth, (req, res) => {
const relPath = req.params.path || "";
const fullPath = resolveTvDataAbsolute(relPath);
@@ -3814,7 +4252,18 @@ app.get("/api/torrents", requireAuth, (req, res) => {
// --- Seçili dosya değiştir ---
app.post("/api/torrents/:hash/select/:index", requireAuth, (req, res) => {
const entry = torrents.get(req.params.hash);
if (!entry) return res.status(404).json({ error: "torrent bulunamadı" });
if (!entry) {
const job = youtubeJobs.get(req.params.hash);
if (!job) return res.status(404).json({ error: "torrent bulunamadı" });
const targetIndex = Number(req.params.index) || 0;
if (!job.files?.[targetIndex]) {
return res
.status(400)
.json({ error: "Geçerli bir video dosyası bulunamadı" });
}
job.selectedIndex = targetIndex;
return res.json({ ok: true, selectedIndex: targetIndex });
}
entry.selectedIndex = Number(req.params.index) || 0;
res.json({ ok: true, selectedIndex: entry.selectedIndex });
});
@@ -3822,7 +4271,13 @@ app.post("/api/torrents/:hash/select/:index", requireAuth, (req, res) => {
// --- Torrent silme (disk dahil) ---
app.delete("/api/torrents/:hash", requireAuth, (req, res) => {
const entry = torrents.get(req.params.hash);
if (!entry) return res.status(404).json({ error: "torrent bulunamadı" });
if (!entry) {
const ytRemoved = removeYoutubeJob(req.params.hash, { removeFiles: true });
if (ytRemoved) {
return res.json({ ok: true, filesRemoved: true });
}
return res.status(404).json({ error: "torrent bulunamadı" });
}
const { torrent, savePath } = entry;
const isComplete = torrent?.done || (torrent?.progress ?? 0) >= 1;
@@ -4009,6 +4464,11 @@ app.post("/api/torrents/:hash/toggle", requireAuth, (req, res) => {
const entry = torrents.get(infoHash);
if (!entry || !entry.torrent || entry.torrent._destroyed) {
if (youtubeJobs.has(infoHash)) {
return res
.status(400)
.json({ error: "YouTube indirmeleri duraklatılamaz" });
}
return res.status(404).json({ error: "torrent bulunamadı" });
}
@@ -5075,6 +5535,24 @@ app.post("/api/movies/rescan", requireAuth, async (req, res) => {
}
});
app.post("/api/youtube/download", requireAuth, async (req, res) => {
try {
const rawUrl = req.body?.url;
const job = startYoutubeDownload(rawUrl);
if (!job) {
return res
.status(400)
.json({ ok: false, error: "Geçerli bir YouTube URL'si gerekli." });
}
res.json({ ok: true, jobId: job.id });
} catch (err) {
res.status(500).json({
ok: false,
error: err?.message || "YouTube indirimi başarısız oldu."
});
}
});
// --- 📺 TV dizileri listesi ---
app.get("/api/tvshows", requireAuth, (req, res) => {
try {
@@ -5687,14 +6165,29 @@ app.post("/api/tvshows/rescan", requireAuth, async (req, res) => {
// --- Stream endpoint (torrent içinden) ---
app.get("/stream/:hash", requireAuth, (req, res) => {
const range = req.headers.range;
const entry = torrents.get(req.params.hash);
if (!entry) return res.status(404).end();
if (entry) {
const selected =
entry.torrent.files[entry.selectedIndex] || entry.torrent.files[0];
return streamTorrentFile(selected, range, res);
}
const file =
entry.torrent.files[entry.selectedIndex] || entry.torrent.files[0];
const job = youtubeJobs.get(req.params.hash);
if (job && job.files?.length) {
const index = job.selectedIndex || 0;
const fileEntry = job.files[index] || job.files[0];
if (!fileEntry) return res.status(404).end();
const absPath = path.join(job.savePath, fileEntry.name);
return streamLocalFile(absPath, range, res);
}
return res.status(404).end();
});
function streamTorrentFile(file, range, res) {
const total = file.length;
const type = mime.lookup(file.name) || "video/mp4";
const range = req.headers.range;
if (!range) {
res.writeHead(200, {
@@ -5720,7 +6213,36 @@ app.get("/stream/:hash", requireAuth, (req, res) => {
stream.on("error", (err) => console.warn("Stream error:", err.message));
res.on("close", () => stream.destroy());
stream.pipe(res);
});
}
function streamLocalFile(filePath, range, res) {
if (!fs.existsSync(filePath)) return res.status(404).end();
const total = fs.statSync(filePath).size;
const type = mime.lookup(filePath) || "video/mp4";
if (!range) {
res.writeHead(200, {
"Content-Length": total,
"Content-Type": type,
"Accept-Ranges": "bytes"
});
return fs.createReadStream(filePath).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 = fs.createReadStream(filePath, { 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);