feat(rclone): Google Drive entegrasyonu ekle

Dockerfile ve docker-compose yapılandırması Rclone ve FUSE için güncellendi.
Backend API'leri Rclone durumunu, ayarlarını, yetkilendirmesini ve mount işlemlerini
yönetmek için eklendi. İndirmeler tamamlandığında (Torrent, YouTube, Mail.ru)
dosyaların otomatik veya manuel olarak Google Drive'a taşınması sağlandı.
Dosya sistemi hem yerel hem de mount edilmiş GDrive yollarını destekleyecek şekilde
güncellendi. Ayarlar ve Dosyalar arayüzüne ilgili kontroller eklendi.
This commit is contained in:
2026-02-02 11:35:05 +03:00
parent e7aaea53ad
commit 0fa3a818ae
7 changed files with 1349 additions and 114 deletions

View File

@@ -43,3 +43,27 @@ WEBDAV_PATH=/webdav
WEBDAV_READONLY=1
# WebDAV index yeniden oluşturma süresi (ms).
WEBDAV_INDEX_TTL=60000
# --- Rclone / Google Drive ---
# Rclone entegrasyonunu aç/kapat
RCLONE_ENABLED=0
# Rclone config dosyası konumu (container içinde)
RCLONE_CONFIG_PATH=/config/rclone/rclone.conf
# Google Drive mount edilecek dizin (container içinde)
RCLONE_MOUNT_DIR=/app/server/gdrive
# Rclone remote adı
RCLONE_REMOTE_NAME=dupe
# Google Drive içinde kullanılacak klasör adı
RCLONE_REMOTE_PATH=Dupe
# Rclone mount tazeleme/poll süresi
RCLONE_POLL_INTERVAL=1m
# Rclone dizin cache süresi
RCLONE_DIR_CACHE_TIME=1m
# Rclone VFS cache modu (off, minimal, writes, full)
RCLONE_VFS_CACHE_MODE=full
# Rclone VFS cache dizini
RCLONE_VFS_CACHE_DIR=/app/server/cache/rclone-vfs
# Rclone debug log (taşıma hatalarını detaylı loglamak için)
RCLONE_DEBUG_MODE_LOG=0
# Media stream debug log (akış kaynağını loglamak için)
MEDIA_DEBUG_LOG=0

View File

