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/Files.svelte b/client/src/routes/Files.svelte index 1a060b0..9b7f8b7 100644 --- a/client/src/routes/Files.svelte +++ b/client/src/routes/Files.svelte @@ -2130,7 +2130,12 @@ autofocus /> {:else} -
{cleanFileName(entry.displayName)}
+
+ {cleanFileName(entry.displayName)} +
{/if} {:else} @@ -2147,7 +2152,9 @@ {/if}
-
{cleanFileName(entry.name)}
+
+ {cleanFileName(entry.name)} +
{#if entry.progressText} {entry.progressText} @@ -3250,13 +3257,15 @@ } .name { font-weight: 600; - font-size: 14px; + font-size: 13px; overflow: hidden; - white-space: nowrap; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + line-height: 1.3; + max-height: calc(1.3em * 2); text-overflow: ellipsis; - min-height: 20px; /* Dosya isimleri için tutarlı yükseklik */ - display: flex; - align-items: center; + word-break: break-word; } .size { font-size: 12px; @@ -3532,9 +3541,7 @@ display: flex; flex-direction: column; isolation: isolate; - transition: - transform 0.18s ease, - box-shadow 0.18s ease; + transition: box-shadow 0.18s ease; cursor: pointer; } .media-card::after { @@ -3550,7 +3557,7 @@ opacity: 0.22; } .media-card.is-selected { - transform: translateY(-6px) scale(0.965); + transform: none; box-shadow: 0 16px 32px rgba(0, 0, 0, 0.22); } .media-card.is-selected::after { @@ -3574,10 +3581,10 @@ flex-shrink: 0; } .media-card:hover { - transform: translateY(-4px) scale(0.98); + transform: none; } .media-card.is-selected:hover { - transform: translateY(-6px) scale(0.965); + transform: none; } .selection-toggle { position: absolute; @@ -3832,15 +3839,17 @@ } .folder-name { font-weight: 600; - font-size: 15px; + font-size: 14px; color: #2d2d2d; - line-height: 1.35; + line-height: 1.25; word-break: break-word; - min-height: 40px; /* Tek ve çok satırlı isimler için aynı yükseklik */ - display: flex; - align-items: center; - justify-content: center; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; text-align: center; + max-height: calc(1.25em * 2); + overflow: hidden; + text-overflow: ellipsis; } .folder-rename-input { @@ -3877,12 +3886,13 @@ text-align: left; } .folder-card.list-view .folder-name { - white-space: nowrap; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + line-height: 1.25; + max-height: calc(1.25em * 2); overflow: hidden; text-overflow: ellipsis; - min-height: 24px; /* Liste görünümünde tutarlı yükseklik */ - display: flex; - align-items: center; } /* Menü düğmesi ve dropdown stilleri */ 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/docker-compose.yml b/docker-compose.yml index d0a7aa6..833c48f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: build: . container_name: app ports: - - "3001:3001" + - "3005:3001" volumes: - ./downloads:/app/server/downloads - ./cache:/app/server/cache diff --git a/server/server.js b/server/server.js index 3026073..cfb3084 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"; @@ -735,7 +735,7 @@ function startYoutubeDownload(url) { youtubeJobs.set(job.id, job); launchYoutubeJob(job); console.log(`▶️ YouTube indirmesi başlatıldı: ${job.url}`); - broadcastSnapshot(); + scheduleSnapshotBroadcast(); return job; } @@ -888,7 +888,7 @@ function launchYoutubeJob(job) { binary, args }); - broadcastSnapshot(); + scheduleSnapshotBroadcast(); }); } @@ -922,7 +922,7 @@ function startYoutubeStage(job, fileName) { }; job.currentStage = stage; job.stages.push(stage); - broadcastSnapshot(); + scheduleSnapshotBroadcast(); } function updateYoutubeProgress(job, match) { @@ -959,7 +959,7 @@ function updateYoutubeProgress(job, match) { if (Number.isFinite(speedValue) && speedUnit) { job.downloadSpeed = bytesFromHuman(speedValue, speedUnit); } - broadcastSnapshot(); + scheduleSnapshotBroadcast(); } async function finalizeYoutubeJob(job, exitCode) { @@ -979,7 +979,7 @@ async function finalizeYoutubeJob(job, exitCode) { args: job.debug?.args, lastLines: tail }); - broadcastSnapshot(); + scheduleSnapshotBroadcast(); return; } if (exitCode !== 0 && fallbackMedia) { @@ -1007,7 +1007,7 @@ async function finalizeYoutubeJob(job, exitCode) { savePath: job.savePath, lastLines: job.debug?.logs?.slice(-8) || [] }); - broadcastSnapshot(); + scheduleSnapshotBroadcast(); return; } @@ -1065,13 +1065,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(); } } @@ -1248,7 +1248,7 @@ function removeYoutubeJob(jobId, { removeFiles = true } = {}) { console.warn("YT cache silinemedi:", err.message); } } - broadcastSnapshot(); + scheduleSnapshotBroadcast(); if (filesRemoved) { broadcastFileUpdate(job.folderId); broadcastDiskSpace(); @@ -4104,25 +4104,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"; @@ -4252,22 +4335,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, @@ -4282,13 +4416,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 }; } ); @@ -4308,9 +4438,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 }); }); @@ -4322,12 +4467,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, { @@ -4356,7 +4508,7 @@ function onTorrentReady({ torrent, savePath, added, respond }) { }; if (typeof respond === "function") respond(payload); - broadcastSnapshot(); + scheduleSnapshotBroadcast(); } async function onTorrentDone({ torrent }) { @@ -4594,7 +4746,7 @@ async function onTorrentDone({ torrent }) { infoUpdate.files[bestVideoPath].type || rootType; } - broadcastSnapshot(); + scheduleSnapshotBroadcast(); } // Auth router ve middleware createAuth ile yüklendi @@ -4741,7 +4893,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 @@ -4877,7 +5029,7 @@ app.post("/api/torrents/toggle-all", requireAuth, (req, res) => { global.pausedTorrents = pausedTorrents; - broadcastSnapshot(); + scheduleSnapshotBroadcast(); res.json({ ok: true, action, @@ -4935,7 +5087,7 @@ app.post("/api/torrents/:hash/toggle", requireAuth, (req, res) => { } global.pausedTorrents = pausedTorrents; - broadcastSnapshot(); + scheduleSnapshotBroadcast(); res.json({ ok: true, action, @@ -5106,16 +5258,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 ( @@ -6575,7 +6727,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)); @@ -6957,13 +7110,6 @@ wss.on("connection", (ws) => { broadcastDiskSpace(); }); -// --- ⏱️ 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(); @@ -6977,7 +7123,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); @@ -7567,7 +7715,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ı",