diff --git a/ANALYSIS.md b/ANALYSIS.md new file mode 100644 index 0000000..471a694 --- /dev/null +++ b/ANALYSIS.md @@ -0,0 +1,290 @@ +# du.pe Proje Analiz Raporu + +## 📋 Proje Özeti + +**du.pe**, Put.io benzeri bir **self-hosted torrent tabanlı dosya yöneticisi ve medya oynatıcısıdır**. Kullanıcıların torrent dosyalarını eklemesine, indirmeleri yönetmesine ve videoları doğrudan tarayıcı üzerinden akışla (stream) izlemesine olanak tanır. + +--- + +## 🏗️ Mimari Yapı + +``` +dupe/ +├── client/ # Svelte + Vite Frontend +│ ├── src/ +│ │ ├── components/ # Sidebar, Topbar, TorrentItem +│ │ ├── routes/ # Files, Movies, TV Shows, Music, Transfers, Trash, Profile, Settings +│ │ ├── stores/ # Svelte stores (movieStore, tvStore, musicStore, trashStore, avatarStore) +│ │ ├── styles/ # Ana CSS dosyası +│ │ └── utils/ # API yardımcı fonksiyonları +│ └── package.json +├── server/ # Node.js + Express Backend +│ ├── modules/ # auth.js, health.js, state.js, websocket.js +│ ├── utils/ # diskSpace.js +│ ├── data/ # users.json +│ └── server.js # Ana sunucu dosyası (~7100 satır) +├── docs/ # Dokümantasyon +└── docker-compose.yml +``` + +--- + +## 🔧 Teknoloji Yığını + +### Backend (Node.js) +- **Express** - REST API sunucusu +- **WebTorrent** - Torrent indirme yönetimi +- **WebSocket (ws)** - Gerçek zamanlı ilerleme güncellemeleri +- **Multer** - Dosya upload işlemleri +- **JWT (jsonwebtoken)** - Kimlik doğrulama +- **ffmpeg/ffprobe** - Video thumbnail oluşturma ve medya bilgisi çıkarma +- **TMDB API** - Film metadatası (poster, backdrop, özet) +- **TVDB API** - Dizi metadatası +- **Fanart.tv API** - Dizi backdrop görselleri +- **yt-dlp** - YouTube video indirme + +### Frontend (Svelte) +- **Svelte 4** - UI framework +- **svelte-routing** - İstemci tarafı yönlendirme +- **Vite** - Build aracı +- **FontAwesome** - İkonlar +- **WebSocket** - Gerçek zamanlı güncellemeler + +--- + +## 📡 API Endpoint'leri + +| Method | Endpoint | Açıklama | +|--------|----------|-----------| +| `GET` | `/api/health` | Sağlık kontrolü | +| `GET` | `/api/profile` | Profil bilgileri | +| `POST` | `/api/profile/avatar` | Avatar yükleme | +| `GET` | `/api/torrents` | Aktif torrent listesi | +| `POST` | `/api/transfer` | Torrent/magnet ekleme | +| `POST` | `/api/youtube/download` | YouTube indirme | +| `POST` | `/api/torrents/:hash/select/:index` | Dosya seçimi | +| `DELETE` | `/api/torrents/:hash` | Torrent silme | +| `POST` | `/api/torrents/:hash/toggle` | Tek torrent durdur/devam | +| `POST` | `/api/torrents/toggle-all` | Tüm torrentleri durdur/devam | +| `GET` | `/api/files` | Dosya gezgini | +| `GET` | `/api/movies` | Film listesi | +| `GET` | `/api/tvshows` | Dizi listesi | +| `GET` | `/api/music` | Müzik listesi | +| `GET` | `/api/trash` | Çöp kutusu | +| `POST` | `/api/trash/restore` | Çöpten geri yükle | +| `DELETE` | `/api/trash` | Çöpten kalıcı sil | +| `POST` | `/api/file/move` | Dosya taşıma | +| `GET` | `/api/disk-space` | Disk alanı bilgisi | +| `GET` | `/api/search/metadata` | TMDB/TVDB arama | +| `GET` | `/stream/:hash` | Video akışı | +| `GET` | `/media/:path(*)` | Genel medya sunumu | +| `GET` | `/thumbnails/:path(*)` | Thumbnail sunumu | +| `WS` | `ws://host/?token=...` | WebSocket bağlantısı | + +--- + +## 🔄 WebSocket Mesajları + +```javascript +// İlerleme güncellemesi +{ type: "progress", torrents: [...] } + +// Dosya güncellemesi +{ type: "fileUpdate", path: "rootFolder" } + +// Disk alanı güncellemesi +{ type: "diskSpace", data: {...} } + +// Medya tespiti +{ type: "mediaDetected", rootFolder, hasSeriesEpisodes, hasMovieMatch } +``` + +--- + +## 🎬 Temel Özellikler + +### 1. Torrent Yönetimi +- `.torrent` dosyası veya magnet link ile ekleme +- Gerçek zamanlı indirme ilerleme takibi +- Tekil veya toplu durdurma/devam ettirme +- Dosya seçimi (birden fazla dosya içeren torrentler için) + +### 2. YouTube İndirme +- YouTube URL'sinden video/ses indirme +- Çözünürlük seçimi (1080p, 720p, 480p, vb.) +- Sadece ses indirme seçeneği +- Cookie desteği + +### 3. Medya Yönetimi +- **Filmler**: TMDB'den otomatik metadata çekme (poster, backdrop, özet) +- **Diziler**: TVDB'den sezon/bölüm bilgileri +- **Müzik**: YouTube indirmeleri için müzik kütüphanesi +- **Thumbnail**: Otomatik video/resim küçük resim oluşturma + +### 4. Dosya Yönetimi +- Dosya gezgini +- Dosya taşıma +- Çöp kutusu (geri yükleme ve kalıcı silme) +- `.ignoreFiles` ile dosya filtreleme + +### 5. Video Oynatıcı +- Özel modal video oynatıcı +- Altyazı desteği (.srt, .vtt) +- Ses kontrolü +- Tam ekran desteği +- İndirme butonu + +### 6. Kimlik Doğrulama +- JWT tabanlı auth +- Token refresh mekanizması +- Profil yönetimi (avatar yükleme) + +--- + +## 🗂️ Veri Yapısı + +### info.json (Her torrent klasöründe) +```json +{ + "infoHash": "...", + "name": "Torrent Adı", + "tracker": "...", + "added": 1234567890, + "folder": "klasor_adi", + "completedAt": 1234567890, + "type": "movie|tv|music|video", + "files": { + "dosya.mp4": { + "size": 123456789, + "extension": "mp4", + "mediaInfo": {...}, + "type": "movie" + } + }, + "primaryVideoPath": "dosya.mp4", + "primaryMediaInfo": {...}, + "seriesEpisodes": {...} +} +``` + +### .trash (Çöp yönetimi) +```json +{ + "updatedAt": 1234567890, + "items": [ + { + "path": "dosya.mp4", + "originalPath": "klasor/dosya.mp4", + "deletedAt": 1234567890, + "isDirectory": false, + "type": "video/mp4" + } + ] +} +``` + +--- + +## 🎯 Frontend Store Yapısı + +### Svelte Stores +- `movieStore` - Film sayacı +- `tvStore` - Dizi sayacı +- `musicStore` - Müzik sayacı +- `trashStore` - Çöp öğeleri +- `avatarStore` - Avatar URL +- `searchStore` - Arama sorgusu + +--- + +## 🚀 Çalışma Mantığı + +1. **Başlangıç**: Sunucu başladığında mevcut torrentler diskten yüklenir +2. **Torrent Ekleme**: `.torrent` veya magnet → WebTorrent client'e ekle → WebSocket ile ilerleme bildirimi +3. **İndirme Tamamlandı**: + - Video thumbnail'ları oluşturulur + - Medya bilgisi (ffprobe) çıkarılır + - TMDB/TVDB'den metadata çekilir + - info.json güncellenir +4. **Frontend**: WebSocket üzerinden gerçek zamanlı güncellemeler alır, UI'yi günceller +5. **Video Oynatma**: `/stream/:hash` endpoint'i üzerinden video akışı sağlanır + +--- + +## 🔐 Güvenlik + +- JWT tabanlı kimlik doğrulama +- Token refresh mekanizması +- Path sanitization (traversal koruması) +- Dosya tipi ve boyut kısıtlamaları +- CORS yapılandırması + +--- + +## 📦 Docker Entegrasyonu + +```yaml +# docker-compose.yml +services: + dupe: + build: . + ports: + - "3001:3001" + volumes: + - ./server/downloads:/app/server/downloads + - ./server/cache:/app/server/cache + environment: + - TMDB_API_KEY + - TVDB_API_KEY + - YT_DLP_BIN +``` + +--- + +## 🎨 UI/UX Özellikleri + +- Responsive tasarım (mobil uyumlu) +- Hamburger menü (mobile) +- Drag & drop torrent yükleme +- Modal video oynatıcı +- İlerleme çubukları +- Thumbnail önizlemeler +- Gerçek zamanlı hız göstergeleri + +--- + +## 📂 Önemli Dosyalar + +| Dosya | Açıklama | +|-------|----------| +| `server/server.js` | Ana backend sunucusu (~7100 satır) | +| `client/src/App.svelte` | Ana uygulama bileşeni | +| `client/src/routes/Transfers.svelte` | Torrent yönetim arayüzü | +| `client/src/utils/api.js` | API yardımcı fonksiyonları | +| `client/src/stores/*.js` | Svelte state yönetimi | +| `server/modules/auth.js` | Kimlik doğrulama modülü | +| `server/modules/websocket.js` | WebSocket sunucusu | + +--- + +## 🔍 Geliştirme Notları + +### Ortam Değişkenleri +```bash +PORT=3001 +TMDB_API_KEY=... +TVDB_API_KEY=... +FANART_TV_API_KEY=... +YT_DLP_BIN=/usr/local/bin/yt-dlp +YT_DLP_COOKIES=/path/to/cookies.txt +VITE_API=http://localhost:3001 +``` + +### Bağımlılıklar +- **ffmpeg** ve **ffprobe** kurulu olmalıdır +- **yt-dlp** (YouTube indirmeleri için) +- **Node.js** v18+ + +--- + +*Rapor oluşturma tarihi: 24 Aralık 2024* diff --git a/client/src/routes/Music.svelte b/client/src/routes/Music.svelte index bc5d707..d38591b 100644 --- a/client/src/routes/Music.svelte +++ b/client/src/routes/Music.svelte @@ -8,6 +8,20 @@ let loading = true; let error = null; + // Player state + let currentTrack = null; + let isPlaying = false; + let currentTime = 0; + let duration = 0; + let volume = 0.8; + let isMuted = false; + let previousVolume = 0.8; + let audioEl; + let progressInterval; + + // View mode + let viewMode = "list"; // "list" or "grid" + async function loadMusic() { loading = true; error = null; @@ -39,15 +53,21 @@ if (!item.thumbnail) return null; const token = localStorage.getItem("token"); const separator = item.thumbnail.includes("?") ? "&" : "?"; - // Cache buster eklemiyoruz; server Cache-Control/ETag ile tarayıcı cache'i kullanacak return `${API}${item.thumbnail}${separator}token=${token}`; } function formatDuration(seconds) { - if (!Number.isFinite(seconds) || seconds <= 0) return ""; + if (!Number.isFinite(seconds) || seconds <= 0) return "0:00"; const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); - return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; + return `${mins}:${String(secs).padStart(2, "0")}`; + } + + function formatTime(seconds) { + if (!Number.isFinite(seconds) || seconds <= 0) return "0:00"; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${String(secs).padStart(2, "0")}`; } function sourceLabel(item) { @@ -55,39 +75,302 @@ return "Music"; } + // Player functions + function playTrack(item, index) { + if (currentTrack?.id === item.id) { + togglePlay(); + return; + } + + currentTrack = { ...item, index }; + currentTime = 0; + duration = item.mediaInfo?.format?.duration || 0; + isPlaying = true; + + if (audioEl) { + audioEl.src = streamURL(item); + audioEl.play().catch((err) => { + console.error("Play error:", err); + isPlaying = false; + }); + } + } + + function togglePlay() { + if (!audioEl) return; + if (isPlaying) { + audioEl.pause(); + } else { + audioEl.play().catch((err) => { + console.error("Play error:", err); + }); + } + isPlaying = !isPlaying; + } + + function playNext() { + if (!currentTrack || items.length === 0) return; + const currentIndex = items.findIndex((i) => i.id === currentTrack.id); + const nextIndex = (currentIndex + 1) % items.length; + playTrack(items[nextIndex], nextIndex); + } + + function playPrevious() { + if (!currentTrack || items.length === 0) return; + const currentIndex = items.findIndex((i) => i.id === currentTrack.id); + const prevIndex = (currentIndex - 1 + items.length) % items.length; + playTrack(items[prevIndex], prevIndex); + } + + function seek(e) { + if (!audioEl || !duration) return; + const percent = parseFloat(e.target.value); + currentTime = (percent / 100) * duration; + audioEl.currentTime = currentTime; + } + + function setVolume(e) { + volume = parseFloat(e.target.value); + if (audioEl) { + audioEl.volume = volume; + } + isMuted = volume === 0; + } + + function toggleMute() { + if (isMuted) { + volume = previousVolume; + isMuted = false; + } else { + previousVolume = volume; + volume = 0; + isMuted = true; + } + if (audioEl) { + audioEl.volume = volume; + } + } + + function handleTimeUpdate() { + if (audioEl) { + currentTime = audioEl.currentTime; + duration = audioEl.duration || 0; + } + } + + function handleLoadedMetadata() { + if (audioEl) { + duration = audioEl.duration || 0; + } + } + + function handleEnded() { + playNext(); + } + + function handlePlay() { + isPlaying = true; + } + + function handlePause() { + isPlaying = false; + } + + function startProgressInterval() { + if (progressInterval) clearInterval(progressInterval); + progressInterval = setInterval(() => { + if (isPlaying && audioEl) { + currentTime = audioEl.currentTime; + } + }, 100); + } + onMount(() => { loadMusic(); + startProgressInterval(); + + return () => { + if (progressInterval) clearInterval(progressInterval); + if (audioEl) { + audioEl.pause(); + audioEl.src = ""; + } + }; });
+
-
+

