Compare commits

22 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
10 changed files with 576 additions and 205 deletions

View File

@@ -97,3 +97,5 @@ RCLONE_AUTO_RESTART=1
RCLONE_AUTO_RESTART_MAX_RETRIES=5
# Yeniden başlatma arasındaki bekleme süresi (milisaniye)
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>
import { Router, Route } from "svelte-routing";
import { useLocation } from "svelte-routing";
import { onMount } from "svelte";
import Sidebar from "./components/Sidebar.svelte";
import Topbar from "./components/Topbar.svelte";
import MiniPlayer from "./components/MiniPlayer.svelte";
import Toast from "./components/Toast.svelte";
import Files from "./routes/Files.svelte";
import Transfers from "./routes/Transfers.svelte";
import Trash from "./routes/Trash.svelte";
@@ -27,6 +29,8 @@
let menuOpen = false;
let wsCounts;
let refreshTimer = null;
const location = useLocation();
$: isMusicRoute = ($location?.pathname || "").startsWith("/music");
const scheduleMediaRefresh = () => {
if (refreshTimer) return;
@@ -162,7 +166,10 @@
<Route path="/trash" component={Trash} />
</div>
{#if !isMusicRoute}
<MiniPlayer />
{/if}
<Toast />
<!-- Sidebar dışına tıklayınca kapanma -->
{#if menuOpen}

View File

@@ -1,9 +1,25 @@
<script>
import { onMount, afterUpdate, tick } from "svelte";
import { musicPlayer, togglePlay, playNext, playPrevious, stopPlayback } from "../stores/musicPlayerStore.js";
import { useLocation } from "svelte-routing";
import { API, withToken } from "../utils/api.js";
import { cleanFileName } from "../utils/filename.js";
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) {
if (!item?.thumbnail) return null;
@@ -36,39 +52,97 @@
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) {
const target = $musicPlayer.currentTime || 0;
if (Number.isFinite(target) && Math.abs(videoEl.currentTime - target) > 0.6) {
if (!Number.isFinite(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) {
videoEl.play().catch(() => undefined);
} else if (videoEl) {
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>
{#if $musicPlayer.currentTrack}
<div class="mini-player" aria-label="Mini music player">
<div class="mini-top">
<div class="mini-user-meta">
<div class="mini-user-name">
{cleanFileName($musicPlayer.currentTrack.title)}
</div>
<div class="mini-user-handle">{sourceLabel($musicPlayer.currentTrack)}</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>
{#if $musicPlayer.currentTrack && !isMusicRoute}
<div
class="mini-player {dragging ? 'dragging' : ''}"
aria-label="Mini music player"
on:pointerdown={startDrag}
style="transform: translate({dragX}px, {dragY}px);"
>
<div class="mini-top"></div>
<div class="mini-cover">
{#if $musicPlayer.isPlaying && videoStreamURL($musicPlayer.currentTrack)}
@@ -90,6 +164,20 @@
</div>
{/if}
</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">
<span class="mini-time">{formatTime($musicPlayer.currentTime)}</span>
@@ -105,20 +193,20 @@
</div>
<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>
</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}
<i class="fa-solid fa-pause"></i>
{:else}
<i class="fa-solid fa-play"></i>
{/if}
</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>
</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>
</button>
</div>
@@ -139,18 +227,21 @@
color: #f6f6f6;
backdrop-filter: blur(14px);
z-index: 50;
cursor: grab;
touch-action: none;
}
.mini-player.dragging {
cursor: grabbing;
}
.mini-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 0;
}
.mini-user-meta {
min-width: 0;
.mini-meta {
text-align: left;
margin: 0 4px 8px -4px;
}
.mini-user-name {
@@ -159,6 +250,19 @@
white-space: nowrap;
overflow: hidden;
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 {
@@ -166,39 +270,55 @@
color: rgba(255, 255, 255, 0.6);
}
.mini-actions {
display: flex;
gap: 8px;
@keyframes mini-marquee {
0% {
transform: translateX(0);
opacity: 1;
}
10% {
transform: translateX(0);
opacity: 1;
}
60% {
transform: translateX(calc(-1 * var(--marquee-shift)));
opacity: 1;
}
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-icon-btn {
width: 32px;
height: 32px;
border-radius: 12px;
border: none;
background: rgba(255, 255, 255, 0.12);
color: #f6f6f6;
cursor: pointer;
}
.mini-cover {
margin: 14px auto 12px;
width: 160px;
height: 160px;
border-radius: 20px;
margin: -16px -16px 12px;
width: calc(100% + 32px);
aspect-ratio: 1 / 1;
border-radius: 26px 26px 0 0;
overflow: hidden;
background: rgba(255, 255, 255, 0.06);
box-shadow: 0 18px 30px rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.12);
display: grid;
place-items: center;
transform: translateY(-6px);
border: none;
display: block;
}
.mini-cover video,
.mini-cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.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

@@ -626,6 +626,25 @@
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) {
if (!bytes) return "0 MB";
if (bytes < 1e6) return (bytes / 1e3).toFixed(1) + " KB";
@@ -1284,10 +1303,6 @@
});
}
function closeMenu() {
activeMenu = null;
}
async function downloadFile(file) {
if (!file || file.isDirectory) {
if (file?.isDirectory) navigateToPath(file.displayPath);
@@ -1305,12 +1320,18 @@
}
function matchFile(file) {
try {
if (!file || file.isDirectory) {
closeMenu();
return;
}
// Dosya adını al (path'in son kısmı)
const fileName = file.name.split('/').pop();
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);
@@ -1344,6 +1365,10 @@
tick().then(() => {
searchMetadata();
});
} catch (err) {
showToast("Eşleştirme penceresi açılamadı.", "error");
closeMenu();
}
}
function closeMatchModal() {
@@ -1374,6 +1399,10 @@
params.set("year", matchYear);
}
const token = localStorage.getItem("token");
if (token) {
params.set("token", token);
}
const response = await apiFetch(`/api/search/metadata?${params}`);
if (response.ok) {
@@ -1490,8 +1519,6 @@
function closeMenu() {
activeMenu = null;
deleteConfirmPending = false;
showMatchModal = false;
matchingFile = null;
}
// Klasör oluşturma fonksiyonları
@@ -2399,6 +2426,20 @@
<i class="fa-solid fa-wand-magic-sparkles"></i>
<span>Eşleştir</span>
</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>
<button
class="menu-item"
@@ -3251,12 +3292,14 @@
.name {
font-weight: 600;
font-size: 13px;
text-align: center;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
line-height: 1.3;
max-height: calc(1.3em * 2);
min-height: calc(1.3em * 2);
text-overflow: ellipsis;
word-break: break-word;
}
@@ -3827,7 +3870,8 @@
.folder-info {
margin-top: 4px;
width: 100%;
text-align: center;
text-align: left;
padding: 0 8px;
flex-shrink: 0;
}
.folder-name {
@@ -3839,8 +3883,9 @@
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-align: center;
text-align: left;
max-height: calc(1.25em * 2);
min-height: calc(1.25em * 2);
overflow: hidden;
text-overflow: ellipsis;
}

View File

@@ -1,12 +1,13 @@
<script>
import { onMount } from "svelte";
import { apiFetch } from "../utils/api.js";
import { showToast } from "../stores/toastStore.js";
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" }
{ id: "general", label: "General", icon: "sliders" },
{ id: "youtube", label: "YouTube", icon: "youtube" },
{ id: "rclone", label: "Rclone", icon: "cloud" },
{ id: "advanced", label: "Advanced", icon: "gear" }
];
let activeTab = "youtube";
@@ -20,7 +21,6 @@
let onlyAudio = false;
const resolutionOptions = ["1080p", "720p", "480p", "360p", "240p", "144p"];
let error = null;
let success = null;
let rcloneStatus = null;
let rcloneLoading = false;
@@ -34,7 +34,6 @@
async function loadCookies() {
loadingCookies = true;
error = null;
success = null;
try {
const resp = await apiFetch("/api/youtube/cookies");
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
@@ -51,7 +50,6 @@
async function saveCookies() {
if (savingCookies) return;
error = null;
success = null;
savingCookies = true;
try {
const payload = {
@@ -71,7 +69,7 @@
throw new Error(data?.error || `HTTP ${resp.status}`);
}
cookiesUpdatedAt = data.updatedAt || Date.now();
success = "Cookies kaydedildi.";
showToast("Cookies kaydedildi.", "success");
} catch (err) {
error = err?.message || "Cookies kaydedilemedi.";
} finally {
@@ -99,7 +97,6 @@
if (savingYtSettings) return;
savingYtSettings = true;
error = null;
success = null;
try {
const resp = await apiFetch("/api/youtube/settings", {
method: "POST",
@@ -113,7 +110,7 @@
if (!resp.ok || !data?.ok) {
throw new Error(data?.error || `HTTP ${resp.status}`);
}
success = "YouTube indirme ayarları kaydedildi.";
showToast("YouTube indirme ayarları kaydedildi.", "success");
} catch (err) {
error = err?.message || "YouTube ayarları kaydedilemedi.";
} finally {
@@ -156,7 +153,6 @@
if (rcloneSaving) return;
rcloneSaving = true;
error = null;
success = null;
try {
const resp = await apiFetch("/api/rclone/settings", {
method: "POST",
@@ -180,7 +176,7 @@
if (!confResp.ok || !confData?.ok) {
throw new Error(confData?.error || `HTTP ${confResp.status}`);
}
success = "Rclone ayarları kaydedildi.";
showToast("Rclone ayarları kaydedildi.", "success");
await loadRcloneStatus();
} catch (err) {
error = err?.message || "Rclone ayarları kaydedilemedi.";
@@ -191,7 +187,6 @@
async function startRcloneMount() {
error = null;
success = null;
try {
const resp = await apiFetch("/api/rclone/mount", { method: "POST" });
const data = await resp.json().catch(() => ({}));
@@ -200,16 +195,16 @@
}
// Mount başlatıldı, birkaç saniye bekleyip tekrar kontrol et
success = "Rclone mount başlatılıyor...";
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) {
success = "Rclone mount başarıyla başlatıldı.";
showToast("Rclone mount başarıyla başlatıldı.", "success");
} else if (rcloneStatus?.running) {
success = "Rclone mount başlatıldı, mount tamamlanıyor...";
showToast("Rclone mount başlatıldı, mount tamamlanıyor...", "info");
} else {
error = "Rclone mount başlatılamadı.";
}
@@ -224,14 +219,13 @@
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.";
showToast("Rclone mount durduruldu.", "success");
await loadRcloneStatus();
} catch (err) {
error = err?.message || "Rclone mount durdurulamadı.";
@@ -240,35 +234,17 @@
async function cleanRcloneCache() {
error = null;
success = null;
try {
const resp = await apiFetch("/api/rclone/cache/clean", { method: "POST" });
const data = await resp.json().catch(() => ({}));
if (!resp.ok || !data?.ok) {
throw new Error(data?.error || `HTTP ${resp.status}`);
}
success = "Rclone cache temizlendi.";
showToast("Cache temizlendi.", "success");
await loadRcloneStatus();
} catch (err) {
error = err?.message || "Rclone cache temizlenemedi.";
}
}
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.";
error = err?.message || "Cache temizlenemedi.";
showToast(error, "error");
}
}
@@ -295,17 +271,38 @@
</div>
<div class="tabs">
{#each tabs as tab}
<button
class:active={activeTab === tab.id}
class="tab"
class="tab {activeTab === 'general' ? 'active' : ''}"
type="button"
on:click={() => (activeTab = tab.id)}
on:click={() => activeTab = 'general'}
>
<i class={tab.icon}></i>
<span>{tab.label}</span>
<i class="fa-solid fa-sliders"></i>
<span>General</span>
</button>
<button
class="tab {activeTab === 'youtube' ? 'active' : ''}"
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>
{/each}
</div>
{#if activeTab === "youtube"}
@@ -390,12 +387,6 @@
{error}
</div>
{/if}
{#if success}
<div class="alert success">
<i class="fa-solid fa-circle-check"></i>
{success}
</div>
{/if}
</div>
{:else if activeTab === "general"}
<div class="card muted">Genel ayarlar burada yer alacak.</div>
@@ -439,11 +430,8 @@
disabled={rcloneLoading || rcloneSaving}
/>
</div>
<button class="btn" on:click={cleanRcloneCache}>
<i class="fa-solid fa-broom"></i> Temizle
</button>
<button class="btn primary" on:click={checkAndCleanCache}>
<i class="fa-solid fa-wand-magic-sparkles"></i> Akıllı Temizle
<button class="btn primary" on:click={cleanRcloneCache}>
<i class="fa-solid fa-broom"></i> Cache Temizle
</button>
</div>
@@ -472,7 +460,24 @@
</button>
</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}>
<i class="fa-solid fa-play"></i> Mount Başlat
</button>
@@ -482,7 +487,7 @@
</div>
{#if rcloneStatus}
<div class="card muted" style="margin-top:10px;">
<div class="card muted" style="margin-top:12px;">
<div><strong>Durum:</strong></div>
<div>Enabled: {rcloneStatus.enabled ? "Evet" : "Hayır"}</div>
<div>
@@ -527,19 +532,6 @@
{/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>

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_MAX_RETRIES: ${RCLONE_AUTO_RESTART_MAX_RETRIES}
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)
: 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() {
if (!wss) return 0;
@@ -723,6 +723,16 @@ function readInfoForRoot(rootFolder) {
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) {
return relPath.replace(/^[\\/]+/, "");
}
@@ -888,14 +898,10 @@ function updateMoveProgressFromStats(stats) {
updated = true;
}
} else {
const gdriveTarget = relRoot ? path.join(GDRIVE_ROOT, relRoot) : null;
const targetExists = gdriveTarget ? fs.existsSync(gdriveTarget) : false;
if (targetExists) {
entry.moveStatus = "done";
entry.moveProgress = 1;
updated = true;
} else if (entry.moveStatus === "uploading") {
// Transfer görünmüyorsa queued kalır; done kararı yukarıda olabilir.
// Transfer listesinde eşleşme yok
// Done kararı için aşağıdaki !hasTransfers kontrolü beklenmeli
// Burada sadece "uploading" durumunu "queued"ye düşürüyoruz ki polling devam etsin
if (entry.moveStatus === "uploading") {
entry.moveStatus = "queued";
updated = true;
}
@@ -919,12 +925,20 @@ function updateMoveProgressFromStats(stats) {
applyProgress(job, prefixes, relRoot);
}
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 = [
relRoot,
RCLONE_REMOTE_PATH ? `${RCLONE_REMOTE_PATH}/${relRoot}` : null
];
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);
}
}
@@ -1105,7 +1119,27 @@ async function runRcloneCacheClean() {
const body = await resp.text();
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) {
@@ -1115,7 +1149,8 @@ async function runRcloneCacheClean() {
// RC kapalıysa dosya sisteminden temizle
fs.rmSync(RCLONE_VFS_CACHE_DIR, { recursive: true, force: 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) {
return { ok: false, error: err?.message || String(err) };
}
@@ -1290,6 +1325,10 @@ function startRcloneMount(settings) {
rcloneProcess.stderr.on("data", (data) => {
const msg = data.toString().trim();
if (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
@@ -2087,7 +2126,8 @@ async function finalizeYoutubeJob(job, exitCode) {
startRcloneStatsPolling();
const moveResult = await moveRootFolderToGdrive(job.folderId);
if (moveResult.ok) {
// Upload tamamlanma durumu RC stats ile belirlenecek
job.moveStatus = "uploading";
scheduleSnapshotBroadcast();
} else {
job.moveStatus = "error";
job.moveError = moveResult.error || "GDrive taşıma hatası";
@@ -2433,7 +2473,8 @@ async function finalizeMailRuJob(job, exitCode) {
startRcloneStatsPolling();
const moveResult = await movePathToGdrive(relPath);
if (moveResult.ok) {
// Upload tamamlanma durumu RC stats ile belirlenecek
job.moveStatus = "uploading";
scheduleSnapshotBroadcast();
} else {
job.moveStatus = "error";
job.moveError = moveResult.error || "GDrive taşıma hatası";
@@ -5928,7 +5969,8 @@ function writeInfoForRoot(rootFolder, info) {
if (!rootFolder || !info) return;
const safe = sanitizeRelative(rootFolder);
if (!safe) return;
const target = path.join(DOWNLOAD_DIR, safe, INFO_FILENAME);
const target = getInfoPathForRoot(safe);
if (!target) return;
try {
info.updatedAt = Date.now();
fs.writeFileSync(target, JSON.stringify(info, null, 2), "utf-8");
@@ -7279,7 +7321,8 @@ async function onTorrentDone({ torrent }) {
startRcloneStatsPolling();
const moveResult = await moveRootFolderToGdrive(rootFolder);
if (moveResult.ok) {
// Upload tamamlanma durumu RC stats ile belirlenecek
entry.moveStatus = "uploading";
scheduleSnapshotBroadcast();
} else {
entry.moveStatus = "error";
entry.moveError = moveResult.error || "GDrive taşıma hatası";
@@ -8312,7 +8355,8 @@ app.get("/api/files", requireAuth, (req, res) => {
const seriesEpisodeInfo = relWithinRoot
? info.seriesEpisodes?.[relWithinRoot] || null
: null;
let mediaCategory = fileMeta?.type || null;
let mediaCategory =
fileMeta?.categoryOverride || fileMeta?.type || null;
if (!mediaCategory) {
const canInheritFromInfo = !relWithinRoot || isVideo;
if (canInheritFromInfo && info.type) {
@@ -8363,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) ---
app.get("/api/trash", requireAuth, (req, res) => {
try {
@@ -10331,18 +10414,25 @@ function collectMusicEntries() {
if (!fileKeys.length) continue;
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;
}
if (!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;
const fileMeta = files[targetPath];
// Hedef dosya çöpteyse atla
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;
const absMusic = resolveStoragePath(`${folder}/${targetPath}`);
if (!absMusic) continue;
@@ -10362,9 +10452,14 @@ function collectMusicEntries() {
const infoHash = info.infoHash || folder;
const title =
info.name || metadata?.title || path.basename(targetPath) || folder;
const thumbnail =
metadata?.thumbnail ||
(metadata ? `/yt-data/${folder}/thumbnail.jpg` : null);
// Thumbnail kontrolü - metadata varsa fiziksel dosya varlığını kontrol et
let thumbnail = metadata?.thumbnail;
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({
id: `${folder}:${targetPath}`,
@@ -10651,7 +10746,12 @@ app.get("/stream/:hash", requireAuth, (req, res) => {
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);
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);
}
@@ -10661,12 +10761,10 @@ app.get("/stream/:hash", requireAuth, (req, res) => {
if (fileKeys.length) {
const idx = Number(req.query.index) || 0;
const targetKey = fileKeys[idx] || fileKeys[0];
const absPath = path.join(
DOWNLOAD_DIR,
req.params.hash,
targetKey.replace(/\\/g, "/")
);
if (fs.existsSync(absPath)) {
// Rclone ile taşınmış dosyalar için resolveStoragePath kullan
const relPath = path.join(req.params.hash, targetKey.replace(/\\/g, "/"));
const absPath = resolveStoragePath(relPath);
if (absPath) {
return streamLocalFile(absPath, range, res);
}
}