Compare commits

25 Commits

Author SHA1 Message Date
0255a44120 fix(client-server): rclone ve miniplayer düzeltmeleri 2026-02-04 00:36:46 +03:00
42447a63f3 refactor(ui): mini player kapak (video play alanı) düzenlemeleri 2026-02-03 18:45:56 +03:00
f9c0cc15f0 refactor(ui): mini player kapak (video play alanı) düzenlemeleri 2026-02-03 18:40:16 +03:00
90f6291e14 refactor(ui): mini player düzenlemesi
Kullanıcı meta verilerini kapağın altına taşıyarak mini player'ın düzenini yeniden düzenledi. Paylaş ve beğen butonlarını kaldırdı.
2026-02-03 17:20:58 +03:00
7753b68578 feat(ui): music sayfasında mini player'ı gizle _3 2026-02-03 16:56:11 +03:00
6fc2d2c45f feat(ui): music sayfasında mini player'ı gizle _2 2026-02-03 16:53:15 +03:00
e66ace9ed5 feat(ui): music sayfasında mini player'ı gizle 2026-02-03 16:49:01 +03:00
1774f681be fix(server): youtube müzik videolarının stream etme sorununu çöz
Küçük resim ve video akışı için dosya yolları çözülürken
`resolveStoragePath` fonksiyonu kullanılmaya başlandı. Bu sayede
dosyalar standart indirme dizininde veya rclone ile yönetilen
bir konumda olsa bile doğru şekilde erişilebilir.
2026-02-03 16:39:49 +03:00
2c06867c75 style(ui): toats güncelleme 2026-02-03 13:15:30 +03:00
2d06056c1a style(ui): sekmeler için sınıf atamasını güncelle 2026-02-03 13:10:20 +03:00
92cdb4ee61 refactor(ui): toast bileşeninde reactive store kullan 2026-02-03 12:39:47 +03:00
d705e37d85 feat(ui): toast bildirim sistemini ekle
Toast bileşeni ve store oluşturuldu. Ayarlar sayfasındaki işlem bildirimleri yeni sistemle gösterilmeye başlandı.
2026-02-03 12:34:49 +03:00
2f3dc72dcc chore(rclone): mailru transfer eşleşme debug logu ekle
MailRu yüklemelerinde transfer eşleşmesi bulunamadığında sorunun
teşhisi için job ve transfer detaylarını içeren uyarı logu eklendi.
2026-02-03 12:03:48 +03:00
a011af7368 fix(rclone): mailru için fileName fallback ekle 2026-02-03 11:53:47 +03:00
90587aa6d6 fix(rclone): avatar dosya yolunu düzelt 2026-02-03 11:33:27 +03:00
c3d38d2e79 fix(rclone): aktarım durum takibini düzelt
Dosya sistemi tabanlı tamamlanma kontrolünü kaldırır.
Aktarım listesinde olmayan "uploading" durumundaki öğeleri "queued"ye
çevirir. Bu sayede polling süreci devam eder ve tamamlanma kararı
aktarım listesinin boşalmasına bırakılır.
2026-02-03 11:23:30 +03:00
2b9c776c8a fix(rclone): taşıma tamamlanma koşullarını düzelt
Taşıma işleminin "done" olarak tamamlanmış sayılması için hedef dizinin
mevcut olması VE kaynak dizinin silinmiş olması şartı getirildi. Ayrıca
"uploading" durumunun "queued" olarak güncellenmesi için hedefin
bulunmaması kontrolü eklendi.
2026-02-03 11:08:39 +03:00
7269f52b0e feat(rclone): rclone yükleme durumunu güncelle
Dosyaların GDrive'a taşınması sırasında yükleme durumunu izler
ve durum güncellemelerini yayınlar.
2026-02-03 10:50:58 +03:00
1a7a8ec66e refactor(ui): mount kontrol arayüzünü yeniden düzenle 2026-02-03 10:25:15 +03:00
1b0662a5ec refactor(rclone): akıllı temizleme özelliğini kaldır
Check-and-clean özelliğini kaldırıp sadece basit cache temizleme
özelliğini korudu. Kullanıcı arayüzünde tek bir "Cache Temizle"
butonu bulunuyor.
2026-02-03 09:26:54 +03:00
8825d0af8d feat(rclone): rclone ayar dosyası yolu desteği ekle 2026-02-03 09:02:14 +03:00
44323275d8 refactor(ui): kullanılmayan closeMenu fonksiyonunu kaldır 2026-02-02 22:54:23 +03:00
20da34beb2 feat(ui): silme işlemini iki aşamalı onay sistemine dönüştür
Tarayıcı doğrulama penceresi yerine inline onay mekanizması eklendi.
Kullanıcı dosya silmek için "Sil" butonuna ilk tıkladığında buton kırmızıya
dönerek "Emin misiniz?" sorusunu gösterir ve ikinci tıklamada silme işlemini
gerçekleştirir. Bu yaklaşım kullanıcı deneyimini iyileştirir ve uygulama
tutarlılığını artırır.
2026-02-02 22:49:26 +03:00
2b5bb86b3e feat(rclone): GDrive dosya silme desteği ekle
Silme API'si artık dosyaların konumunu otomatik olarak tespit edebiliyor
(DOWNLOAD_DIR veya GDRIVE_ROOT). GDrive dosyaları için doğrudan silme
mantığı uygulanırken, Downloads dosyaları için mevcut trash sistemi
korunuyor.
2026-02-02 22:30:38 +03:00
1e4fb38cfb feat(rclone): mount başlatma durumu ve log sunumunu geliştir
Mount işlemi için "Başlatılıyor" ara durumu eklenerek kullanıcı geri bildirimi
iyileştirildi. Sunucu tarafında log seviyeleri ayrıştırılarak gerçek hatalar
bilgi mesajlarından ayırt edildi ve arayüze yansıtıldı.
2026-02-02 22:14:41 +03:00
10 changed files with 769 additions and 254 deletions

View File

@@ -97,3 +97,5 @@ RCLONE_AUTO_RESTART=1
RCLONE_AUTO_RESTART_MAX_RETRIES=5 RCLONE_AUTO_RESTART_MAX_RETRIES=5
# Yeniden başlatma arasındaki bekleme süresi (milisaniye) # Yeniden başlatma arasındaki bekleme süresi (milisaniye)
RCLONE_AUTO_RESTART_DELAY_MS=5000 RCLONE_AUTO_RESTART_DELAY_MS=5000
# Rclone settings dosya yolu (container içinde)
RCLONE_SETTINGS_PATH=/app/server/cache/rclone.json

View File

