feat(rclone): RC API ilerleme takibi ve conf editörü ekle

- Rclone RC API kullanılarak dosya yüklemelerinde anlık ilerleme çubuğu eklendi.
- Arayüz üzerinden `rclone.conf` dosyası düzenlenebilir hale getirildi.
- VFS cache boyutu/yaş sınırları ve otomatik temizleme ayarı eklendi.
- Manuel yetkilendirme alanları kaldırıldı.
This commit is contained in:
2026-02-02 15:26:16 +03:00
parent 0fa3a818ae
commit cd4769b3c1
4 changed files with 404 additions and 132 deletions

View File

@@ -56,6 +56,14 @@ const RCLONE_VFS_CACHE_MODE =
const RCLONE_DEBUG_MODE_LOG = ["1", "true", "yes", "on"].includes(
String(process.env.RCLONE_DEBUG_MODE_LOG || "").toLowerCase()
);
const RCLONE_RC_ENABLED = ["1", "true", "yes", "on"].includes(
String(process.env.RCLONE_RC_ENABLED || "1").toLowerCase()
);
const RCLONE_RC_ADDR = process.env.RCLONE_RC_ADDR || "127.0.0.1:5572";
const RCLONE_VFS_CACHE_MAX_SIZE =
process.env.RCLONE_VFS_CACHE_MAX_SIZE || "20G";
const RCLONE_VFS_CACHE_MAX_AGE =
process.env.RCLONE_VFS_CACHE_MAX_AGE || "24h";
const MEDIA_DEBUG_LOG = ["1", "true", "yes", "on"].includes(
String(process.env.MEDIA_DEBUG_LOG || "").toLowerCase()
);
@@ -753,6 +761,7 @@ function resolveRootDir(rootFolder) {
let rcloneProcess = null;
let rcloneLastError = null;
const rcloneAuthSessions = new Map();
let rcloneCacheCleanTimer = null;
function logRcloneMoveError(context, error) {
if (!error) return;
@@ -764,10 +773,11 @@ function logRcloneMoveError(context, error) {
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitForFileStable(filePath, attempts = 5, intervalMs = 2000) {
async function waitForFileStable(filePath, attempts = 8, intervalMs = 3000) {
if (!filePath || !fs.existsSync(filePath)) return false;
let prevSize = null;
let prevMtime = null;
let stableCount = 0;
for (let i = 0; i < attempts; i += 1) {
let stat;
try {
@@ -778,7 +788,12 @@ async function waitForFileStable(filePath, attempts = 5, intervalMs = 2000) {
const size = stat.size;
const mtime = stat.mtimeMs;
if (prevSize !== null && prevMtime !== null) {
if (size === prevSize && mtime === prevMtime) return true;
if (size === prevSize && mtime === prevMtime) {
stableCount += 1;
if (stableCount >= 2) return true;
} else {
stableCount = 0;
}
}
prevSize = size;
prevMtime = mtime;
@@ -787,6 +802,127 @@ async function waitForFileStable(filePath, attempts = 5, intervalMs = 2000) {
return false;
}
function computeRootVideoBytes(rootFolder) {
try {
const entries = enumerateVideoFiles(rootFolder) || [];
const total = entries.reduce((sum, item) => sum + (Number(item.size) || 0), 0);
return total || null;
} catch {
return null;
}
}
let rcloneStatsTimer = null;
async function fetchRcloneStats() {
if (!RCLONE_RC_ENABLED) return null;
try {
let resp = await fetch(`http://${RCLONE_RC_ADDR}/core/stats`, {
method: "POST"
});
if (resp.status === 404) {
resp = await fetch(`http://${RCLONE_RC_ADDR}/rc/core/stats`, {
method: "POST"
});
}
if (!resp.ok) return null;
return await resp.json();
} catch {
return null;
}
}
function updateMoveProgressFromStats(stats) {
if (!stats) return false;
const transfers = Array.isArray(stats.transferring) ? stats.transferring : [];
let updated = false;
const applyProgress = (entry, prefix) => {
if (!entry) return;
const matched = transfers.filter((t) => String(t.name || "").includes(prefix));
if (matched.length) {
const bytes = matched.reduce((sum, t) => sum + (Number(t.bytes) || 0), 0);
const pct = matched.reduce((sum, t) => sum + (Number(t.percentage) || 0), 0) / matched.length;
if (!entry.moveTotalBytes) {
const totalFromStats = matched.reduce((sum, t) => sum + (Number(t.size) || 0), 0);
entry.moveTotalBytes = totalFromStats || entry.moveTotalBytes || null;
}
const progress = Number.isFinite(pct)
? Math.min(Math.max(pct / 100, 0), 0.99)
: entry.moveTotalBytes
? Math.min(Math.max(bytes / entry.moveTotalBytes, 0), 0.99)
: 0;
if (Number.isFinite(progress) && progress !== entry.moveProgress) {
entry.moveProgress = progress;
updated = true;
}
if (entry.moveStatus !== "uploading") {
entry.moveStatus = "uploading";
updated = true;
}
} else {
// Transfer görünmüyorsa queued kalır; done kararı aşağıda verilecek.
if (entry.moveStatus === "uploading") {
entry.moveStatus = "queued";
updated = true;
}
}
};
for (const entry of torrents.values()) {
applyProgress(entry, `${RCLONE_REMOTE_PATH}/${entry.rootFolder || ""}`);
}
for (const job of youtubeJobs.values()) {
applyProgress(job, `${RCLONE_REMOTE_PATH}/${job.folderId || ""}`);
}
for (const job of mailruJobs.values()) {
const folderPrefix = job.folderId ? `${RCLONE_REMOTE_PATH}/${job.folderId}` : null;
if (folderPrefix) {
applyProgress(job, folderPrefix);
}
}
const hasTransfers = transfers.length > 0;
if (!hasTransfers) {
const markDoneIfReady = (entry) => {
if (!entry || !entry.moveTotalBytes) return;
if (entry.moveStatus === "queued" || entry.moveStatus === "uploading") {
if ((entry.moveProgress || 0) >= 0.99) {
entry.moveStatus = "done";
entry.moveProgress = 1;
updated = true;
}
}
};
for (const entry of torrents.values()) markDoneIfReady(entry);
for (const job of youtubeJobs.values()) markDoneIfReady(job);
for (const job of mailruJobs.values()) markDoneIfReady(job);
}
return updated;
}
function startRcloneStatsPolling() {
if (rcloneStatsTimer) return;
rcloneStatsTimer = setInterval(async () => {
const hasActive = Array.from(torrents.values()).some((e) =>
["queued", "uploading"].includes(e.moveStatus)
) ||
Array.from(youtubeJobs.values()).some((e) =>
["queued", "uploading"].includes(e.moveStatus)
) ||
Array.from(mailruJobs.values()).some((e) =>
["queued", "uploading"].includes(e.moveStatus)
);
if (!hasActive) {
clearInterval(rcloneStatsTimer);
rcloneStatsTimer = null;
return;
}
const stats = await fetchRcloneStats();
if (updateMoveProgressFromStats(stats)) {
scheduleSnapshotBroadcast();
}
}, 2000);
}
function parseRcloneTokenFromText(text) {
if (!text || !text.includes("access_token")) return null;
const start = text.indexOf("{");
@@ -816,6 +952,7 @@ function loadRcloneSettings() {
return {
autoMove: false,
autoMount: false,
cacheCleanMinutes: 0,
remoteName: RCLONE_REMOTE_NAME,
remotePath: RCLONE_REMOTE_PATH,
mountDir: RCLONE_MOUNT_DIR,
@@ -827,6 +964,7 @@ function loadRcloneSettings() {
return {
autoMove: Boolean(data.autoMove),
autoMount: Boolean(data.autoMount),
cacheCleanMinutes: Number(data.cacheCleanMinutes) || 0,
remoteName: data.remoteName || RCLONE_REMOTE_NAME,
remotePath: data.remotePath || RCLONE_REMOTE_PATH,
mountDir: data.mountDir || RCLONE_MOUNT_DIR,
@@ -837,6 +975,7 @@ function loadRcloneSettings() {
return {
autoMove: false,
autoMount: false,
cacheCleanMinutes: 0,
remoteName: RCLONE_REMOTE_NAME,
remotePath: RCLONE_REMOTE_PATH,
mountDir: RCLONE_MOUNT_DIR,
@@ -856,6 +995,55 @@ function saveRcloneSettings(partial) {
return next;
}
function startRcloneCacheCleanSchedule(minutes) {
if (rcloneCacheCleanTimer) {
clearInterval(rcloneCacheCleanTimer);
rcloneCacheCleanTimer = null;
}
const interval = Number(minutes);
if (!interval || interval <= 0) return;
rcloneCacheCleanTimer = setInterval(() => {
if (!RCLONE_RC_ENABLED) return;
fetch(`http://${RCLONE_RC_ADDR}/vfs/refresh`, { method: "POST" })
.then((resp) => {
if (resp.status === 404) {
return fetch(`http://${RCLONE_RC_ADDR}/rc/vfs/refresh`, {
method: "POST"
});
}
return resp;
})
.then(() => {
console.log("🧹 Rclone cache temizleme tetiklendi.");
})
.catch(() => {
console.warn("⚠️ Rclone cache temizleme başarısız.");
});
}, interval * 60 * 1000);
}
async function runRcloneCacheClean() {
const settings = loadRcloneSettings();
const wasRunning = Boolean(rcloneProcess && !rcloneProcess.killed);
if (wasRunning) {
stopRcloneMount();
}
try {
fs.rmSync(RCLONE_VFS_CACHE_DIR, { recursive: true, force: true });
fs.mkdirSync(RCLONE_VFS_CACHE_DIR, { recursive: true });
if (wasRunning) {
const result = startRcloneMount(settings);
if (!result.ok) {
return { ok: false, error: result.error || "Rclone yeniden başlatılamadı" };
}
return { ok: true, method: "fs", restarted: true };
}
return { ok: true, method: "fs", restarted: false };
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
}
function isRcloneMounted(mountDir) {
if (!mountDir) return false;
try {
@@ -903,6 +1091,10 @@ function startRcloneMount(settings) {
RCLONE_VFS_CACHE_MODE,
"--cache-dir",
RCLONE_VFS_CACHE_DIR,
"--vfs-cache-max-size",
RCLONE_VFS_CACHE_MAX_SIZE,
"--vfs-cache-max-age",
RCLONE_VFS_CACHE_MAX_AGE,
"--dir-cache-time",
RCLONE_DIR_CACHE_TIME,
"--poll-interval",
@@ -910,6 +1102,11 @@ function startRcloneMount(settings) {
"--log-level",
"INFO"
];
if (RCLONE_RC_ENABLED) {
args.push("--rc");
args.push("--rc-addr", RCLONE_RC_ADDR);
args.push("--rc-no-auth");
}
try {
rcloneProcess = spawn("rclone", args, {
@@ -1297,6 +1494,8 @@ function startYoutubeDownload(url, { moveToGdrive = false } = {}) {
moveToGdrive: Boolean(moveToGdrive),
moveStatus: "idle",
moveError: null,
moveProgress: null,
moveTotalBytes: null,
progress: 0,
downloaded: 0,
totalBytes: 0,
@@ -1652,17 +1851,20 @@ async function finalizeYoutubeJob(job, exitCode) {
console.log(`✅ YouTube indirmesi tamamlandı: ${job.title}`);
if (job.moveToGdrive) {
job.moveStatus = "moving";
job.moveStatus = "queued";
job.moveError = null;
job.moveProgress = 0;
job.moveTotalBytes = job.totalBytes || computeRootVideoBytes(job.folderId) || null;
scheduleSnapshotBroadcast();
startRcloneStatsPolling();
const moveResult = await moveRootFolderToGdrive(job.folderId);
if (moveResult.ok) {
job.moveStatus = "done";
} else {
job.moveStatus = "error";
job.moveError = moveResult.error || "GDrive taşıma hatası";
logRcloneMoveError(`youtube:${job.id}`, job.moveError);
}
if (moveResult.ok) {
// Upload tamamlanma durumu RC stats ile belirlenecek
} else {
job.moveStatus = "error";
job.moveError = moveResult.error || "GDrive taşıma hatası";
logRcloneMoveError(`youtube:${job.id}`, job.moveError);
}
broadcastFileUpdate("downloads");
scheduleSnapshotBroadcast();
}
@@ -1818,7 +2020,9 @@ function mailruSnapshot(job) {
status: job.state,
moveToGdrive: job.moveToGdrive || false,
moveStatus: job.moveStatus || "idle",
moveError: job.moveError || null
moveError: job.moveError || null,
moveProgress: job.moveProgress ?? null,
moveTotalBytes: job.moveTotalBytes ?? null
};
}
@@ -1993,12 +2197,15 @@ async function finalizeMailRuJob(job, exitCode) {
console.log(`✅ Mail.ru indirmesi tamamlandı: ${job.title}`);
if (job.moveToGdrive) {
job.moveStatus = "moving";
job.moveStatus = "queued";
job.moveError = null;
job.moveProgress = 0;
job.moveTotalBytes = job.totalBytes || null;
scheduleSnapshotBroadcast();
startRcloneStatsPolling();
const moveResult = await movePathToGdrive(relPath);
if (moveResult.ok) {
job.moveStatus = "done";
// Upload tamamlanma durumu RC stats ile belirlenecek
} else {
job.moveStatus = "error";
job.moveError = moveResult.error || "GDrive taşıma hatası";
@@ -2101,6 +2308,8 @@ async function startMailRuDownload(url, { moveToGdrive = false } = {}) {
moveToGdrive: Boolean(moveToGdrive),
moveStatus: "idle",
moveError: null,
moveProgress: null,
moveTotalBytes: null,
state: "awaiting_match",
progress: 0,
downloaded: 0,
@@ -2390,7 +2599,9 @@ function youtubeSnapshot(job) {
status: job.state,
moveToGdrive: job.moveToGdrive || false,
moveStatus: job.moveStatus || "idle",
moveError: job.moveError || null
moveError: job.moveError || null,
moveProgress: job.moveProgress ?? null,
moveTotalBytes: job.moveTotalBytes ?? null
};
}
@@ -6471,7 +6682,9 @@ function snapshot() {
thumbnail: entry?.thumbnail || null,
moveToGdrive: entry?.moveToGdrive || false,
moveStatus: entry?.moveStatus || "idle",
moveError: entry?.moveError || null
moveError: entry?.moveError || null,
moveProgress: entry?.moveProgress ?? null,
moveTotalBytes: entry?.moveTotalBytes ?? null
};
}
);
@@ -6507,7 +6720,9 @@ function wireTorrent(
rootFolder: savePath ? path.basename(savePath) : null,
moveToGdrive: Boolean(moveToGdrive),
moveStatus: "idle",
moveError: null
moveError: null,
moveProgress: null,
moveTotalBytes: null
});
const scheduleTorrentSnapshot = () => scheduleSnapshotBroadcast();
@@ -6820,12 +7035,23 @@ async function onTorrentDone({ torrent }) {
}
if (entry.moveToGdrive) {
entry.moveStatus = "moving";
const paused = pauseTorrentEntry(entry);
if (paused) {
console.log(`⏸️ GDrive taşıma için torrent durduruldu: ${entry.infoHash}`);
}
entry.moveStatus = "queued";
entry.moveError = null;
entry.moveProgress = 0;
entry.moveTotalBytes =
entry.totalBytes ||
torrent?.length ||
computeRootVideoBytes(rootFolder) ||
null;
scheduleSnapshotBroadcast();
startRcloneStatsPolling();
const moveResult = await moveRootFolderToGdrive(rootFolder);
if (moveResult.ok) {
entry.moveStatus = "done";
// Upload tamamlanma durumu RC stats ile belirlenecek
} else {
entry.moveStatus = "error";
entry.moveError = moveResult.error || "GDrive taşıma hatası";
@@ -8771,6 +8997,7 @@ app.get("/api/rclone/status", requireAuth, async (req, res) => {
configPath: settings.configPath,
autoMove: settings.autoMove,
autoMount: settings.autoMount,
cacheCleanMinutes: settings.cacheCleanMinutes || 0,
configExists: fs.existsSync(settings.configPath),
remoteConfigured: rcloneConfigHasRemote(settings.remoteName),
lastError: rcloneLastError || null
@@ -8779,15 +9006,17 @@ app.get("/api/rclone/status", requireAuth, async (req, res) => {
app.post("/api/rclone/settings", requireAuth, (req, res) => {
try {
const { autoMove, autoMount, remoteName, remotePath, mountDir } = req.body || {};
const { autoMove, autoMount, remoteName, remotePath, mountDir, cacheCleanMinutes } = req.body || {};
const next = saveRcloneSettings({
autoMove: Boolean(autoMove),
autoMount: Boolean(autoMount),
cacheCleanMinutes: Number(cacheCleanMinutes) || 0,
remoteName: remoteName || RCLONE_REMOTE_NAME,
remotePath: remotePath || RCLONE_REMOTE_PATH,
mountDir: mountDir || RCLONE_MOUNT_DIR,
configPath: RCLONE_CONFIG_PATH
});
startRcloneCacheCleanSchedule(next.cacheCleanMinutes);
res.json({ ok: true, settings: next });
} catch (err) {
res.status(500).json({ ok: false, error: err?.message || String(err) });
@@ -8905,6 +9134,48 @@ app.post("/api/rclone/mount", requireAuth, (req, res) => {
return res.json({ ok: true, ...result });
});
app.post("/api/rclone/cache/clean", requireAuth, async (req, res) => {
const result = await runRcloneCacheClean();
if (!result.ok) {
return res.status(500).json({ ok: false, error: result.error });
}
return res.json({ ok: true, ...result });
});
app.get("/api/rclone/conf", requireAuth, (req, res) => {
try {
if (!fs.existsSync(RCLONE_CONFIG_PATH)) {
return res.json({ ok: true, content: "" });
}
const content = fs.readFileSync(RCLONE_CONFIG_PATH, "utf-8");
res.json({ ok: true, content });
} catch (err) {
res.status(500).json({ ok: false, error: err?.message || String(err) });
}
});
app.post("/api/rclone/conf", requireAuth, (req, res) => {
try {
const content = String(req.body?.content || "");
if (!content.trim()) {
return res.status(400).json({ ok: false, error: "Boş içerik gönderilemez." });
}
ensureDirForFile(RCLONE_CONFIG_PATH);
fs.writeFileSync(RCLONE_CONFIG_PATH, content, "utf-8");
const settings = loadRcloneSettings();
if (rcloneProcess && !rcloneProcess.killed) {
stopRcloneMount();
const restart = startRcloneMount(settings);
if (!restart.ok) {
return res.status(500).json({ ok: false, error: restart.error || "Rclone yeniden başlatılamadı." });
}
}
res.json({ ok: true });
} catch (err) {
res.status(500).json({ ok: false, error: err?.message || String(err) });
}
});
app.post("/api/rclone/unmount", requireAuth, (req, res) => {
const result = stopRcloneMount();
if (!result.ok) {
@@ -10180,6 +10451,7 @@ if (RCLONE_ENABLED && initialRcloneSettings.autoMount) {
console.warn(`⚠️ Rclone mount başlatılamadı: ${result.error}`);
}
}
startRcloneCacheCleanSchedule(initialRcloneSettings.cacheCleanMinutes || 0);
// --- ✅ Client build (frontend) dosyalarını sun ---
const publicDir = path.join(__dirname, "public");