@@ -8,7 +8,7 @@ RUN npm run build
# Build server
FROM node:22-slim
RUN apt-get update && apt-get install -y ffmpeg curl aria2 && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y ffmpeg curl aria2 rclone fuse3 && rm -rf /var/lib/apt/lists/*
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp \
&& chmod a+rx /usr/local/bin/yt-dlp
WORKDIR /app/server

View File

@@ -604,6 +604,27 @@
refreshMovieCount();
refreshTvShowCount();
}
async function moveToGdrive(entry) {
if (!entry?.name) return;
const ok = confirm("Bu öğe GDrive'a taşınsın mı?");
if (!ok) return;
try {
const resp = await apiFetch("/api/rclone/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: entry.name })
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.ok) {
alert(data?.error || "GDrive taşıma başarısız oldu.");
return;
}
await loadFiles();
} catch (err) {
alert(err?.message || "GDrive taşıma başarısız oldu.");
}
}
function formatSize(bytes) {
if (!bytes) return "0 MB";
if (bytes < 1e6) return (bytes / 1e3).toFixed(1) + " KB";
@@ -1237,7 +1258,7 @@
const button = event.currentTarget;
const rect = button.getBoundingClientRect();
const menuWidth = 160;
const menuHeight = 140; // Yaklaşık menü yüksekliği
const menuHeight = 180; // Yaklaşık menü yüksekliği
// Üç noktanın son noktası ile menünün sol kenarını hizala
// Düğme genişliği 34px, son nokta sağ kenara yakın
@@ -2389,6 +2410,14 @@
</button>
<div class="menu-divider"></div>
{/if}
<button
class="menu-item"
on:click|stopPropagation={() => moveToGdrive(activeMenu)}
>
<i class="fa-solid fa-cloud-arrow-up"></i>
<span>GDrive'a Taşı</span>
</button>
<div class="menu-divider"></div>
<button
class="menu-item delete"
on:click|stopPropagation={() => deleteFile(activeMenu)}

View File

@@ -5,6 +5,7 @@
const tabs = [
{ id: "general", label: "General", icon: "fa-solid fa-sliders" },
{ id: "youtube", label: "YouTube", icon: "fa-brands fa-youtube" },
{ id: "rclone", label: "Rclone", icon: "fa-solid fa-cloud" },
{ id: "advanced", label: "Advanced", icon: "fa-solid fa-gear" }
];
@@ -21,6 +22,19 @@
let error = null;
let success = null;
let rcloneStatus = null;
let rcloneLoading = false;
let rcloneSaving = false;
let rcloneAuthUrl = "";
let rcloneToken = "";
let rcloneClientId = "";
let rcloneClientSecret = "";
let rcloneAutoMove = false;
let rcloneAutoMount = false;
let rcloneRemoteName = "";
let rcloneRemotePath = "";
let rcloneMountDir = "";
async function loadCookies() {
loadingCookies = true;
error = null;
@@ -111,9 +125,136 @@
}
}
async function loadRcloneStatus() {
rcloneLoading = true;
error = null;
try {
const resp = await apiFetch("/api/rclone/status");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
rcloneStatus = data;
rcloneAutoMove = Boolean(data?.autoMove);
rcloneAutoMount = Boolean(data?.autoMount);
rcloneRemoteName = data?.remoteName || "";
rcloneRemotePath = data?.remotePath || "";
rcloneMountDir = data?.mountDir || "";
} catch (err) {
error = err?.message || "Rclone durumu alınamadı.";
} finally {
rcloneLoading = false;
}
}
async function saveRcloneSettings() {
if (rcloneSaving) return;
rcloneSaving = true;
error = null;
success = null;
try {
const resp = await apiFetch("/api/rclone/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
autoMove: rcloneAutoMove,
autoMount: rcloneAutoMount,
remoteName: rcloneRemoteName,
remotePath: rcloneRemotePath,
mountDir: rcloneMountDir
})
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.ok) {
throw new Error(data?.error || `HTTP ${resp.status}`);
}
success = "Rclone ayarları kaydedildi.";
await loadRcloneStatus();
} catch (err) {
error = err?.message || "Rclone ayarları kaydedilemedi.";
} finally {
rcloneSaving = false;
}
}
async function requestRcloneAuthUrl() {
error = null;
success = null;
try {
const resp = await apiFetch("/api/rclone/auth-url");
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.ok) {
throw new Error(data?.error || `HTTP ${resp.status}`);
}
rcloneAuthUrl = data.url || "";
} catch (err) {
error = err?.message || "Auth URL alınamadı.";
}
}
async function applyRcloneToken() {
if (!rcloneToken) {
error = "Token zorunlu.";
return;
}
error = null;
success = null;
try {
const resp = await apiFetch("/api/rclone/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token: rcloneToken,
clientId: rcloneClientId,
clientSecret: rcloneClientSecret,
remoteName: rcloneRemoteName
})
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.ok) {
throw new Error(data?.error || `HTTP ${resp.status}`);
}
success = "Token kaydedildi.";
await loadRcloneStatus();
} catch (err) {
error = err?.message || "Token kaydedilemedi.";
}
}
async function startRcloneMount() {
error = null;
success = null;
try {
const resp = await apiFetch("/api/rclone/mount", { method: "POST" });
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.ok) {
throw new Error(data?.error || `HTTP ${resp.status}`);
}
success = "Rclone mount başlatıldı.";
await loadRcloneStatus();
} catch (err) {
error = err?.message || "Rclone mount başlatılamadı.";
}
}
async function stopRcloneMount() {
error = null;
success = null;
try {
const resp = await apiFetch("/api/rclone/unmount", { method: "POST" });
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.ok) {
throw new Error(data?.error || `HTTP ${resp.status}`);
}
success = "Rclone mount durduruldu.";
await loadRcloneStatus();
} catch (err) {
error = err?.message || "Rclone mount durdurulamadı.";
}
}
onMount(() => {
loadCookies();
loadYoutubeSettings();
loadRcloneStatus();
});
function formatDate(ts) {
@@ -236,6 +377,135 @@
</div>
{:else if activeTab === "general"}
<div class="card muted">Genel ayarlar burada yer alacak.</div>
{:else if activeTab === "rclone"}
<div class="card">
<div class="card-header">
<div class="title">
<i class="fa-solid fa-cloud"></i>
<span>Google Drive (Rclone)</span>
</div>
</div>
<div class="field inline compact left-align">
<label class="checkbox-row">
<input
type="checkbox"
bind:checked={rcloneAutoMove}
disabled={rcloneLoading || rcloneSaving}
/>
<span>İndirince otomatik taşı</span>
</label>
<label class="checkbox-row">
<input
type="checkbox"
bind:checked={rcloneAutoMount}
disabled={rcloneLoading || rcloneSaving}
/>
<span>Başlangıçta otomatik mount</span>
</label>
</div>
<div class="field inline compact left-align">
<div class="inline-field">
<label for="rclone-remote">Remote adı</label>
<input
id="rclone-remote"
type="text"
bind:value={rcloneRemoteName}
disabled={rcloneLoading || rcloneSaving}
/>
</div>
<div class="inline-field">
<label for="rclone-path">Drive klasörü</label>
<input
id="rclone-path"
type="text"
bind:value={rcloneRemotePath}
disabled={rcloneLoading || rcloneSaving}
/>
</div>
</div>
<div class="field">
<label for="rclone-mount">Mount dizini</label>
<input
id="rclone-mount"
type="text"
bind:value={rcloneMountDir}
disabled={rcloneLoading || rcloneSaving}
/>
</div>
<div class="actions">
<button class="btn" on:click={loadRcloneStatus} disabled={rcloneLoading || rcloneSaving}>
<i class="fa-solid fa-rotate"></i> Yenile
</button>
<button class="btn primary" on:click={saveRcloneSettings} disabled={rcloneLoading || rcloneSaving}>
<i class="fa-solid fa-floppy-disk"></i> Kaydet
</button>
</div>
<div class="field">
<label>Yetkilendirme</label>
<div class="actions">
<button class="btn" on:click={requestRcloneAuthUrl}>
<i class="fa-solid fa-link"></i> Auth URL al
</button>
</div>
{#if rcloneAuthUrl}
<input type="text" readonly value={rcloneAuthUrl} />
{/if}
</div>
<div class="field">
<label for="rclone-client-id">Client ID (opsiyonel)</label>
<input id="rclone-client-id" type="text" bind:value={rcloneClientId} />
</div>
<div class="field">
<label for="rclone-client-secret">Client Secret (opsiyonel)</label>
<input id="rclone-client-secret" type="text" bind:value={rcloneClientSecret} />
</div>
<div class="field">
<label for="rclone-token">Token</label>
<textarea id="rclone-token" bind:value={rcloneToken} placeholder="rclone authorize çıktısındaki token JSON'u"></textarea>
</div>
<div class="actions">
<button class="btn" on:click={applyRcloneToken}>
<i class="fa-solid fa-key"></i> Token Kaydet
</button>
<button class="btn" on:click={startRcloneMount}>
<i class="fa-solid fa-play"></i> Mount Başlat
</button>
<button class="btn" on:click={stopRcloneMount}>
<i class="fa-solid fa-stop"></i> Mount Durdur
</button>
</div>
{#if rcloneStatus}
<div class="card muted" style="margin-top:10px;">
<div>Enabled: {rcloneStatus.enabled ? "Evet" : "Hayır"}</div>
<div>Mounted: {rcloneStatus.mounted ? "Evet" : "Hayır"}</div>
<div>Remote: {rcloneStatus.remoteConfigured ? "Hazır" : "Eksik"}</div>
{#if rcloneStatus.lastError}
<div>Son hata: {rcloneStatus.lastError}</div>
{/if}
</div>
{/if}
{#if error}
<div class="alert error" style="margin-top:10px;">
<i class="fa-solid fa-circle-exclamation"></i>
{error}
</div>
{/if}
{#if success}
<div class="alert success" style="margin-top:10px;">
<i class="fa-solid fa-circle-check"></i>
{success}
</div>
{/if}
</div>
{:else if activeTab === "advanced"}
<div class="card muted">Gelişmiş ayarlar burada yer alacak.</div>
{/if}

View File

@@ -9,6 +9,8 @@
let totalDownloaded = 0;
let totalDownloadSpeed = 0;
let pollTimer;
let moveToGdriveDefault = false;
let loadingRcloneStatus = false;
// Modal / player state
let showModal = false;
@@ -79,11 +81,28 @@
updateTransferStats();
}
async function loadRcloneStatus() {
loadingRcloneStatus = true;
try {
const resp = await apiFetch("/api/rclone/status");
if (!resp.ok) return;
const data = await resp.json().catch(() => ({}));
if (typeof data?.autoMove === "boolean") {
moveToGdriveDefault = data.autoMove;
}
} catch (err) {
console.warn("Rclone durumu alınamadı:", err);
} finally {
loadingRcloneStatus = false;
}
}
async function upload(e) {
const f = e.target.files?.[0];
if (!f) return;
const fd = new FormData();
fd.append("torrent", f);
fd.append("moveToGdrive", moveToGdriveDefault ? "1" : "0");
await apiFetch("/api/transfer", { method: "POST", body: fd }); // ✅
await list();
}
@@ -132,7 +151,7 @@
await apiFetch("/api/transfer", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ magnet: input })
body: JSON.stringify({ magnet: input, moveToGdrive: moveToGdriveDefault })
});
await list();
return;
@@ -142,7 +161,7 @@
const resp = await apiFetch("/api/youtube/download", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: normalizedYoutube })
body: JSON.stringify({ url: normalizedYoutube, moveToGdrive: moveToGdriveDefault })
});
if (!resp.ok) {
const data = await resp.json().catch(() => null);
@@ -157,7 +176,7 @@
const resp = await apiFetch("/api/mailru/download", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: normalizedMailRu })
body: JSON.stringify({ url: normalizedMailRu, moveToGdrive: moveToGdriveDefault })
});
if (!resp.ok) {
const data = await resp.json().catch(() => null);
@@ -369,6 +388,18 @@
}
}
async function toggleTransferGdrive(infoHash, enabled) {
try {
await apiFetch(`/api/transfer/${infoHash}/gdrive`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: enabled ? "1" : "0" })
});
} catch (err) {
console.warn("GDrive toggle hatası:", err);
}
}
function streamURL(hash, index = 0) {
const base = `${API}/stream/${hash}?index=${index}`;
return withToken(base);
@@ -581,6 +612,7 @@
for (const file of torrentsToUpload) {
const fd = new FormData();
fd.append("torrent", file);
fd.append("moveToGdrive", moveToGdriveDefault ? "1" : "0");
await apiFetch("/api/transfer", { method: "POST", body: fd });
}
@@ -603,6 +635,7 @@
onMount(() => {
list(); // 🔒 token'lı liste çekimi
wsConnect(); // 🔒 token'lı WebSocket
loadRcloneStatus();
addGlobalDragListeners();
const slider = document.querySelector(".volume-slider");
if (slider) {
@@ -642,6 +675,14 @@
<label class="btn-primary" on:click={handleUrlInput}>
<i class="fa-solid fa-magnet btn-icon"></i> ADD URL
</label>
<label class="gdrive-toggle">
<input
type="checkbox"
bind:checked={moveToGdriveDefault}
disabled={loadingRcloneStatus}
/>
<span>GDrive'a taşı</span>
</label>
</div>
<div style="display:flex; gap:10px;" title="Total Transfer Speed">
<div class="transfer-info-box">
@@ -734,6 +775,15 @@
{/if}
</div>
<div style="display:flex; gap:5px;">
<label class="gdrive-toggle compact" on:click|stopPropagation>
<input
type="checkbox"
checked={t.moveToGdrive}
on:change={(e) =>
toggleTransferGdrive(t.infoHash, e.target.checked)}
/>
<span>GDrive</span>
</label>
{#if t.type === "torrent" || !t.type}
<button
class="toggle-btn"
@@ -815,6 +865,12 @@
<div class="progress-text">
{#if t.type === "mailru" && t.status === "awaiting_match"}
Eşleştirme bekleniyor
{:else if (t.progress || 0) >= 1 && t.moveToGdrive && t.moveStatus === "moving"}
GDrive'a Taşınıyor.. • 100.0% • {(t.downloaded / 1e6).toFixed(1)} MB
{:else if (t.progress || 0) >= 1 && t.moveToGdrive && t.moveStatus === "done"}
GDrive ✓ • 100.0% • {(t.downloaded / 1e6).toFixed(1)} MB
{:else if (t.progress || 0) >= 1 && t.moveToGdrive && t.moveStatus === "error"}
GDrive Hata • 100.0% • {(t.downloaded / 1e6).toFixed(1)} MB
{:else if (t.progress || 0) < 1}
{(t.progress * 100).toFixed(1)}% •
{t.downloaded ? (t.downloaded / 1e6).toFixed(1) : 0} MB •
@@ -1502,4 +1558,26 @@
.more-item:hover {
background: #f1f5f9;
}
.gdrive-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: #444;
background: #f7f7f7;
border: 1px solid #e2e2e2;
border-radius: 8px;
padding: 6px 10px;
}
.gdrive-toggle.compact {
padding: 4px 6px;
font-size: 11px;
background: #fff;
}
.gdrive-toggle input {
accent-color: #4caf50;
}
</style>

View File

@@ -7,7 +7,14 @@ services:
volumes:
- ./downloads:/app/server/downloads
- ./cache:/app/server/cache
- ./rclone:/config/rclone
restart: unless-stopped
cap_add:
- SYS_ADMIN
security_opt:
- apparmor:unconfined
devices:
- /dev/fuse:/dev/fuse
# Login credentials for basic auth
environment:
USERNAME: ${USERNAME}
@@ -25,3 +32,12 @@ services:
WEBDAV_PATH: ${WEBDAV_PATH}
WEBDAV_READONLY: ${WEBDAV_READONLY}
WEBDAV_INDEX_TTL: ${WEBDAV_INDEX_TTL}
RCLONE_ENABLED: ${RCLONE_ENABLED}
RCLONE_CONFIG_PATH: ${RCLONE_CONFIG_PATH}
RCLONE_MOUNT_DIR: ${RCLONE_MOUNT_DIR}
RCLONE_REMOTE_NAME: ${RCLONE_REMOTE_NAME}
RCLONE_REMOTE_PATH: ${RCLONE_REMOTE_PATH}
RCLONE_POLL_INTERVAL: ${RCLONE_POLL_INTERVAL}
RCLONE_DIR_CACHE_TIME: ${RCLONE_DIR_CACHE_TIME}
RCLONE_VFS_CACHE_MODE: ${RCLONE_VFS_CACHE_MODE}
RCLONE_VFS_CACHE_DIR: ${RCLONE_VFS_CACHE_DIR}

File diff suppressed because it is too large Load Diff