@@ -1,9 +1,11 @@
<script> <script>
import { Router, Route } from "svelte-routing"; import { Router, Route } from "svelte-routing";
import { useLocation } from "svelte-routing";
import { onMount } from "svelte"; import { onMount } from "svelte";
import Sidebar from "./components/Sidebar.svelte"; import Sidebar from "./components/Sidebar.svelte";
import Topbar from "./components/Topbar.svelte"; import Topbar from "./components/Topbar.svelte";
import MiniPlayer from "./components/MiniPlayer.svelte"; import MiniPlayer from "./components/MiniPlayer.svelte";
import Toast from "./components/Toast.svelte";
import Files from "./routes/Files.svelte"; import Files from "./routes/Files.svelte";
import Transfers from "./routes/Transfers.svelte"; import Transfers from "./routes/Transfers.svelte";
import Trash from "./routes/Trash.svelte"; import Trash from "./routes/Trash.svelte";
@@ -27,6 +29,8 @@
let menuOpen = false; let menuOpen = false;
let wsCounts; let wsCounts;
let refreshTimer = null; let refreshTimer = null;
const location = useLocation();
$: isMusicRoute = ($location?.pathname || "").startsWith("/music");
const scheduleMediaRefresh = () => { const scheduleMediaRefresh = () => {
if (refreshTimer) return; if (refreshTimer) return;
@@ -162,7 +166,10 @@
<Route path="/trash" component={Trash} /> <Route path="/trash" component={Trash} />
</div> </div>
<MiniPlayer /> {#if !isMusicRoute}
<MiniPlayer />
{/if}
<Toast />
<!-- Sidebar dışına tıklayınca kapanma --> <!-- Sidebar dışına tıklayınca kapanma -->
{#if menuOpen} {#if menuOpen}

View File

@@ -1,9 +1,25 @@
<script> <script>
import { onMount, afterUpdate, tick } from "svelte";
import { musicPlayer, togglePlay, playNext, playPrevious, stopPlayback } from "../stores/musicPlayerStore.js"; import { musicPlayer, togglePlay, playNext, playPrevious, stopPlayback } from "../stores/musicPlayerStore.js";
import { useLocation } from "svelte-routing";
import { API, withToken } from "../utils/api.js"; import { API, withToken } from "../utils/api.js";
import { cleanFileName } from "../utils/filename.js"; import { cleanFileName } from "../utils/filename.js";
let videoEl = null; let videoEl = null;
let titleWrap = null;
let titleInner = null;
let marqueeShift = "0px";
let marqueeDuration = "0s";
let marqueeEnabled = false;
let dragX = 0;
let dragY = 0;
let dragging = false;
let dragStartX = 0;
let dragStartY = 0;
let originX = 0;
let originY = 0;
const location = useLocation();
$: isMusicRoute = ($location?.pathname || "").startsWith("/music");
function thumbnailURL(item) { function thumbnailURL(item) {
if (!item?.thumbnail) return null; if (!item?.thumbnail) return null;
@@ -36,39 +52,97 @@
return withToken(`${API}/stream/${item.infoHash}?index=${index}`); return withToken(`${API}/stream/${item.infoHash}?index=${index}`);
} }
const HARD_SYNC_THRESHOLD = 0.2;
const SOFT_SYNC_THRESHOLD = 0.05;
$: if (videoEl && $musicPlayer.currentTrack && $musicPlayer.isPlaying) { $: if (videoEl && $musicPlayer.currentTrack && $musicPlayer.isPlaying) {
const target = $musicPlayer.currentTime || 0; const target = $musicPlayer.currentTime || 0;
if (Number.isFinite(target) && Math.abs(videoEl.currentTime - target) > 0.6) { if (!Number.isFinite(target)) {
videoEl.currentTime = target; videoEl.playbackRate = 1;
} else {
const delta = target - (videoEl.currentTime || 0);
if (Math.abs(delta) > HARD_SYNC_THRESHOLD) {
videoEl.currentTime = target;
} else if (Math.abs(delta) > SOFT_SYNC_THRESHOLD) {
videoEl.playbackRate = delta > 0 ? 1.02 : 0.98;
} else {
videoEl.playbackRate = 1;
}
} }
} }
async function updateMarquee() {
if (!titleWrap || !titleInner) return;
await tick();
const wrapW = titleWrap.clientWidth || 0;
const textW = titleInner.scrollWidth || 0;
if (textW > wrapW + 2) {
const shift = Math.max(textW - wrapW, 0);
marqueeShift = `${shift}px`;
marqueeDuration = `${Math.max(8, shift / 25)}s`;
marqueeEnabled = true;
} else {
marqueeShift = "0px";
marqueeDuration = "0s";
marqueeEnabled = false;
}
}
onMount(() => {
updateMarquee();
});
afterUpdate(() => {
updateMarquee();
});
$: if ($musicPlayer.currentTrack?.id) {
setTimeout(updateMarquee, 0);
}
$: if (videoEl && $musicPlayer.isPlaying) { $: if (videoEl && $musicPlayer.isPlaying) {
videoEl.play().catch(() => undefined); videoEl.play().catch(() => undefined);
} else if (videoEl) { } else if (videoEl) {
videoEl.pause(); videoEl.pause();
} }
function shouldIgnoreDrag(target) {
if (!target) return false;
return Boolean(target.closest("button, a, input, textarea, select, [data-no-drag]"));
}
function startDrag(event) {
if (shouldIgnoreDrag(event.target)) return;
dragging = true;
dragStartX = event.clientX;
dragStartY = event.clientY;
originX = dragX;
originY = dragY;
window.addEventListener("pointermove", onDrag);
window.addEventListener("pointerup", endDrag, { once: true });
}
function onDrag(event) {
if (!dragging) return;
dragX = originX + (event.clientX - dragStartX);
dragY = originY + (event.clientY - dragStartY);
}
function endDrag() {
dragging = false;
window.removeEventListener("pointermove", onDrag);
}
</script> </script>
{#if $musicPlayer.currentTrack} {#if $musicPlayer.currentTrack && !isMusicRoute}
<div class="mini-player" aria-label="Mini music player"> <div
<div class="mini-top"> class="mini-player {dragging ? 'dragging' : ''}"
<div class="mini-user-meta"> aria-label="Mini music player"
<div class="mini-user-name"> on:pointerdown={startDrag}
{cleanFileName($musicPlayer.currentTrack.title)} style="transform: translate({dragX}px, {dragY}px);"
</div> >
<div class="mini-user-handle">{sourceLabel($musicPlayer.currentTrack)}</div> <div class="mini-top"></div>
</div>
<div class="mini-actions">
<button class="mini-icon-btn" title="Paylaş">
<i class="fa-solid fa-arrow-up-from-bracket"></i>
</button>
<button class="mini-icon-btn" title="Beğen">
<i class="fa-regular fa-heart"></i>
</button>
</div>
</div>
<div class="mini-cover"> <div class="mini-cover">
{#if $musicPlayer.isPlaying && videoStreamURL($musicPlayer.currentTrack)} {#if $musicPlayer.isPlaying && videoStreamURL($musicPlayer.currentTrack)}
@@ -90,6 +164,20 @@
</div> </div>
{/if} {/if}
</div> </div>
<div class="mini-meta">
<div class="mini-user-name" bind:this={titleWrap}>
{#key $musicPlayer.currentTrack?.id}
<span
class="marquee {marqueeEnabled ? 'active' : ''}"
bind:this={titleInner}
style="--marquee-shift: {marqueeShift}; --marquee-duration: {marqueeDuration};"
>
{cleanFileName($musicPlayer.currentTrack.title)}
</span>
{/key}
</div>
<div class="mini-user-handle">{sourceLabel($musicPlayer.currentTrack)}</div>
</div>
<div class="mini-progress"> <div class="mini-progress">
<span class="mini-time">{formatTime($musicPlayer.currentTime)}</span> <span class="mini-time">{formatTime($musicPlayer.currentTime)}</span>
@@ -105,20 +193,20 @@
</div> </div>
<div class="mini-controls"> <div class="mini-controls">
<button class="mini-btn" on:click={playPrevious} title="Önceki"> <button class="mini-btn" on:click={playPrevious} title="Önceki" data-no-drag>
<i class="fa-solid fa-backward-step"></i> <i class="fa-solid fa-backward-step"></i>
</button> </button>
<button class="mini-btn main" on:click={togglePlay} title="Oynat/Durdur"> <button class="mini-btn main" on:click={togglePlay} title="Oynat/Durdur" data-no-drag>
{#if $musicPlayer.isPlaying} {#if $musicPlayer.isPlaying}
<i class="fa-solid fa-pause"></i> <i class="fa-solid fa-pause"></i>
{:else} {:else}
<i class="fa-solid fa-play"></i> <i class="fa-solid fa-play"></i>
{/if} {/if}
</button> </button>
<button class="mini-btn" on:click={playNext} title="Sonraki"> <button class="mini-btn" on:click={playNext} title="Sonraki" data-no-drag>
<i class="fa-solid fa-forward-step"></i> <i class="fa-solid fa-forward-step"></i>
</button> </button>
<button class="mini-btn ghost" on:click={stopPlayback} title="Kapat"> <button class="mini-btn ghost" on:click={stopPlayback} title="Kapat" data-no-drag>
<i class="fa-solid fa-xmark"></i> <i class="fa-solid fa-xmark"></i>
</button> </button>
</div> </div>
@@ -139,18 +227,21 @@
color: #f6f6f6; color: #f6f6f6;
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
z-index: 50; z-index: 50;
cursor: grab;
touch-action: none;
}
.mini-player.dragging {
cursor: grabbing;
} }
.mini-top { .mini-top {
display: flex; min-height: 0;
align-items: center;
justify-content: space-between;
gap: 10px;
} }
.mini-user-meta { .mini-meta {
min-width: 0;
text-align: left; text-align: left;
margin: 0 4px 8px -4px;
} }
.mini-user-name { .mini-user-name {
@@ -159,6 +250,19 @@
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
display: block;
position: relative;
max-width: 100%;
min-width: 0;
}
.mini-user-name .marquee {
display: inline-block;
will-change: transform;
}
.mini-user-name .marquee.active {
animation: mini-marquee var(--marquee-duration) linear infinite;
} }
.mini-user-handle { .mini-user-handle {
@@ -166,39 +270,55 @@
color: rgba(255, 255, 255, 0.6); color: rgba(255, 255, 255, 0.6);
} }
.mini-actions { @keyframes mini-marquee {
display: flex; 0% {
gap: 8px; transform: translateX(0);
} opacity: 1;
}
.mini-icon-btn { 10% {
width: 32px; transform: translateX(0);
height: 32px; opacity: 1;
border-radius: 12px; }
border: none; 60% {
background: rgba(255, 255, 255, 0.12); transform: translateX(calc(-1 * var(--marquee-shift)));
color: #f6f6f6; opacity: 1;
cursor: pointer; }
80% {
transform: translateX(calc(-1 * var(--marquee-shift)));
opacity: 1;
}
84% {
transform: translateX(calc(-1 * var(--marquee-shift)));
opacity: 0;
}
86% {
transform: translateX(0);
opacity: 0;
}
100% {
transform: translateX(0);
opacity: 1;
}
} }
.mini-cover { .mini-cover {
margin: 14px auto 12px; margin: -16px -16px 12px;
width: 160px; width: calc(100% + 32px);
height: 160px; aspect-ratio: 1 / 1;
border-radius: 20px; border-radius: 26px 26px 0 0;
overflow: hidden; overflow: hidden;
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
box-shadow: 0 18px 30px rgba(0, 0, 0, 0.35); box-shadow: 0 18px 30px rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.12); border: none;
display: grid; display: block;
place-items: center;
transform: translateY(-6px);
} }
.mini-cover video,
.mini-cover img { .mini-cover img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
display: block;
} }
.mini-cover video { .mini-cover video {

View File

@@ -0,0 +1,76 @@
<script>
import { toast } from '../stores/toastStore.js';
import { fade } from 'svelte/transition';
const icons = {
success: 'fa-solid fa-circle-check',
error: 'fa-solid fa-circle-exclamation',
info: 'fa-solid fa-circle-info'
};
</script>
{#if $toast.visible && $toast.message}
<div class="toast-container" transition:fade={{ duration: 200 }}>
<div class="toast {$toast.type}">
<i class="{icons[$toast.type] || icons.info}"></i>
<span>{$toast.message}</span>
</div>
</div>
{/if}
<style>
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9999;
max-width: min(350px, 90vw);
}
.toast {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 18px;
border-radius: 10px;
font-size: 14px;
font-weight: 500;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.toast i {
font-size: 18px;
flex-shrink: 0;
}
.toast.success {
background: linear-gradient(135deg, rgba(0, 200, 83, 0.95), rgba(0, 150, 63, 0.95));
color: white;
}
.toast.error {
background: linear-gradient(135deg, rgba(220, 38, 38, 0.95), rgba(185, 28, 28, 0.95));
color: white;
}
.toast.info {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.95), rgba(37, 99, 235, 0.95));
color: white;
}
@media (max-width: 640px) {
.toast-container {
bottom: 16px;
right: 16px;
left: 16px;
max-width: none;
}
.toast {
padding: 12px 16px;
font-size: 13px;
}
}
</style>

View File

@@ -468,6 +468,7 @@
let pendingPlayTarget = null; let pendingPlayTarget = null;
let activeMenu = null; // Aktif menü öğesi let activeMenu = null; // Aktif menü öğesi
let menuPosition = { top: 0, left: 0 }; // Menü pozisyonu let menuPosition = { top: 0, left: 0 }; // Menü pozisyonu
let deleteConfirmPending = false; // Silme onayı beklemede mi
let showMatchModal = false; let showMatchModal = false;
let matchingFile = null; let matchingFile = null;
let matchTitle = ""; let matchTitle = "";
@@ -625,6 +626,25 @@
alert(err?.message || "GDrive taşıma başarısız oldu."); alert(err?.message || "GDrive taşıma başarısız oldu.");
} }
} }
async function setCategory(entry, category) {
if (!entry?.name) return;
try {
const resp = await apiFetch("/api/file/category", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: entry.name, category })
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.ok) {
alert(data?.error || "Kategori güncellenemedi.");
return;
}
await loadFiles();
} catch (err) {
alert(err?.message || "Kategori güncellenemedi.");
}
}
function formatSize(bytes) { function formatSize(bytes) {
if (!bytes) return "0 MB"; if (!bytes) return "0 MB";
if (bytes < 1e6) return (bytes / 1e3).toFixed(1) + " KB"; if (bytes < 1e6) return (bytes / 1e3).toFixed(1) + " KB";
@@ -1283,10 +1303,6 @@
}); });
} }
function closeMenu() {
activeMenu = null;
}
async function downloadFile(file) { async function downloadFile(file) {
if (!file || file.isDirectory) { if (!file || file.isDirectory) {
if (file?.isDirectory) navigateToPath(file.displayPath); if (file?.isDirectory) navigateToPath(file.displayPath);
@@ -1304,45 +1320,55 @@
} }
function matchFile(file) { function matchFile(file) {
if (!file || file.isDirectory) { try {
closeMenu(); if (!file || file.isDirectory) {
return; closeMenu();
} return;
// Dosya adını al (path'in son kısmı)
const fileName = file.name.split('/').pop();
// Önce dizi kontrolü yap (SxxExx formatı)
const seriesMatch = fileName.match(/S(\d{1,2})E(\d{1,2})/i);
if (seriesMatch) {
matchType = "series";
const { title, year } = extractTitleAndYear(fileName);
matchTitle = title || fileName;
matchYear = year ? String(year) : "";
} else {
// Film kontrolü (yıl bilgisi)
const { title, year } = extractTitleAndYear(fileName);
if (year && year >= 1900 && year <= 2099) {
matchType = "movie";
matchTitle = title || fileName;
matchYear = String(year);
} else {
// Varsayılan olarak film kabul et
matchType = "movie";
matchTitle = title || fileName;
matchYear = "";
} }
const rawName = file?.name || file?.displayName || "";
const fileName = rawName.split("/").pop();
if (!fileName) {
showToast("Eşleştirilecek dosya adı bulunamadı.", "error");
closeMenu();
return;
}
// Önce dizi kontrolü yap (SxxExx formatı)
const seriesMatch = fileName.match(/S(\d{1,2})E(\d{1,2})/i);
if (seriesMatch) {
matchType = "series";
const { title, year } = extractTitleAndYear(fileName);
matchTitle = title || fileName;
matchYear = year ? String(year) : "";
} else {
// Film kontrolü (yıl bilgisi)
const { title, year } = extractTitleAndYear(fileName);
if (year && year >= 1900 && year <= 2099) {
matchType = "movie";
matchTitle = title || fileName;
matchYear = String(year);
} else {
// Varsayılan olarak film kabul et
matchType = "movie";
matchTitle = title || fileName;
matchYear = "";
}
}
matchingFile = file;
showMatchModal = true;
closeMenu();
// Modal açıldıktan sonra otomatik arama yap
tick().then(() => {
searchMetadata();
});
} catch (err) {
showToast("Eşleştirme penceresi açılamadı.", "error");
closeMenu();
} }
matchingFile = file;
showMatchModal = true;
closeMenu();
// Modal açıldıktan sonra otomatik arama yap
tick().then(() => {
searchMetadata();
});
} }
function closeMatchModal() { function closeMatchModal() {
@@ -1373,6 +1399,10 @@
params.set("year", matchYear); params.set("year", matchYear);
} }
const token = localStorage.getItem("token");
if (token) {
params.set("token", token);
}
const response = await apiFetch(`/api/search/metadata?${params}`); const response = await apiFetch(`/api/search/metadata?${params}`);
if (response.ok) { if (response.ok) {
@@ -1444,48 +1474,51 @@
async function deleteFile(item) { async function deleteFile(item) {
if (!item) return; if (!item) return;
const target = resolveDeletionTargets(item);
if (!target) { // Eğer onay beklemedeyse, silme işlemini gerçekleştir
if (deleteConfirmPending) {
const target = resolveDeletionTargets(item);
if (!target) {
closeMenu();
return;
}
const result = await performDeletion(target);
deleteConfirmPending = false; // Reset flag
if (!result.ok) {
alert("Silme hatası: " + (result.error || "Bilinmeyen hata"));
closeMenu();
return;
}
if (item.isDirectory) {
const displayKey = normalizePath(
item.displayPath ||
(item.name?.startsWith("dir:") ? item.name.slice(4) : ""),
);
if (displayKey || displayKey === "") {
pendingFolders.delete(displayKey);
}
}
await loadFiles();
await Promise.all([refreshMovieCount(), refreshTvShowCount(), fetchTrashItems()]);
selectedItems = new Set(
[...selectedItems].filter((name) => name !== item.name),
);
closeMenu(); closeMenu();
return; return;
} }
const label = // İlk tıklama - onay moduna geç
target.type === "directory" deleteConfirmPending = true;
? target.label || item.displayName || "Klasör"
: target.label || cleanFileName(item.name);
const message =
target.type === "directory"
? `"${label}" klasörünü silmek istediğine emin misin?`
: `"${label}" dosyasını silmek istediğinizden emin misiniz?`;
if (!confirm(message)) {
closeMenu();
return;
}
const result = await performDeletion(target);
if (!result.ok) {
alert("Silme hatası: " + (result.error || "Bilinmeyen hata"));
closeMenu();
return;
}
if (item.isDirectory) {
const displayKey = normalizePath(
item.displayPath ||
(item.name?.startsWith("dir:") ? item.name.slice(4) : ""),
);
if (displayKey || displayKey === "") {
pendingFolders.delete(displayKey);
}
} }
await loadFiles(); // Menü kapandığında onay durumunu resetle
await Promise.all([refreshMovieCount(), refreshTvShowCount(), fetchTrashItems()]); function closeMenu() {
selectedItems = new Set( activeMenu = null;
[...selectedItems].filter((name) => name !== item.name), deleteConfirmPending = false;
);
closeMenu();
} }
// Klasör oluşturma fonksiyonları // Klasör oluşturma fonksiyonları
@@ -2393,6 +2426,20 @@
<i class="fa-solid fa-wand-magic-sparkles"></i> <i class="fa-solid fa-wand-magic-sparkles"></i>
<span>Eşleştir</span> <span>Eşleştir</span>
</button> </button>
<button
class="menu-item"
on:click|stopPropagation={() => setCategory(activeMenu, "music")}
>
<i class="fa-solid fa-music"></i>
<span>Kategori: Müzik</span>
</button>
<button
class="menu-item"
on:click|stopPropagation={() => setCategory(activeMenu, "auto")}
>
<i class="fa-solid fa-rotate-left"></i>
<span>Kategori: Otomatik</span>
</button>
<div class="menu-divider"></div> <div class="menu-divider"></div>
<button <button
class="menu-item" class="menu-item"
@@ -2419,11 +2466,11 @@
</button> </button>
<div class="menu-divider"></div> <div class="menu-divider"></div>
<button <button
class="menu-item delete" class="menu-item delete {deleteConfirmPending ? 'confirming' : ''}"
on:click|stopPropagation={() => deleteFile(activeMenu)} on:click|stopPropagation={() => deleteFile(activeMenu)}
> >
<i class="fa-solid fa-trash"></i> <i class="fa-solid fa-trash"></i>
<span>Sil</span> <span>{deleteConfirmPending ? 'Emin misiniz?' : 'Sil'}</span>
</button> </button>
</div> </div>
{/if} {/if}
@@ -3245,12 +3292,14 @@
.name { .name {
font-weight: 600; font-weight: 600;
font-size: 13px; font-size: 13px;
text-align: center;
overflow: hidden; overflow: hidden;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
line-height: 1.3; line-height: 1.3;
max-height: calc(1.3em * 2); max-height: calc(1.3em * 2);
min-height: calc(1.3em * 2);
text-overflow: ellipsis; text-overflow: ellipsis;
word-break: break-word; word-break: break-word;
} }
@@ -3821,7 +3870,8 @@
.folder-info { .folder-info {
margin-top: 4px; margin-top: 4px;
width: 100%; width: 100%;
text-align: center; text-align: left;
padding: 0 8px;
flex-shrink: 0; flex-shrink: 0;
} }
.folder-name { .folder-name {
@@ -3833,8 +3883,9 @@
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
text-align: center; text-align: left;
max-height: calc(1.25em * 2); max-height: calc(1.25em * 2);
min-height: calc(1.25em * 2);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
@@ -3979,7 +4030,17 @@
.menu-item.delete { .menu-item.delete {
color: #e53935; color: #e53935;
} }
.menu-item.delete.confirming {
color: #fff;
background-color: #e53935;
font-weight: 600;
}
.menu-item.delete.confirming:hover {
background-color: #c62828;
}
.menu-item.delete:hover { .menu-item.delete:hover {
background-color: #ffebee; background-color: #ffebee;
} }

View File

@@ -1,12 +1,13 @@
<script> <script>
import { onMount } from "svelte"; import { onMount } from "svelte";
import { apiFetch } from "../utils/api.js"; import { apiFetch } from "../utils/api.js";
import { showToast } from "../stores/toastStore.js";
const tabs = [ const tabs = [
{ id: "general", label: "General", icon: "fa-solid fa-sliders" }, { id: "general", label: "General", icon: "sliders" },
{ id: "youtube", label: "YouTube", icon: "fa-brands fa-youtube" }, { id: "youtube", label: "YouTube", icon: "youtube" },
{ id: "rclone", label: "Rclone", icon: "fa-solid fa-cloud" }, { id: "rclone", label: "Rclone", icon: "cloud" },
{ id: "advanced", label: "Advanced", icon: "fa-solid fa-gear" } { id: "advanced", label: "Advanced", icon: "gear" }
]; ];
let activeTab = "youtube"; let activeTab = "youtube";
@@ -20,7 +21,6 @@
let onlyAudio = false; let onlyAudio = false;
const resolutionOptions = ["1080p", "720p", "480p", "360p", "240p", "144p"]; const resolutionOptions = ["1080p", "720p", "480p", "360p", "240p", "144p"];
let error = null; let error = null;
let success = null;
let rcloneStatus = null; let rcloneStatus = null;
let rcloneLoading = false; let rcloneLoading = false;
@@ -34,7 +34,6 @@
async function loadCookies() { async function loadCookies() {
loadingCookies = true; loadingCookies = true;
error = null; error = null;
success = null;
try { try {
const resp = await apiFetch("/api/youtube/cookies"); const resp = await apiFetch("/api/youtube/cookies");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
@@ -51,7 +50,6 @@
async function saveCookies() { async function saveCookies() {
if (savingCookies) return; if (savingCookies) return;
error = null; error = null;
success = null;
savingCookies = true; savingCookies = true;
try { try {
const payload = { const payload = {
@@ -71,7 +69,7 @@
throw new Error(data?.error || `HTTP ${resp.status}`); throw new Error(data?.error || `HTTP ${resp.status}`);
} }
cookiesUpdatedAt = data.updatedAt || Date.now(); cookiesUpdatedAt = data.updatedAt || Date.now();
success = "Cookies kaydedildi."; showToast("Cookies kaydedildi.", "success");
} catch (err) { } catch (err) {
error = err?.message || "Cookies kaydedilemedi."; error = err?.message || "Cookies kaydedilemedi.";
} finally { } finally {
@@ -99,7 +97,6 @@
if (savingYtSettings) return; if (savingYtSettings) return;
savingYtSettings = true; savingYtSettings = true;
error = null; error = null;
success = null;
try { try {
const resp = await apiFetch("/api/youtube/settings", { const resp = await apiFetch("/api/youtube/settings", {
method: "POST", method: "POST",
@@ -113,7 +110,7 @@
if (!resp.ok || !data?.ok) { if (!resp.ok || !data?.ok) {
throw new Error(data?.error || `HTTP ${resp.status}`); throw new Error(data?.error || `HTTP ${resp.status}`);
} }
success = "YouTube indirme ayarları kaydedildi."; showToast("YouTube indirme ayarları kaydedildi.", "success");
} catch (err) { } catch (err) {
error = err?.message || "YouTube ayarları kaydedilemedi."; error = err?.message || "YouTube ayarları kaydedilemedi.";
} finally { } finally {
@@ -156,7 +153,6 @@
if (rcloneSaving) return; if (rcloneSaving) return;
rcloneSaving = true; rcloneSaving = true;
error = null; error = null;
success = null;
try { try {
const resp = await apiFetch("/api/rclone/settings", { const resp = await apiFetch("/api/rclone/settings", {
method: "POST", method: "POST",
@@ -180,7 +176,7 @@
if (!confResp.ok || !confData?.ok) { if (!confResp.ok || !confData?.ok) {
throw new Error(confData?.error || `HTTP ${confResp.status}`); throw new Error(confData?.error || `HTTP ${confResp.status}`);
} }
success = "Rclone ayarları kaydedildi."; showToast("Rclone ayarları kaydedildi.", "success");
await loadRcloneStatus(); await loadRcloneStatus();
} catch (err) { } catch (err) {
error = err?.message || "Rclone ayarları kaydedilemedi."; error = err?.message || "Rclone ayarları kaydedilemedi.";
@@ -191,14 +187,30 @@
async function startRcloneMount() { async function startRcloneMount() {
error = null; error = null;
success = null;
try { try {
const resp = await apiFetch("/api/rclone/mount", { method: "POST" }); const resp = await apiFetch("/api/rclone/mount", { method: "POST" });
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.ok) { if (!resp.ok || !data?.ok) {
throw new Error(data?.error || `HTTP ${resp.status}`); throw new Error(data?.error || `HTTP ${resp.status}`);
} }
success = "Rclone mount başlatıldı.";
// Mount başlatıldı, birkaç saniye bekleyip tekrar kontrol et
showToast("Rclone mount başlatılıyor...", "info");
// 2 saniye sonra status güncelle
setTimeout(async () => {
await loadRcloneStatus();
// Status yüklendikten sonra mesajı güncelle
if (rcloneStatus?.mounted) {
showToast("Rclone mount başarıyla başlatıldı.", "success");
} else if (rcloneStatus?.running) {
showToast("Rclone mount başlatıldı, mount tamamlanıyor...", "info");
} else {
error = "Rclone mount başlatılamadı.";
}
}, 2000);
// İlk status güncellemesi
await loadRcloneStatus(); await loadRcloneStatus();
} catch (err) { } catch (err) {
error = err?.message || "Rclone mount başlatılamadı."; error = err?.message || "Rclone mount başlatılamadı.";
@@ -207,14 +219,13 @@
async function stopRcloneMount() { async function stopRcloneMount() {
error = null; error = null;
success = null;
try { try {
const resp = await apiFetch("/api/rclone/unmount", { method: "POST" }); const resp = await apiFetch("/api/rclone/unmount", { method: "POST" });
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.ok) { if (!resp.ok || !data?.ok) {
throw new Error(data?.error || `HTTP ${resp.status}`); throw new Error(data?.error || `HTTP ${resp.status}`);
} }
success = "Rclone mount durduruldu."; showToast("Rclone mount durduruldu.", "success");
await loadRcloneStatus(); await loadRcloneStatus();
} catch (err) { } catch (err) {
error = err?.message || "Rclone mount durdurulamadı."; error = err?.message || "Rclone mount durdurulamadı.";
@@ -223,35 +234,17 @@
async function cleanRcloneCache() { async function cleanRcloneCache() {
error = null; error = null;
success = null;
try { try {
const resp = await apiFetch("/api/rclone/cache/clean", { method: "POST" }); const resp = await apiFetch("/api/rclone/cache/clean", { method: "POST" });
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.ok) { if (!resp.ok || !data?.ok) {
throw new Error(data?.error || `HTTP ${resp.status}`); throw new Error(data?.error || `HTTP ${resp.status}`);
} }
success = "Rclone cache temizlendi."; showToast("Cache temizlendi.", "success");
await loadRcloneStatus();
} catch (err) { } catch (err) {
error = err?.message || "Rclone cache temizlenemedi."; error = err?.message || "Cache temizlenemedi.";
} showToast(error, "error");
}
async function checkAndCleanCache() {
error = null;
success = null;
try {
const resp = await apiFetch("/api/rclone/cache/check-and-clean", { method: "POST" });
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new Error(data?.error || data?.message || `HTTP ${resp.status}`);
}
if (data.cleaned) {
success = data.message || "Cache temizlendi.";
} else {
success = data.message || "Disk durumu iyi, temizleme gerekmedi.";
}
} catch (err) {
error = err?.message || "Cache kontrolü başarısız.";
} }
} }
@@ -278,17 +271,38 @@
</div> </div>
<div class="tabs"> <div class="tabs">
{#each tabs as tab} <button
<button class="tab {activeTab === 'general' ? 'active' : ''}"
class:active={activeTab === tab.id} type="button"
class="tab" on:click={() => activeTab = 'general'}
type="button" >
on:click={() => (activeTab = tab.id)} <i class="fa-solid fa-sliders"></i>
> <span>General</span>
<i class={tab.icon}></i> </button>
<span>{tab.label}</span> <button
</button> class="tab {activeTab === 'youtube' ? 'active' : ''}"
{/each} type="button"
on:click={() => activeTab = 'youtube'}
>
<i class="fa-brands fa-youtube"></i>
<span>YouTube</span>
</button>
<button
class="tab {activeTab === 'rclone' ? 'active' : ''}"
type="button"
on:click={() => activeTab = 'rclone'}
>
<i class="fa-solid fa-cloud"></i>
<span>Rclone</span>
</button>
<button
class="tab {activeTab === 'advanced' ? 'active' : ''}"
type="button"
on:click={() => activeTab = 'advanced'}
>
<i class="fa-solid fa-gear"></i>
<span>Advanced</span>
</button>
</div> </div>
{#if activeTab === "youtube"} {#if activeTab === "youtube"}
@@ -373,12 +387,6 @@
{error} {error}
</div> </div>
{/if} {/if}
{#if success}
<div class="alert success">
<i class="fa-solid fa-circle-check"></i>
{success}
</div>
{/if}
</div> </div>
{:else if activeTab === "general"} {:else if activeTab === "general"}
<div class="card muted">Genel ayarlar burada yer alacak.</div> <div class="card muted">Genel ayarlar burada yer alacak.</div>
@@ -422,11 +430,8 @@
disabled={rcloneLoading || rcloneSaving} disabled={rcloneLoading || rcloneSaving}
/> />
</div> </div>
<button class="btn" on:click={cleanRcloneCache}> <button class="btn primary" on:click={cleanRcloneCache}>
<i class="fa-solid fa-broom"></i> Temizle <i class="fa-solid fa-broom"></i> Cache Temizle
</button>
<button class="btn primary" on:click={checkAndCleanCache}>
<i class="fa-solid fa-wand-magic-sparkles"></i> Akıllı Temizle
</button> </button>
</div> </div>
@@ -455,7 +460,24 @@
</button> </button>
</div> </div>
<div class="actions"> {#if error}
<div class="alert error" style="margin-top:10px;">
<i class="fa-solid fa-circle-exclamation"></i>
{error}
</div>
{/if}
</div>
<!-- Mount Kontrol Kartı -->
<div class="card">
<div class="card-header">
<div class="title">
<i class="fa-solid fa-cloud"></i>
<span>Mount Kontrol</span>
</div>
</div>
<div class="actions left">
<button class="btn" on:click={startRcloneMount}> <button class="btn" on:click={startRcloneMount}>
<i class="fa-solid fa-play"></i> Mount Başlat <i class="fa-solid fa-play"></i> Mount Başlat
</button> </button>
@@ -465,10 +487,20 @@
</div> </div>
{#if rcloneStatus} {#if rcloneStatus}
<div class="card muted" style="margin-top:10px;"> <div class="card muted" style="margin-top:12px;">
<div><strong>Durum:</strong></div> <div><strong>Durum:</strong></div>
<div>Enabled: {rcloneStatus.enabled ? "Evet" : "Hayır"}</div> <div>Enabled: {rcloneStatus.enabled ? "Evet" : "Hayır"}</div>
<div>Mounted: {rcloneStatus.mounted ? "Evet" : "Hayır"}</div> <div>
Mounted:
{#if rcloneStatus.mountStatus === "starting"}
<span style="color: #f57c00;">Başlatılıyor...</span>
{:else if rcloneStatus.mounted}
<span style="color: #388e3c;">Evet</span>
{:else}
Hayır
{/if}
</div>
<div>Running: {rcloneStatus.running ? "Evet" : "Hayır"}</div>
<div>Remote: {rcloneStatus.remoteConfigured ? "Hazır" : "Eksik"}</div> <div>Remote: {rcloneStatus.remoteConfigured ? "Hazır" : "Eksik"}</div>
{#if rcloneStatus.vfsCacheMode} {#if rcloneStatus.vfsCacheMode}
<div>VFS Cache Mode: <code>{rcloneStatus.vfsCacheMode}</code></div> <div>VFS Cache Mode: <code>{rcloneStatus.vfsCacheMode}</code></div>
@@ -489,21 +521,15 @@
</div> </div>
{/if} {/if}
{#if rcloneStatus.lastError} {#if rcloneStatus.lastError}
<div style="margin-top: 8px; color: #d32f2f;">Son hata: {rcloneStatus.lastError}</div> <div style="margin-top: 8px; color: #d32f2f;">
<i class="fa-solid fa-circle-exclamation"></i> Son hata: {rcloneStatus.lastError}
</div>
{/if}
{#if rcloneStatus.lastLog && !rcloneStatus.lastError}
<div style="margin-top: 8px; font-size: 11px; color: #666;">
<i class="fa-solid fa-circle-info"></i> Son log: {rcloneStatus.lastLog}
</div>
{/if} {/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> </div>
{/if} {/if}
</div> </div>

View File

@@ -0,0 +1,24 @@
import { writable } from 'svelte/store';
export const toast = writable({
message: null,
type: 'success', // success, error, info
visible: false
});
let toastTimeout = null;
export function showToast(message, type = 'success', duration = 3000) {
// Önceki toast'ı temizle
if (toastTimeout) {
clearTimeout(toastTimeout);
}
// Yeni toast'ı göster
toast.set({ message, type, visible: true });
// Belirli süre sonra gizle
toastTimeout = setTimeout(() => {
toast.set({ message: null, type: 'success', visible: false });
}, duration);
}

View File

@@ -56,3 +56,4 @@ services:
RCLONE_AUTO_RESTART: ${RCLONE_AUTO_RESTART} RCLONE_AUTO_RESTART: ${RCLONE_AUTO_RESTART}
RCLONE_AUTO_RESTART_MAX_RETRIES: ${RCLONE_AUTO_RESTART_MAX_RETRIES} RCLONE_AUTO_RESTART_MAX_RETRIES: ${RCLONE_AUTO_RESTART_MAX_RETRIES}
RCLONE_AUTO_RESTART_DELAY_MS: ${RCLONE_AUTO_RESTART_DELAY_MS} RCLONE_AUTO_RESTART_DELAY_MS: ${RCLONE_AUTO_RESTART_DELAY_MS}
RCLONE_SETTINGS_PATH: ${RCLONE_SETTINGS_PATH}

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "dupe",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -186,7 +186,7 @@ const FFPROBE_MAX_BUFFER =
Number(process.env.FFPROBE_MAX_BUFFER) > 0 Number(process.env.FFPROBE_MAX_BUFFER) > 0
? Number(process.env.FFPROBE_MAX_BUFFER) ? Number(process.env.FFPROBE_MAX_BUFFER)
: 10 * 1024 * 1024; : 10 * 1024 * 1024;
const AVATAR_PATH = path.join(__dirname, "..", "client", "src", "assets", "avatar.png"); const AVATAR_PATH = path.join(__dirname, "cache", "avatar.png");
function getWsClientCount() { function getWsClientCount() {
if (!wss) return 0; if (!wss) return 0;
@@ -723,6 +723,16 @@ function readInfoForRoot(rootFolder) {
return null; return null;
} }
function getInfoPathForRoot(rootFolder) {
const safe = sanitizeRelative(rootFolder);
if (!safe) return null;
const localPath = path.join(DOWNLOAD_DIR, safe, INFO_FILENAME);
if (fs.existsSync(localPath)) return localPath;
const gdrivePath = path.join(GDRIVE_ROOT, safe, INFO_FILENAME);
if (fs.existsSync(gdrivePath)) return gdrivePath;
return localPath;
}
function sanitizeRelative(relPath) { function sanitizeRelative(relPath) {
return relPath.replace(/^[\\/]+/, ""); return relPath.replace(/^[\\/]+/, "");
} }
@@ -780,6 +790,7 @@ function resolveRootDir(rootFolder) {
let rcloneProcess = null; let rcloneProcess = null;
let rcloneLastError = null; let rcloneLastError = null;
let rcloneLastLogMessage = null; // Tüm log mesajları için (NOTICE dahil)
const rcloneAuthSessions = new Map(); const rcloneAuthSessions = new Map();
let rcloneCacheCleanTimer = null; let rcloneCacheCleanTimer = null;
// Auto-restart sayaçları // Auto-restart sayaçları
@@ -887,14 +898,10 @@ function updateMoveProgressFromStats(stats) {
updated = true; updated = true;
} }
} else { } else {
const gdriveTarget = relRoot ? path.join(GDRIVE_ROOT, relRoot) : null; // Transfer listesinde eşleşme yok
const targetExists = gdriveTarget ? fs.existsSync(gdriveTarget) : false; // Done kararı için aşağıdaki !hasTransfers kontrolü beklenmeli
if (targetExists) { // Burada sadece "uploading" durumunu "queued"ye düşürüyoruz ki polling devam etsin
entry.moveStatus = "done"; if (entry.moveStatus === "uploading") {
entry.moveProgress = 1;
updated = true;
} else if (entry.moveStatus === "uploading") {
// Transfer görünmüyorsa queued kalır; done kararı yukarıda olabilir.
entry.moveStatus = "queued"; entry.moveStatus = "queued";
updated = true; updated = true;
} }
@@ -918,12 +925,20 @@ function updateMoveProgressFromStats(stats) {
applyProgress(job, prefixes, relRoot); applyProgress(job, prefixes, relRoot);
} }
for (const job of mailruJobs.values()) { for (const job of mailruJobs.values()) {
const relRoot = job.folderId || ""; // MailRu için folderId null olabilir, fileName kullanıyoruz
const relRoot = job.folderId || job.fileName || "";
const prefixes = [ const prefixes = [
relRoot, relRoot,
RCLONE_REMOTE_PATH ? `${RCLONE_REMOTE_PATH}/${relRoot}` : null RCLONE_REMOTE_PATH ? `${RCLONE_REMOTE_PATH}/${relRoot}` : null
]; ];
if (relRoot) { if (relRoot) {
// Debug: MailRu transfer eşleşmesi
const matched = transfers.filter((t) =>
prefixes.filter(Boolean).some((p) => String(t.name || "").includes(p))
);
if (job.moveStatus === "uploading" && matched.length === 0) {
console.log(`⚠️ MailRu transfer eşleşme yok: job=${job.fileName}, relRoot=${relRoot}, prefixes=${JSON.stringify(prefixes)}, transfers=${transfers.map(t => t.name).join(",")}`);
}
applyProgress(job, prefixes, relRoot); applyProgress(job, prefixes, relRoot);
} }
} }
@@ -1104,7 +1119,27 @@ async function runRcloneCacheClean() {
const body = await resp.text(); const body = await resp.text();
return { ok: false, error: `Rclone RC hata: ${body || resp.status}` }; return { ok: false, error: `Rclone RC hata: ${body || resp.status}` };
} }
return { ok: true, method: "rc", restarted: false }; // RC refresh sonrası cache dizinini temizle (mount düşmeden)
try {
if (fs.existsSync(RCLONE_VFS_CACHE_DIR)) {
const entries = fs.readdirSync(RCLONE_VFS_CACHE_DIR);
for (const entry of entries) {
const target = path.join(RCLONE_VFS_CACHE_DIR, entry);
fs.rmSync(target, { recursive: true, force: true });
}
} else {
fs.mkdirSync(RCLONE_VFS_CACHE_DIR, { recursive: true });
}
} catch (cleanupErr) {
return {
ok: false,
error: cleanupErr?.message || String(cleanupErr)
};
}
const remaining = fs.existsSync(RCLONE_VFS_CACHE_DIR)
? fs.readdirSync(RCLONE_VFS_CACHE_DIR).length
: 0;
return { ok: true, method: "rc+fs", restarted: false, remaining };
} }
if (wasRunning && !RCLONE_RC_ENABLED) { if (wasRunning && !RCLONE_RC_ENABLED) {
@@ -1114,7 +1149,8 @@ async function runRcloneCacheClean() {
// RC kapalıysa dosya sisteminden temizle // RC kapalıysa dosya sisteminden temizle
fs.rmSync(RCLONE_VFS_CACHE_DIR, { recursive: true, force: true }); fs.rmSync(RCLONE_VFS_CACHE_DIR, { recursive: true, force: true });
fs.mkdirSync(RCLONE_VFS_CACHE_DIR, { recursive: true }); fs.mkdirSync(RCLONE_VFS_CACHE_DIR, { recursive: true });
return { ok: true, method: "fs", restarted: false }; const remaining = fs.readdirSync(RCLONE_VFS_CACHE_DIR).length;
return { ok: true, method: "fs", restarted: false, remaining };
} catch (err) { } catch (err) {
return { ok: false, error: err?.message || String(err) }; return { ok: false, error: err?.message || String(err) };
} }
@@ -1276,12 +1312,38 @@ function startRcloneMount(settings) {
rcloneProcess.stdout.on("data", (data) => { rcloneProcess.stdout.on("data", (data) => {
const msg = data.toString().trim(); const msg = data.toString().trim();
if (msg) console.log(`🌀 rclone: ${msg}`); if (msg) {
rcloneLastLogMessage = msg;
// NOTICE mesajları için farklı ikon, diğerleri için normal
if (msg.toUpperCase().includes("NOTICE")) {
console.log(`📡 rclone: ${msg}`);
} else {
console.log(`🌀 rclone: ${msg}`);
}
}
}); });
rcloneProcess.stderr.on("data", (data) => { rcloneProcess.stderr.on("data", (data) => {
const msg = data.toString().trim(); const msg = data.toString().trim();
if (msg) { if (msg) {
rcloneLastError = msg; if (msg.includes("Dir.Remove not empty")) {
console.info(` rclone: ${msg}`);
return;
}
rcloneLastLogMessage = msg;
// NOTICE ve INFO seviyesindeki loglar hata değil
// Sadece ERROR, FATAL, CRITICAL seviyesindekileri "son hata" olarak işaretle
const upperMsg = msg.toUpperCase();
if (upperMsg.includes("ERROR") ||
upperMsg.includes("FATAL") ||
upperMsg.includes("CRITICAL") ||
upperMsg.includes("FAILED") ||
upperMsg.includes("COULDN'T") ||
upperMsg.includes("CANNOT") ||
upperMsg.includes("REFUSED") ||
upperMsg.includes("TIMEOUT") ||
upperMsg.includes("CONNECTION")) {
rcloneLastError = msg;
}
console.warn(`⚠️ rclone: ${msg}`); console.warn(`⚠️ rclone: ${msg}`);
} }
}); });
@@ -2064,7 +2126,8 @@ async function finalizeYoutubeJob(job, exitCode) {
startRcloneStatsPolling(); startRcloneStatsPolling();
const moveResult = await moveRootFolderToGdrive(job.folderId); const moveResult = await moveRootFolderToGdrive(job.folderId);
if (moveResult.ok) { if (moveResult.ok) {
// Upload tamamlanma durumu RC stats ile belirlenecek job.moveStatus = "uploading";
scheduleSnapshotBroadcast();
} else { } else {
job.moveStatus = "error"; job.moveStatus = "error";
job.moveError = moveResult.error || "GDrive taşıma hatası"; job.moveError = moveResult.error || "GDrive taşıma hatası";
@@ -2410,7 +2473,8 @@ async function finalizeMailRuJob(job, exitCode) {
startRcloneStatsPolling(); startRcloneStatsPolling();
const moveResult = await movePathToGdrive(relPath); const moveResult = await movePathToGdrive(relPath);
if (moveResult.ok) { if (moveResult.ok) {
// Upload tamamlanma durumu RC stats ile belirlenecek job.moveStatus = "uploading";
scheduleSnapshotBroadcast();
} else { } else {
job.moveStatus = "error"; job.moveStatus = "error";
job.moveError = moveResult.error || "GDrive taşıma hatası"; job.moveError = moveResult.error || "GDrive taşıma hatası";
@@ -5905,7 +5969,8 @@ function writeInfoForRoot(rootFolder, info) {
if (!rootFolder || !info) return; if (!rootFolder || !info) return;
const safe = sanitizeRelative(rootFolder); const safe = sanitizeRelative(rootFolder);
if (!safe) return; if (!safe) return;
const target = path.join(DOWNLOAD_DIR, safe, INFO_FILENAME); const target = getInfoPathForRoot(safe);
if (!target) return;
try { try {
info.updatedAt = Date.now(); info.updatedAt = Date.now();
fs.writeFileSync(target, JSON.stringify(info, null, 2), "utf-8"); fs.writeFileSync(target, JSON.stringify(info, null, 2), "utf-8");
@@ -7256,7 +7321,8 @@ async function onTorrentDone({ torrent }) {
startRcloneStatsPolling(); startRcloneStatsPolling();
const moveResult = await moveRootFolderToGdrive(rootFolder); const moveResult = await moveRootFolderToGdrive(rootFolder);
if (moveResult.ok) { if (moveResult.ok) {
// Upload tamamlanma durumu RC stats ile belirlenecek entry.moveStatus = "uploading";
scheduleSnapshotBroadcast();
} else { } else {
entry.moveStatus = "error"; entry.moveStatus = "error";
entry.moveError = moveResult.error || "GDrive taşıma hatası"; entry.moveError = moveResult.error || "GDrive taşıma hatası";
@@ -7744,17 +7810,58 @@ app.delete("/api/file", requireAuth, (req, res) => {
if (!filePath) return res.status(400).json({ error: "path gerekli" }); if (!filePath) return res.status(400).json({ error: "path gerekli" });
const safePath = sanitizeRelative(filePath); const safePath = sanitizeRelative(filePath);
const fullPath = path.join(DOWNLOAD_DIR, safePath);
let folderId = (safePath.split(/[\/]/)[0] || "").trim(); // Dosyanın nerede olduğunu bul (DOWNLOAD_DIR veya GDRIVE_ROOT)
let rootDir = folderId ? path.join(DOWNLOAD_DIR, folderId) : null; let fullPath = null;
let folderIsDirectory = false; let storageBase = null; // Dosyanın bulunduğu storage base (DOWNLOAD_DIR veya GDRIVE_ROOT)
if (rootDir && fs.existsSync(rootDir)) {
try { for (const baseDir of getStorageRoots()) {
folderIsDirectory = fs.statSync(rootDir).isDirectory(); const testPath = path.join(baseDir, safePath);
} catch (err) { if (fs.existsSync(testPath)) {
folderIsDirectory = false; fullPath = testPath;
storageBase = baseDir;
break;
} }
} }
// Dosya bulunamadı
if (!fullPath) {
return res.status(404).json({ error: "Dosya bulunamadı" });
}
let folderId = (safePath.split(/[\/]/)[0] || "").trim();
let rootDir = null;
let folderIsDirectory = false;
// GDrive'da ise special handling - GDrive'ın kendisi root olarak kabul edilir
const isGDriveFile = storageBase === GDRIVE_ROOT;
if (isGDriveFile) {
// GDrive'da klasör yapısı farklıdır
// folderId varsa ve GDRIVE_ROOT/folderId bir klasörse
const testRootDir = path.join(GDRIVE_ROOT, folderId);
if (folderId && fs.existsSync(testRootDir)) {
try {
folderIsDirectory = fs.statSync(testRootDir).isDirectory();
if (folderIsDirectory) {
rootDir = GDRIVE_ROOT; // GDrive root'u
}
} catch (err) {
folderIsDirectory = false;
}
}
} else {
// Downloads klasörü için normal mantık
rootDir = folderId ? path.join(DOWNLOAD_DIR, folderId) : null;
if (rootDir && fs.existsSync(rootDir)) {
try {
folderIsDirectory = fs.statSync(rootDir).isDirectory();
} catch (err) {
folderIsDirectory = false;
}
}
}
// Kök dosyalarda ilk segment dosya adıdır; klasör değilse root davranışı uygula // Kök dosyalarda ilk segment dosya adıdır; klasör değilse root davranışı uygula
if (folderId && !folderIsDirectory) { if (folderId && !folderIsDirectory) {
folderId = ""; folderId = "";
@@ -7783,6 +7890,31 @@ app.delete("/api/file", requireAuth, (req, res) => {
const isDirectory = stats.isDirectory(); const isDirectory = stats.isDirectory();
const relWithinRoot = safePath.split(/[\\/]/).slice(1).join("/"); const relWithinRoot = safePath.split(/[\\/]/).slice(1).join("/");
let trashEntry = null; let trashEntry = null;
// GDrive dosyaları için özel handling - doğrudan sil
const isGDriveFile = storageBase === GDRIVE_ROOT;
if (isGDriveFile) {
// GDrive dosyaları için doğrudan silme (trash sistemi yok)
try {
if (isDirectory) {
fs.rmSync(fullPath, { recursive: true, force: true });
console.log(`🗑️ GDrive klasör silindi: ${safePath}`);
} else {
fs.unlinkSync(fullPath);
console.log(`🗑️ GDrive dosya silindi: ${safePath}`);
}
removeThumbnailsForPath(safePath);
broadcastFileUpdate("gdrive");
broadcastDiskSpace();
return res.json({ ok: true, filesRemoved: true, deletedFrom: "gdrive" });
} catch (deleteErr) {
console.error(`❌ GDrive dosya silme hatası: ${deleteErr.message}`);
return res.status(500).json({ error: `Silme hatası: ${deleteErr.message}` });
}
}
// Downloads klasörü için normal trash sistemi
if (folderId && folderIsDirectory && rootDir) { if (folderId && folderIsDirectory && rootDir) {
const infoBeforeDelete = readInfoForRoot(folderId); const infoBeforeDelete = readInfoForRoot(folderId);
mediaFlags = detectMediaFlagsForPath( mediaFlags = detectMediaFlagsForPath(
@@ -8223,7 +8355,8 @@ app.get("/api/files", requireAuth, (req, res) => {
const seriesEpisodeInfo = relWithinRoot const seriesEpisodeInfo = relWithinRoot
? info.seriesEpisodes?.[relWithinRoot] || null ? info.seriesEpisodes?.[relWithinRoot] || null
: null; : null;
let mediaCategory = fileMeta?.type || null; let mediaCategory =
fileMeta?.categoryOverride || fileMeta?.type || null;
if (!mediaCategory) { if (!mediaCategory) {
const canInheritFromInfo = !relWithinRoot || isVideo; const canInheritFromInfo = !relWithinRoot || isVideo;
if (canInheritFromInfo && info.type) { if (canInheritFromInfo && info.type) {
@@ -8274,6 +8407,45 @@ app.get("/api/files", requireAuth, (req, res) => {
} }
}); });
// --- 🏷️ Dosya kategori override ---
app.post("/api/file/category", requireAuth, (req, res) => {
try {
const relPath = sanitizeRelative(String(req.body?.path || ""));
const category = String(req.body?.category || "").toLowerCase();
if (!relPath) return res.status(400).json({ error: "Geçersiz yol" });
if (!category) return res.status(400).json({ error: "Geçersiz kategori" });
const rootFolder = rootFromRelPath(relPath);
if (!rootFolder) return res.status(400).json({ error: "Kök bulunamadı" });
const info = readInfoForRoot(rootFolder);
if (!info) return res.status(404).json({ error: "info.json bulunamadı" });
const segments = relPathToSegments(relPath);
const relWithinRoot = segments.slice(1).join("/");
if (!info.files) info.files = {};
if (!relWithinRoot) {
if (category === "auto") delete info.type;
else info.type = category;
writeInfoForRoot(rootFolder, info);
return res.json({ ok: true });
}
if (!info.files[relWithinRoot]) {
return res.status(404).json({ error: "Dosya kaydı bulunamadı" });
}
if (category === "auto") {
delete info.files[relWithinRoot].categoryOverride;
} else {
info.files[relWithinRoot].categoryOverride = category;
}
writeInfoForRoot(rootFolder, info);
return res.json({ ok: true });
} catch (err) {
console.error("❌ Kategori güncelleme hatası:", err);
res.status(500).json({ error: err.message });
}
});
// --- 🗑️ Çöp listesi API (.trash flag sistemi) --- // --- 🗑️ Çöp listesi API (.trash flag sistemi) ---
app.get("/api/trash", requireAuth, (req, res) => { app.get("/api/trash", requireAuth, (req, res) => {
try { try {
@@ -9210,6 +9382,10 @@ app.get("/api/rclone/status", requireAuth, async (req, res) => {
enabled: RCLONE_ENABLED, enabled: RCLONE_ENABLED,
mounted, mounted,
running: Boolean(rcloneProcess), running: Boolean(rcloneProcess),
// Mount durumu hakkında daha fazla bilgi
mountStatus: !rcloneProcess ? "stopped" :
mounted ? "mounted" :
"starting", // Process çalışıyor ama mount henüz tamamlanmadı
mountDir: settings.mountDir, mountDir: settings.mountDir,
remoteName: settings.remoteName, remoteName: settings.remoteName,
remotePath: settings.remotePath, remotePath: settings.remotePath,
@@ -9219,7 +9395,8 @@ app.get("/api/rclone/status", requireAuth, async (req, res) => {
cacheCleanMinutes: settings.cacheCleanMinutes || 0, cacheCleanMinutes: settings.cacheCleanMinutes || 0,
configExists: fs.existsSync(settings.configPath), configExists: fs.existsSync(settings.configPath),
remoteConfigured: rcloneConfigHasRemote(settings.remoteName), remoteConfigured: rcloneConfigHasRemote(settings.remoteName),
lastError: rcloneLastError || null, lastError: rcloneLastError || null, // Sadece gerçek hatalar
lastLog: rcloneLastLogMessage || null, // Son log mesajı (NOTICE dahil)
// Performans ayarları // Performans ayarları
vfsCacheMode: RCLONE_VFS_CACHE_MODE, vfsCacheMode: RCLONE_VFS_CACHE_MODE,
bufferSize: RCLONE_BUFFER_SIZE, bufferSize: RCLONE_BUFFER_SIZE,
@@ -10237,18 +10414,25 @@ function collectMusicEntries() {
if (!fileKeys.length) continue; if (!fileKeys.length) continue;
let targetPath = info.primaryVideoPath; let targetPath = info.primaryVideoPath;
if (targetPath && files[targetPath]?.type !== "music") { const primaryMeta = targetPath ? files[targetPath] : null;
const primaryCategory =
primaryMeta?.categoryOverride || primaryMeta?.type || null;
if (targetPath && primaryCategory !== "music") {
targetPath = null; targetPath = null;
} }
if (!targetPath) { if (!targetPath) {
targetPath = targetPath =
fileKeys.find((key) => files[key]?.type === "music") || fileKeys[0]; fileKeys.find(
(key) =>
(files[key]?.categoryOverride || files[key]?.type) === "music"
) || fileKeys[0];
} }
if (!targetPath) continue; if (!targetPath) continue;
const fileMeta = files[targetPath]; const fileMeta = files[targetPath];
// Hedef dosya çöpteyse atla // Hedef dosya çöpteyse atla
if (isPathTrashed(folder, targetPath, false)) continue; if (isPathTrashed(folder, targetPath, false)) continue;
const mediaType = fileMeta?.type || info.type || null; const mediaType =
fileMeta?.categoryOverride || fileMeta?.type || info.type || null;
if (mediaType !== "music") continue; if (mediaType !== "music") continue;
const absMusic = resolveStoragePath(`${folder}/${targetPath}`); const absMusic = resolveStoragePath(`${folder}/${targetPath}`);
if (!absMusic) continue; if (!absMusic) continue;
@@ -10268,9 +10452,14 @@ function collectMusicEntries() {
const infoHash = info.infoHash || folder; const infoHash = info.infoHash || folder;
const title = const title =
info.name || metadata?.title || path.basename(targetPath) || folder; info.name || metadata?.title || path.basename(targetPath) || folder;
const thumbnail = // Thumbnail kontrolü - metadata varsa fiziksel dosya varlığını kontrol et
metadata?.thumbnail || let thumbnail = metadata?.thumbnail;
(metadata ? `/yt-data/${folder}/thumbnail.jpg` : null); if (!thumbnail && metadata) {
const thumbPath = path.join(YT_DATA_ROOT, folder, "thumbnail.jpg");
if (fs.existsSync(thumbPath)) {
thumbnail = `/yt-data/${folder}/thumbnail.jpg`;
}
}
entries.push({ entries.push({
id: `${folder}:${targetPath}`, id: `${folder}:${targetPath}`,
@@ -10557,7 +10746,12 @@ app.get("/stream/:hash", requireAuth, (req, res) => {
const index = job.selectedIndex || 0; const index = job.selectedIndex || 0;
const fileEntry = job.files[index] || job.files[0]; const fileEntry = job.files[index] || job.files[0];
if (!fileEntry) return res.status(404).end(); if (!fileEntry) return res.status(404).end();
const absPath = path.join(job.savePath, fileEntry.name); const folderId = job.folderId || path.basename(job.savePath || "");
const relPath = folderId ? path.join(folderId, fileEntry.name) : fileEntry.name;
const absPath =
resolveStoragePath(relPath) ||
(job.savePath ? path.join(job.savePath, fileEntry.name) : null);
if (!absPath) return res.status(404).end();
return streamLocalFile(absPath, range, res); return streamLocalFile(absPath, range, res);
} }
@@ -10567,12 +10761,10 @@ app.get("/stream/:hash", requireAuth, (req, res) => {
if (fileKeys.length) { if (fileKeys.length) {
const idx = Number(req.query.index) || 0; const idx = Number(req.query.index) || 0;
const targetKey = fileKeys[idx] || fileKeys[0]; const targetKey = fileKeys[idx] || fileKeys[0];
const absPath = path.join( // Rclone ile taşınmış dosyalar için resolveStoragePath kullan
DOWNLOAD_DIR, const relPath = path.join(req.params.hash, targetKey.replace(/\\/g, "/"));
req.params.hash, const absPath = resolveStoragePath(relPath);
targetKey.replace(/\\/g, "/") if (absPath) {
);
if (fs.existsSync(absPath)) {
return streamLocalFile(absPath, range, res); return streamLocalFile(absPath, range, res);
} }
} }