Youtube download özelliği eklendi
This commit is contained in:
@@ -56,19 +56,70 @@
|
|||||||
await list();
|
await list();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addMagnet() {
|
const YT_VIDEO_ID_RE = /^[A-Za-z0-9_-]{11}$/;
|
||||||
const m = prompt("URL Girin:");
|
|
||||||
if (!m) return;
|
function isMagnetLink(value) {
|
||||||
await apiFetch("/api/transfer", {
|
if (!value || typeof value !== "string") return false;
|
||||||
method: "POST",
|
const normalized = value.trim().toLowerCase();
|
||||||
headers: { "Content-Type": "application/json" },
|
return normalized.startsWith("magnet:?xt=");
|
||||||
body: JSON.stringify({ magnet: m })
|
|
||||||
}); // ✅
|
|
||||||
await list();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectFile(hash, index) {
|
function normalizeYoutubeUrl(value) {
|
||||||
ws?.send(JSON.stringify({ type: "select", infoHash: hash, index }));
|
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) {
|
async function removeTorrent(hash) {
|
||||||
@@ -116,13 +167,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateAllPausedState() {
|
function updateAllPausedState() {
|
||||||
if (torrents.length === 0) {
|
const torrentOnly = torrents.filter((t) => !t.type || t.type === "torrent");
|
||||||
|
if (torrentOnly.length === 0) {
|
||||||
isAllPaused = false;
|
isAllPaused = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eğer tüm torrentler paused ise, global durumu paused yap
|
// 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;
|
isAllPaused = allPaused;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,18 +218,32 @@
|
|||||||
return (bytesPerSec / 1e6).toFixed(2) + " MB/s";
|
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) {
|
function openModal(t) {
|
||||||
|
if (!t.files || !t.files.length) {
|
||||||
|
alert("Bu indirme için oynatılabilir video bulunamadı.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const selectedFile =
|
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) {
|
if (!selectedFile) {
|
||||||
alert("Bu torrentte oynatılabilir video dosyası bulunamadı!");
|
alert("Bu indirmede oynatılabilir video dosyası bulunamadı!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedVideo = {
|
selectedVideo = {
|
||||||
...t,
|
...t,
|
||||||
fileIndex: selectedFile.index,
|
fileIndex: selectedFile.index,
|
||||||
fileName: selectedFile.name
|
fileName: selectedFile.name,
|
||||||
|
type: t.type || "torrent"
|
||||||
};
|
};
|
||||||
showModal = true;
|
showModal = true;
|
||||||
}
|
}
|
||||||
@@ -403,7 +469,7 @@
|
|||||||
style="display:none;"
|
style="display:none;"
|
||||||
/>
|
/>
|
||||||
</label>
|
</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
|
<i class="fa-solid fa-magnet btn-icon"></i> ADD URL
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -476,19 +542,28 @@
|
|||||||
|
|
||||||
<div class="torrent-info">
|
<div class="torrent-info">
|
||||||
<div class="torrent-header">
|
<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;">
|
<div style="display:flex; gap:5px;">
|
||||||
<button
|
{#if t.type !== "youtube"}
|
||||||
class="toggle-btn"
|
<button
|
||||||
on:click|stopPropagation={() => toggleSingleTorrent(t.infoHash)}
|
class="toggle-btn"
|
||||||
title={t.paused ? "Devam Ettir" : "Durdur"}
|
on:click|stopPropagation={() => toggleSingleTorrent(t.infoHash)}
|
||||||
>
|
title={t.paused ? "Devam Ettir" : "Durdur"}
|
||||||
{#if t.paused}
|
>
|
||||||
<i class="fa-solid fa-play"></i>
|
{#if t.paused}
|
||||||
{:else}
|
<i class="fa-solid fa-play"></i>
|
||||||
<i class="fa-solid fa-pause"></i>
|
{:else}
|
||||||
{/if}
|
<i class="fa-solid fa-pause"></i>
|
||||||
</button>
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
<button
|
<button
|
||||||
class="remove-btn"
|
class="remove-btn"
|
||||||
on:click|stopPropagation={() => removeTorrent(t.infoHash)}
|
on:click|stopPropagation={() => removeTorrent(t.infoHash)}
|
||||||
@@ -498,26 +573,33 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="torrent-hash">
|
<div class="torrent-hash">
|
||||||
Hash: {t.infoHash} | Tracker: {t.tracker ?? "Unknown"} | Added:
|
{#if t.type === "youtube"}
|
||||||
{t.added ? new Date(t.added).toLocaleString() : "Unknown"}
|
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>
|
||||||
|
|
||||||
<div class="torrent-files">
|
{#if t.files && t.files.length}
|
||||||
{#each t.files as f}
|
<div class="torrent-files">
|
||||||
<div class="file-row">
|
{#each t.files as f}
|
||||||
<button
|
<div class="file-row">
|
||||||
on:click|stopPropagation={() =>
|
<button
|
||||||
selectFile(t.infoHash, f.index)}
|
on:click|stopPropagation={() =>
|
||||||
>
|
selectFile(t.infoHash, f.index)}
|
||||||
{f.index === t.selectedIndex ? "Selected" : "Select"}
|
>
|
||||||
</button>
|
{f.index === t.selectedIndex ? "Selected" : "Select"}
|
||||||
<div class="filename">{f.name}</div>
|
</button>
|
||||||
<div class="filesize">
|
<div class="filename">{f.name}</div>
|
||||||
{(f.length / 1e6).toFixed(1)} MB
|
<div class="filesize">
|
||||||
|
{(f.length / 1e6).toFixed(1)} MB
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/each}
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
|
|
||||||
<div class="progress-bar">
|
<div class="progress-bar">
|
||||||
<div
|
<div
|
||||||
@@ -530,12 +612,17 @@
|
|||||||
{#if (t.progress || 0) < 1}
|
{#if (t.progress || 0) < 1}
|
||||||
{(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)} ↓
|
||||||
{t.numPeers ?? 0} peers
|
{#if t.type !== "youtube"}
|
||||||
|
• {t.numPeers ?? 0} peers
|
||||||
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
100.0% • {(t.downloaded / 1e6).toFixed(1)} MB
|
100.0% • {(t.downloaded / 1e6).toFixed(1)} MB
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if t.status === "error"}
|
||||||
|
<div class="torrent-error">Download failed</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -656,6 +743,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* --- Torrent Listeleme --- */
|
/* --- Torrent Listeleme --- */
|
||||||
.torrent-list {
|
.torrent-list {
|
||||||
@@ -711,10 +799,22 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.torrent-title {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.torrent-name {
|
.torrent-name {
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.torrent-subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
.toggle-btn {
|
.toggle-btn {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -799,6 +899,13 @@
|
|||||||
transition: width 0.3s;
|
transition: width 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.torrent-error {
|
||||||
|
color: #e74c3c;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.progress-text {
|
.progress-text {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #444;
|
color: #444;
|
||||||
|
|||||||
548
server/server.js
548
server/server.js
@@ -6,7 +6,7 @@ import fs from "fs";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import mime from "mime-types";
|
import mime from "mime-types";
|
||||||
import { fileURLToPath } from "url";
|
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 crypto from "crypto"; // 🔒 basit token üretimi için
|
||||||
import { getSystemDiskInfo } from "./utils/diskSpace.js";
|
import { getSystemDiskInfo } from "./utils/diskSpace.js";
|
||||||
import { createAuth } from "./modules/auth.js";
|
import { createAuth } from "./modules/auth.js";
|
||||||
@@ -21,6 +21,7 @@ const app = express();
|
|||||||
const upload = multer({ dest: path.join(__dirname, "uploads") });
|
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();
|
||||||
let wss;
|
let wss;
|
||||||
const PORT = process.env.PORT || 3001;
|
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 IMAGE_THUMB_ROOT = path.join(THUMBNAIL_DIR, "images");
|
||||||
const MOVIE_DATA_ROOT = path.join(CACHE_DIR, "movie_data");
|
const MOVIE_DATA_ROOT = path.join(CACHE_DIR, "movie_data");
|
||||||
const TV_DATA_ROOT = path.join(CACHE_DIR, "tv_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 [
|
for (const dir of [
|
||||||
THUMBNAIL_DIR,
|
THUMBNAIL_DIR,
|
||||||
VIDEO_THUMB_ROOT,
|
VIDEO_THUMB_ROOT,
|
||||||
IMAGE_THUMB_ROOT,
|
IMAGE_THUMB_ROOT,
|
||||||
MOVIE_DATA_ROOT,
|
MOVIE_DATA_ROOT,
|
||||||
TV_DATA_ROOT
|
TV_DATA_ROOT,
|
||||||
|
YT_DATA_ROOT
|
||||||
]) {
|
]) {
|
||||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
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 VIDEO_EXTS = [".mp4", ".webm", ".mkv", ".mov", ".m4v"];
|
||||||
const generatingThumbnails = new Set();
|
const generatingThumbnails = new Set();
|
||||||
const INFO_FILENAME = "info.json";
|
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_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 =
|
||||||
@@ -488,6 +494,389 @@ function sanitizeRelative(relPath) {
|
|||||||
return relPath.replace(/^[\\/]+/, "");
|
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) {
|
function relPathToSegments(relPath) {
|
||||||
return sanitizeRelative(relPath).split(/[\\/]/).filter(Boolean);
|
return sanitizeRelative(relPath).split(/[\\/]/).filter(Boolean);
|
||||||
}
|
}
|
||||||
@@ -553,6 +942,26 @@ function parseFrameRate(value) {
|
|||||||
return Number.isFinite(num) ? num : null;
|
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) {
|
async function extractMediaInfo(filePath, retryCount = 0) {
|
||||||
if (!filePath || !fs.existsSync(filePath)) return null;
|
if (!filePath || !fs.existsSync(filePath)) return null;
|
||||||
|
|
||||||
@@ -1093,6 +1502,18 @@ function resolveTvDataAbsolute(relPath) {
|
|||||||
return resolved;
|
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) {
|
function removeAllThumbnailsForRoot(rootFolder) {
|
||||||
const safe = sanitizeRelative(rootFolder);
|
const safe = sanitizeRelative(rootFolder);
|
||||||
if (!safe) return;
|
if (!safe) return;
|
||||||
@@ -3394,7 +3815,7 @@ function inferMediaFlagsFromTrashEntry(entry) {
|
|||||||
|
|
||||||
// --- Snapshot (thumbnail dahil, tracker + tarih eklendi) ---
|
// --- Snapshot (thumbnail dahil, tracker + tarih eklendi) ---
|
||||||
function snapshot() {
|
function snapshot() {
|
||||||
return Array.from(torrents.values()).map(
|
const torrentEntries = Array.from(torrents.values()).map(
|
||||||
({ torrent, selectedIndex, savePath, added, paused }) => {
|
({ torrent, selectedIndex, savePath, added, paused }) => {
|
||||||
const rootFolder = path.basename(savePath);
|
const rootFolder = path.basename(savePath);
|
||||||
const bestVideoIndex = pickBestVideoFile(torrent);
|
const bestVideoIndex = pickBestVideoFile(torrent);
|
||||||
@@ -3409,9 +3830,10 @@ function snapshot() {
|
|||||||
queueVideoThumbnail(path.join(savePath, bestVideo.path), relPath);
|
queueVideoThumbnail(path.join(savePath, bestVideo.path), relPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
infoHash: torrent.infoHash,
|
infoHash: torrent.infoHash,
|
||||||
name: torrent.name,
|
type: "torrent",
|
||||||
|
name: torrent.name,
|
||||||
progress: torrent.progress,
|
progress: torrent.progress,
|
||||||
downloaded: torrent.downloaded,
|
downloaded: torrent.downloaded,
|
||||||
downloadSpeed: paused ? 0 : torrent.downloadSpeed, // Pause durumunda hız 0
|
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 }) {
|
function wireTorrent(torrent, { savePath, added, respond, restored = false }) {
|
||||||
@@ -3778,6 +4208,14 @@ app.get("/movie-data/:path(*)", requireAuth, (req, res) => {
|
|||||||
res.sendFile(fullPath);
|
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) => {
|
app.get("/tv-data/:path(*)", requireAuth, (req, res) => {
|
||||||
const relPath = req.params.path || "";
|
const relPath = req.params.path || "";
|
||||||
const fullPath = resolveTvDataAbsolute(relPath);
|
const fullPath = resolveTvDataAbsolute(relPath);
|
||||||
@@ -3814,7 +4252,18 @@ app.get("/api/torrents", requireAuth, (req, res) => {
|
|||||||
// --- Seçili dosya değiştir ---
|
// --- Seçili dosya değiştir ---
|
||||||
app.post("/api/torrents/:hash/select/:index", requireAuth, (req, res) => {
|
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) 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;
|
entry.selectedIndex = Number(req.params.index) || 0;
|
||||||
res.json({ ok: true, selectedIndex: entry.selectedIndex });
|
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) ---
|
// --- Torrent silme (disk dahil) ---
|
||||||
app.delete("/api/torrents/:hash", requireAuth, (req, res) => {
|
app.delete("/api/torrents/:hash", requireAuth, (req, res) => {
|
||||||
const entry = torrents.get(req.params.hash);
|
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 { torrent, savePath } = entry;
|
||||||
const isComplete = torrent?.done || (torrent?.progress ?? 0) >= 1;
|
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);
|
const entry = torrents.get(infoHash);
|
||||||
if (!entry || !entry.torrent || entry.torrent._destroyed) {
|
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ı" });
|
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 ---
|
// --- 📺 TV dizileri listesi ---
|
||||||
app.get("/api/tvshows", requireAuth, (req, res) => {
|
app.get("/api/tvshows", requireAuth, (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -5687,14 +6165,29 @@ app.post("/api/tvshows/rescan", requireAuth, async (req, res) => {
|
|||||||
|
|
||||||
// --- Stream endpoint (torrent içinden) ---
|
// --- Stream endpoint (torrent içinden) ---
|
||||||
app.get("/stream/:hash", requireAuth, (req, res) => {
|
app.get("/stream/:hash", requireAuth, (req, res) => {
|
||||||
|
const range = req.headers.range;
|
||||||
const entry = torrents.get(req.params.hash);
|
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 =
|
const job = youtubeJobs.get(req.params.hash);
|
||||||
entry.torrent.files[entry.selectedIndex] || entry.torrent.files[0];
|
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 total = file.length;
|
||||||
const type = mime.lookup(file.name) || "video/mp4";
|
const type = mime.lookup(file.name) || "video/mp4";
|
||||||
const range = req.headers.range;
|
|
||||||
|
|
||||||
if (!range) {
|
if (!range) {
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
@@ -5720,7 +6213,36 @@ app.get("/stream/:hash", requireAuth, (req, res) => {
|
|||||||
stream.on("error", (err) => console.warn("Stream error:", err.message));
|
stream.on("error", (err) => console.warn("Stream error:", err.message));
|
||||||
res.on("close", () => stream.destroy());
|
res.on("close", () => stream.destroy());
|
||||||
stream.pipe(res);
|
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);
|
console.log("🗄️ Download path:", DOWNLOAD_DIR);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user