Music

{#if !loading && !error} {items.length} Songs {/if}
- +
+
+ + +
+ +
- {#if loading} -
Yükleniyor…
- {:else if error} -
{error}
- {:else if !items.length} -
Henüz müzik videosu yok.
- {:else} -
- {#each items as item, idx (item.id)} -
-
{String(idx + 1).padStart(2, "0")}
-
- {#if thumbnailURL(item)} - {item.title} + +
+ {#if loading} +
+
+

Yükleniyor…

+
+ {:else if error} +
+ +

{error}

+
+ {:else if !items.length} +
+ +

Henüz müzik dosyası yok.

+
+ {:else if viewMode === 'list'} + +
+
+
#
+
+
Başlık
+
Kaynak
+
+
+
+ {#each items as item, idx (item.id)} +
playTrack(item, idx)} + on:dblclick={() => { + if (currentTrack?.id === item.id) togglePlay(); + }} + > +
+ {#if currentTrack?.id === item.id && isPlaying} +
+ + + +
+ {:else} + {String(idx + 1).padStart(2, "0")} + {/if} +
+
+ {#if thumbnailURL(item)} + {item.title} + {:else} +
+ +
+ {/if} +
+
+
{cleanFileName(item.title)}
+
{sourceLabel(item)}
+
+
+ {formatDuration(item.mediaInfo?.format?.duration || item.duration)} +
+
+ + + + +
+
+ {/each} +
+ {:else} + +
+ {#each items as item, idx (item.id)} +
playTrack(item, idx)} + > +
+ {#if thumbnailURL(item)} + {item.title} + {:else} +
+ +
+ {/if} +
+ +
+ {#if currentTrack?.id === item.id && isPlaying} +
+ +
+ {/if} +
+
+
{cleanFileName(item.title)}
+
{sourceLabel(item)}
+
+ {formatDuration(item.mediaInfo?.format?.duration || item.duration)} +
+
+
+ {/each} +
+ {/if} +
+ + + {#if currentTrack} +
+ + +
+ +
+
+ {#if thumbnailURL(currentTrack)} + {currentTrack.title} {:else}
@@ -95,191 +378,925 @@ {/if}
-
{cleanFileName(item.title)}
-
{sourceLabel(item)}
-
-
- {formatDuration(item.mediaInfo?.format?.duration || item.duration)} -
-
- - - - - - +
{cleanFileName(currentTrack.title)}
+
{sourceLabel(currentTrack)}
+
- {/each} + + +
+ + + +
+ + +
+ {formatTime(currentTime)} +
+ +
+ {formatTime(duration)} +
+ + +
+
+ +
+ +
+
+ + + +
+
{/if}
diff --git a/server/server.js b/server/server.js index cf94a9f..18d333c 100644 --- a/server/server.js +++ b/server/server.js @@ -8,7 +8,7 @@ import mime from "mime-types"; import { fileURLToPath } from "url"; import { exec, spawn } from "child_process"; import crypto from "crypto"; // 🔒 basit token üretimi için -import { getSystemDiskInfo } from "./utils/diskSpace.js"; +import { getDiskSpace, getDownloadsSize } from "./utils/diskSpace.js"; import { createAuth } from "./modules/auth.js"; import { buildHealthReport, healthRouter } from "./modules/health.js"; import { restoreTorrentsFromDisk } from "./modules/state.js"; @@ -760,7 +760,7 @@ function startYoutubeDownload(url) { youtubeJobs.set(job.id, job); launchYoutubeJob(job); console.log(`▶️ YouTube indirmesi başlatıldı: ${job.url}`); - broadcastSnapshot(); + scheduleSnapshotBroadcast(); return job; } @@ -1033,7 +1033,7 @@ function launchPornhubJob(job) { binary, args }); - broadcastSnapshot(); + scheduleSnapshotBroadcast(); }); } @@ -1067,7 +1067,7 @@ function startYoutubeStage(job, fileName) { }; job.currentStage = stage; job.stages.push(stage); - broadcastSnapshot(); + scheduleSnapshotBroadcast(); } function updateYoutubeProgress(job, match) { @@ -1104,7 +1104,7 @@ function updateYoutubeProgress(job, match) { if (Number.isFinite(speedValue) && speedUnit) { job.downloadSpeed = bytesFromHuman(speedValue, speedUnit); } - broadcastSnapshot(); + scheduleSnapshotBroadcast(); } async function finalizeYoutubeJob(job, exitCode) { @@ -1124,7 +1124,7 @@ async function finalizeYoutubeJob(job, exitCode) { args: job.debug?.args, lastLines: tailLines }); - broadcastSnapshot(); + scheduleSnapshotBroadcast(); return; } if (exitCode !== 0 && fallbackMedia) { @@ -1153,7 +1153,7 @@ async function finalizeYoutubeJob(job, exitCode) { savePath: job.savePath, lastLines: job.debug?.logs?.slice(-8) || [] }); - broadcastSnapshot(); + scheduleSnapshotBroadcast(); return; } @@ -1262,13 +1262,13 @@ async function finalizeYoutubeJob(job, exitCode) { primaryMediaInfo: mediaInfo }); broadcastFileUpdate(job.folderId); - broadcastSnapshot(); + scheduleSnapshotBroadcast(); broadcastDiskSpace(); console.log(`✅ YouTube indirmesi tamamlandı: ${job.title}`); } catch (err) { job.state = "error"; job.error = err?.message || "YouTube indirimi tamamlanamadı"; - broadcastSnapshot(); + scheduleSnapshotBroadcast(); } } @@ -1504,7 +1504,7 @@ function removeYoutubeJob(jobId, { removeFiles = true } = {}) { console.warn("YT cache silinemedi:", err.message); } } - broadcastSnapshot(); + scheduleSnapshotBroadcast(); if (filesRemoved) { broadcastFileUpdate(job.folderId); broadcastDiskSpace(); @@ -4372,25 +4372,108 @@ function broadcastFileUpdate(rootFolder) { wss.clients.forEach((c) => c.readyState === 1 && c.send(data)); } -function broadcastDiskSpace() { - if (!wss) return; - getSystemDiskInfo(DOWNLOAD_DIR).then(diskInfo => { - const data = JSON.stringify({ - type: "diskSpace", - data: diskInfo - }); - wss.clients.forEach((c) => c.readyState === 1 && c.send(data)); - }).catch(err => { - console.error("❌ Disk space broadcast error:", err.message); - }); +const DISK_SPACE_CACHE_TTL_MS = 30000; +const DOWNLOADS_SIZE_CACHE_TTL_MS = 5 * 60 * 1000; +let diskSpaceCache = { value: null, fetchedAt: 0 }; +let downloadsSizeCache = { value: null, fetchedAt: 0 }; +let diskInfoInFlight = null; +let lastDiskSpacePayload = null; + +async function getCachedDiskInfo({ force = false } = {}) { + const now = Date.now(); + const diskFresh = + !force && diskSpaceCache.value && now - diskSpaceCache.fetchedAt < DISK_SPACE_CACHE_TTL_MS; + const downloadsFresh = + !force && + downloadsSizeCache.value && + now - downloadsSizeCache.fetchedAt < DOWNLOADS_SIZE_CACHE_TTL_MS; + + if (diskFresh && downloadsFresh) { + return { + ...diskSpaceCache.value, + downloads: downloadsSizeCache.value, + timestamp: new Date().toISOString() + }; + } + + if (diskInfoInFlight) return diskInfoInFlight; + + diskInfoInFlight = (async () => { + const diskSpace = diskFresh + ? diskSpaceCache.value + : await getDiskSpace(DOWNLOAD_DIR); + if (!diskFresh) { + diskSpaceCache = { value: diskSpace, fetchedAt: now }; + } + + const downloadsSize = downloadsFresh + ? downloadsSizeCache.value + : await getDownloadsSize(DOWNLOAD_DIR); + if (!downloadsFresh) { + downloadsSizeCache = { value: downloadsSize, fetchedAt: now }; + } + + return { + ...diskSpace, + downloads: downloadsSize, + timestamp: new Date().toISOString() + }; + })(); + + try { + return await diskInfoInFlight; + } finally { + diskInfoInFlight = null; + } } +function broadcastDiskSpace() { + if (!wss || !hasActiveWsClients()) return; + getCachedDiskInfo() + .then((diskInfo) => { + const data = JSON.stringify({ + type: "diskSpace", + data: diskInfo + }); + if (data === lastDiskSpacePayload) return; + lastDiskSpacePayload = data; + wss.clients.forEach((c) => c.readyState === 1 && c.send(data)); + }) + .catch((err) => { + console.error("❌ Disk space broadcast error:", err.message); + }); +} + +let lastSnapshotPayload = null; +const SNAPSHOT_DEBOUNCE_MS = 1000; +let snapshotTimer = null; +let lastSnapshotAt = 0; + function broadcastSnapshot() { - if (!wss) return; + if (!wss || !hasActiveWsClients()) return; const data = JSON.stringify({ type: "progress", torrents: snapshot() }); + if (data === lastSnapshotPayload) return; + lastSnapshotPayload = data; wss.clients.forEach((c) => c.readyState === 1 && c.send(data)); } +function scheduleSnapshotBroadcast() { + if (!wss || !hasActiveWsClients()) return; + const now = Date.now(); + const remaining = SNAPSHOT_DEBOUNCE_MS - (now - lastSnapshotAt); + if (remaining <= 0) { + lastSnapshotAt = now; + broadcastSnapshot(); + return; + } + if (snapshotTimer) return; + snapshotTimer = setTimeout(() => { + snapshotTimer = null; + lastSnapshotAt = Date.now(); + broadcastSnapshot(); + }, remaining); +} + let mediaRescanTask = null; let pendingMediaRescan = { movies: false, tv: false }; let lastMediaRescanReason = "manual"; @@ -4520,22 +4603,73 @@ function inferMediaFlagsFromTrashEntry(entry) { return { movies: true, tv: false }; } +const THUMBNAIL_CHECK_INTERVAL_MS = 15000; + +function hasActiveWsClients() { + if (!wss) return false; + let hasActive = false; + wss.clients.forEach((c) => { + if (c.readyState === 1) hasActive = true; + }); + return hasActive; +} + +function ensureTorrentSnapshotCache(entry) { + if (!entry) return entry; + const { torrent, savePath } = entry; + if (!entry.rootFolder && savePath) entry.rootFolder = path.basename(savePath); + + if (!entry.filesSnapshot && Array.isArray(torrent?.files)) { + entry.filesSnapshot = torrent.files.map((f, i) => ({ + index: i, + name: f.name, + length: f.length + })); + } + + if ( + entry.bestVideoIndex === undefined || + entry.bestVideoIndex === null + ) { + entry.bestVideoIndex = pickBestVideoFile(torrent); + } + + const bestVideo = + torrent?.files?.[entry.bestVideoIndex] || torrent?.files?.[0]; + if (!bestVideo || !savePath || !entry.rootFolder) return entry; + + const now = Date.now(); + const shouldCheckThumbnail = + !entry.thumbnail || + !entry.thumbnailCheckedAt || + now - entry.thumbnailCheckedAt > THUMBNAIL_CHECK_INTERVAL_MS; + + if (shouldCheckThumbnail) { + const relPath = path.join(entry.rootFolder, bestVideo.path); + const { relThumb, absThumb } = getVideoThumbnailPaths(relPath); + entry.thumbnailCheckedAt = now; + entry.thumbnailRelPath = relThumb; + + if (fs.existsSync(absThumb)) { + entry.thumbnail = thumbnailUrl(relThumb); + entry.thumbnailQueued = false; + } else if (torrent?.progress === 1 || torrent?.done) { + if (!entry.thumbnailQueued) { + queueVideoThumbnail(path.join(savePath, bestVideo.path), relPath); + entry.thumbnailQueued = true; + } + } + } + + return entry; +} + // --- Snapshot (thumbnail dahil, tracker + tarih eklendi) --- function snapshot() { - const torrentEntries = Array.from(torrents.values()).map( - ({ torrent, selectedIndex, savePath, added, paused }) => { - const rootFolder = path.basename(savePath); - const bestVideoIndex = pickBestVideoFile(torrent); - const bestVideo = torrent.files[bestVideoIndex]; - let thumbnail = null; - - if (bestVideo) { - const relPath = path.join(rootFolder, bestVideo.path); - const { relThumb, absThumb } = getVideoThumbnailPaths(relPath); - if (fs.existsSync(absThumb)) thumbnail = thumbnailUrl(relThumb); - else if (torrent.progress === 1) - queueVideoThumbnail(path.join(savePath, bestVideo.path), relPath); - } + const torrentEntries = Array.from(torrents.values()).map((entry) => { + const { torrent, selectedIndex, savePath, added, paused } = entry; + ensureTorrentSnapshotCache(entry); + const rootFolder = entry?.rootFolder || path.basename(savePath); return { infoHash: torrent.infoHash, @@ -4550,13 +4684,9 @@ function snapshot() { added, savePath, // 🆕 BURASI! paused: paused || false, // Pause durumunu ekle - files: torrent.files.map((f, i) => ({ - index: i, - name: f.name, - length: f.length - })), + files: entry?.filesSnapshot || [], selectedIndex, - thumbnail + thumbnail: entry?.thumbnail || null }; } ); @@ -4576,9 +4706,24 @@ function wireTorrent(torrent, { savePath, added, respond, restored = false }) { selectedIndex: 0, savePath, added, - paused: false + paused: false, + filesSnapshot: null, + thumbnail: null, + thumbnailCheckedAt: 0, + thumbnailQueued: false, + bestVideoIndex: null, + rootFolder: savePath ? path.basename(savePath) : null }); + const scheduleTorrentSnapshot = () => scheduleSnapshotBroadcast(); + torrent.on("download", scheduleTorrentSnapshot); + torrent.on("upload", scheduleTorrentSnapshot); + torrent.on("wire", scheduleTorrentSnapshot); + torrent.on("noPeers", scheduleTorrentSnapshot); + torrent.on("metadata", scheduleTorrentSnapshot); + torrent.on("warning", scheduleTorrentSnapshot); + torrent.on("error", scheduleTorrentSnapshot); + torrent.on("ready", () => { onTorrentReady({ torrent, savePath, added, respond, restored }); }); @@ -4590,12 +4735,19 @@ function wireTorrent(torrent, { savePath, added, respond, restored = false }) { function onTorrentReady({ torrent, savePath, added, respond }) { const selectedIndex = pickBestVideoFile(torrent); + const existing = torrents.get(torrent.infoHash) || {}; torrents.set(torrent.infoHash, { torrent, selectedIndex, savePath, added, - paused: false + paused: false, + filesSnapshot: existing.filesSnapshot || null, + thumbnail: existing.thumbnail || null, + thumbnailCheckedAt: existing.thumbnailCheckedAt || 0, + thumbnailQueued: existing.thumbnailQueued || false, + bestVideoIndex: selectedIndex, + rootFolder: savePath ? path.basename(savePath) : existing.rootFolder || null }); const rootFolder = path.basename(savePath); upsertInfoFile(savePath, { @@ -4624,7 +4776,7 @@ function onTorrentReady({ torrent, savePath, added, respond }) { }; if (typeof respond === "function") respond(payload); - broadcastSnapshot(); + scheduleSnapshotBroadcast(); } async function onTorrentDone({ torrent }) { @@ -4862,7 +5014,7 @@ async function onTorrentDone({ torrent }) { infoUpdate.files[bestVideoPath].type || rootType; } - broadcastSnapshot(); + scheduleSnapshotBroadcast(); } // Auth router ve middleware createAuth ile yüklendi @@ -5016,7 +5168,7 @@ app.delete("/api/torrents/:hash", requireAuth, (req, res) => { `ℹ️ ${req.params.hash} torrent'i tamamlandığı için yalnızca Transfers listesinden kaldırıldı; dosyalar tutuldu.` ); } - broadcastSnapshot(); + scheduleSnapshotBroadcast(); res.json({ ok: true, filesRemoved: !isComplete @@ -5152,7 +5304,7 @@ app.post("/api/torrents/toggle-all", requireAuth, (req, res) => { global.pausedTorrents = pausedTorrents; - broadcastSnapshot(); + scheduleSnapshotBroadcast(); res.json({ ok: true, action, @@ -5210,7 +5362,7 @@ app.post("/api/torrents/:hash/toggle", requireAuth, (req, res) => { } global.pausedTorrents = pausedTorrents; - broadcastSnapshot(); + scheduleSnapshotBroadcast(); res.json({ ok: true, action, @@ -5381,16 +5533,16 @@ app.delete("/api/file", requireAuth, (req, res) => { entry?.torrent?.destroy(() => { torrents.delete(matchedInfoHash); console.log(`🧹 Torrent kaydı da temizlendi: ${matchedInfoHash}`); - broadcastSnapshot(); + scheduleSnapshotBroadcast(); // Torrent silindiğinde disk space bilgisini güncelle broadcastDiskSpace(); }); } else { - broadcastSnapshot(); + scheduleSnapshotBroadcast(); } } else { - broadcastSnapshot(); + scheduleSnapshotBroadcast(); } if ( @@ -6947,7 +7099,8 @@ function collectMusicEntries() { ? `https://www.youtube.com/watch?v=${fileMeta.youtube.videoId}` : null, thumbnail, - categories: metadata?.categories || fileMeta?.categories || null + categories: metadata?.categories || fileMeta?.categories || null, + mediaInfo: fileMeta?.mediaInfo || null }); } entries.sort((a, b) => (b.added || 0) - (a.added || 0)); @@ -7335,13 +7488,6 @@ wss.on("connection", (ws) => { } }); -// --- ⏱️ Her 2 saniyede bir aktif torrent durumu yayınla --- -setInterval(() => { - if (torrents.size > 0) { - broadcastSnapshot(); - } -}, 2000); - // --- ⏱️ Her 30 saniyede bir disk space bilgisi yayınla --- setInterval(() => { broadcastDiskSpace(); @@ -7355,7 +7501,9 @@ app.get("/api/disk-space", requireAuth, async (req, res) => { fs.mkdirSync(DOWNLOAD_DIR, { recursive: true }); } - const diskInfo = await getSystemDiskInfo(DOWNLOAD_DIR); + const diskInfo = await getCachedDiskInfo({ + force: req.query?.fresh === "1" + }); res.json(diskInfo); } catch (err) { console.error("❌ Disk space error:", err.message); @@ -7945,7 +8093,7 @@ app.patch("/api/folder", requireAuth, (req, res) => { console.log(`📁 Kök klasör yeniden adlandırıldı: ${rootFolder} -> ${newRootFolder}`); broadcastFileUpdate(rootFolder); broadcastFileUpdate(newRootFolder); - broadcastSnapshot(); + scheduleSnapshotBroadcast(); return res.json({ success: true, message: "Klasör yeniden adlandırıldı",