Initial commit
This commit is contained in:
69
.gitignore
vendored
Normal file
69
.gitignore
vendored
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# Node modules
|
||||||
|
/node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids *.pid *.seed *.pid.lock
|
||||||
|
|
||||||
|
# Coverage directory
|
||||||
|
.nyc_output/
|
||||||
|
coverage/
|
||||||
|
lcov-report/
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.*.local
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Build / output directories
|
||||||
|
/dist/
|
||||||
|
/build/
|
||||||
|
/output/
|
||||||
|
/. svelte-kit/
|
||||||
|
.svelte-kit/
|
||||||
|
.vite/
|
||||||
|
.tmp/
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# Docker files / volumes
|
||||||
|
docker-compose.override.yml
|
||||||
|
docker-compose.*.yml
|
||||||
|
docker/*-volume/
|
||||||
|
docker/*-data/
|
||||||
|
*.tar
|
||||||
|
*.img
|
||||||
|
|
||||||
|
# OS / IDE stuff
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
*.swp
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.sublime-project
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Media / Download directories (depending on your setup)
|
||||||
|
downloads/
|
||||||
|
movie/movieData/
|
||||||
|
movie/movieData/**/subtitles/
|
||||||
|
movie/movieData/**/poster.jpg
|
||||||
|
movie/movieData/**/backdrop.jpg
|
||||||
|
|
||||||
|
# Torrent / upload temp files
|
||||||
|
/uploads/
|
||||||
|
/uploads/*
|
||||||
|
*.torrent
|
||||||
|
*.part
|
||||||
|
*.temp
|
||||||
|
|
||||||
|
# Other sensitive files
|
||||||
|
/key.pem
|
||||||
|
/cert.pem
|
||||||
|
*.log
|
||||||
7
client/Dockerfile
Normal file
7
client/Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci || npm i
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 5173
|
||||||
|
CMD ["npm","run","dev","--","--host","0.0.0.0"]
|
||||||
18
client/index.html
Normal file
18
client/index.html
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
referrerpolicy="no-referrer"
|
||||||
|
/>
|
||||||
|
<title>du.pe</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
19
client/package.json
Normal file
19
client/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "dupe-client",
|
||||||
|
"version": "1.2.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"dev": "vite --host 0.0.0.0 --port 5173"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"svelte": "^4.2.18",
|
||||||
|
"svelte-routing": "^2.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||||
|
"vite": "^5.4.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
35
client/src/App.svelte
Normal file
35
client/src/App.svelte
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script>
|
||||||
|
import { Router, Route } from "svelte-routing";
|
||||||
|
import Sidebar from "./components/Sidebar.svelte";
|
||||||
|
import Topbar from "./components/Topbar.svelte";
|
||||||
|
import Files from "./routes/Files.svelte";
|
||||||
|
import Transfers from "./routes/Transfers.svelte";
|
||||||
|
import Sharing from "./routes/Sharing.svelte";
|
||||||
|
import Trash from "./routes/Trash.svelte";
|
||||||
|
|
||||||
|
let menuOpen = false;
|
||||||
|
const toggleMenu = () => {
|
||||||
|
menuOpen = !menuOpen;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Router>
|
||||||
|
<div class="app">
|
||||||
|
<Sidebar {menuOpen} />
|
||||||
|
<div class="content">
|
||||||
|
<Topbar on:toggleMenu={toggleMenu} />
|
||||||
|
<Route path="/" component={Files} />
|
||||||
|
<Route path="/transfers" component={Transfers} />
|
||||||
|
<Route path="/sharing" component={Sharing} />
|
||||||
|
<Route path="/trash" component={Trash} />
|
||||||
|
</div>
|
||||||
|
{#if menuOpen}
|
||||||
|
<div
|
||||||
|
class="backdrop show"
|
||||||
|
on:click={() => {
|
||||||
|
menuOpen = false;
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Router>
|
||||||
22
client/src/components/Sidebar.svelte
Normal file
22
client/src/components/Sidebar.svelte
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script>
|
||||||
|
import { Link } from "svelte-routing";
|
||||||
|
export let menuOpen = false;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="sidebar" class:open={menuOpen}>
|
||||||
|
<div class="logo">du.pe</div>
|
||||||
|
<div class="menu">
|
||||||
|
<Link to="/" class="item" getProps={({ isCurrent }) => ({ class: isCurrent ? "item active" : "item" })}>
|
||||||
|
<i class="fa-solid fa-folder icon"></i> Files
|
||||||
|
</Link>
|
||||||
|
<Link to="/transfers" class="item" getProps={({ isCurrent }) => ({ class: isCurrent ? "item active" : "item" })}>
|
||||||
|
<i class="fa-solid fa-arrow-down icon"></i> Transfers
|
||||||
|
</Link>
|
||||||
|
<Link to="/sharing" class="item" getProps={({ isCurrent }) => ({ class: isCurrent ? "item active" : "item" })}>
|
||||||
|
<i class="fa-solid fa-share-nodes icon"></i> Sharing
|
||||||
|
</Link>
|
||||||
|
<Link to="/trash" class="item" getProps={({ isCurrent }) => ({ class: isCurrent ? "item active" : "item" })}>
|
||||||
|
<i class="fa-solid fa-trash icon"></i> Trash
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
20
client/src/components/Topbar.svelte
Normal file
20
client/src/components/Topbar.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from "svelte";
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
let search = "";
|
||||||
|
export let placeholder = "Search files...";
|
||||||
|
const onToggle = () => dispatch("toggleMenu");
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="topbar">
|
||||||
|
<!-- Mobilde görünen hamburger -->
|
||||||
|
<button class="menu-toggle" on:click={onToggle} aria-label="Toggle menu">
|
||||||
|
<i class="fa-solid fa-bars"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="search">
|
||||||
|
<i class="fa-solid fa-magnifying-glass"></i>
|
||||||
|
<input placeholder={placeholder} bind:value={search} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
11
client/src/components/TorrentItem.svelte
Normal file
11
client/src/components/TorrentItem.svelte
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script>
|
||||||
|
export let t;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="torrent">
|
||||||
|
<div style="flex:1">
|
||||||
|
<div style="font-weight:600;">{t.name}</div>
|
||||||
|
<div class="progress"><div style="width:{t.progress * 100}%"></div></div>
|
||||||
|
<div class="small">{Math.round(t.progress * 100)}% - {t.downloadSpeed} KB/s</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
8
client/src/main.js
Normal file
8
client/src/main.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import App from "./App.svelte";
|
||||||
|
import "./styles/main.css";
|
||||||
|
|
||||||
|
const app = new App({
|
||||||
|
target: document.getElementById("app"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
541
client/src/routes/Files.svelte
Normal file
541
client/src/routes/Files.svelte
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { API } from "../utils/api.js";
|
||||||
|
import { cleanFileName } from "../utils/filename.js";
|
||||||
|
|
||||||
|
let files = [];
|
||||||
|
let showModal = false;
|
||||||
|
let selectedVideo = null;
|
||||||
|
let subtitleURL = null;
|
||||||
|
let subtitleLang = "en";
|
||||||
|
let subtitleLabel = "Custom Subtitles";
|
||||||
|
|
||||||
|
// 🎬 Player kontrolleri
|
||||||
|
let videoEl;
|
||||||
|
let isPlaying = false;
|
||||||
|
let currentTime = 0;
|
||||||
|
let duration = 0;
|
||||||
|
let volume = 1;
|
||||||
|
|
||||||
|
// 📂 Dosyaları yükle
|
||||||
|
async function loadFiles() {
|
||||||
|
const r = await fetch(`${API}/api/files`);
|
||||||
|
files = await r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (!bytes) return "0 MB";
|
||||||
|
if (bytes < 1e6) return (bytes / 1e3).toFixed(1) + " KB";
|
||||||
|
if (bytes < 1e9) return (bytes / 1e6).toFixed(1) + " MB";
|
||||||
|
return (bytes / 1e9).toFixed(2) + " GB";
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal(f) {
|
||||||
|
selectedVideo = f;
|
||||||
|
showModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
showModal = false;
|
||||||
|
selectedVideo = null;
|
||||||
|
subtitleURL = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🎞️ Video kontrolleri
|
||||||
|
function togglePlay() {
|
||||||
|
if (!videoEl) return;
|
||||||
|
if (isPlaying) videoEl.pause();
|
||||||
|
else videoEl.play();
|
||||||
|
isPlaying = !isPlaying;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProgress() {
|
||||||
|
currentTime = videoEl?.currentTime || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDuration() {
|
||||||
|
duration = videoEl?.duration || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seekVideo(e) {
|
||||||
|
if (!videoEl) return;
|
||||||
|
const newTime = parseFloat(e.target.value);
|
||||||
|
if (Math.abs(videoEl.currentTime - newTime) > 0.2) {
|
||||||
|
videoEl.currentTime = newTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeVolume(e) {
|
||||||
|
if (!videoEl) return;
|
||||||
|
const val = parseFloat(e.target.value);
|
||||||
|
videoEl.volume = val;
|
||||||
|
e.target.style.setProperty("--fill", (val || 0) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFullscreen() {
|
||||||
|
if (!videoEl) return;
|
||||||
|
if (document.fullscreenElement) document.exitFullscreen();
|
||||||
|
else videoEl.requestFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(seconds) {
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0");
|
||||||
|
const s = Math.floor(seconds % 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0");
|
||||||
|
return `${m}:${s}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubtitleUpload(e) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
const ext = file.name.split(".").pop().toLowerCase();
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
const content =
|
||||||
|
typeof ev.target.result === "string"
|
||||||
|
? ev.target.result
|
||||||
|
: decoder.decode(ev.target.result);
|
||||||
|
if (ext === "srt") {
|
||||||
|
const vttText =
|
||||||
|
"\uFEFFWEBVTT\n\n" + content.replace(/\r+/g, "").replace(/,/g, ".");
|
||||||
|
const blob = new Blob([vttText], {
|
||||||
|
type: "text/vtt;charset=utf-8"
|
||||||
|
});
|
||||||
|
subtitleURL = URL.createObjectURL(blob);
|
||||||
|
} else if (ext === "vtt") {
|
||||||
|
const blob = new Blob([content], {
|
||||||
|
type: "text/vtt;charset=utf-8"
|
||||||
|
});
|
||||||
|
subtitleURL = URL.createObjectURL(blob);
|
||||||
|
} else {
|
||||||
|
alert("Yalnızca .srt veya .vtt dosyaları destekleniyor.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEsc(e) {
|
||||||
|
if (e.key === "Escape" && showModal) closeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadFiles();
|
||||||
|
const slider = document.querySelector(".volume-slider");
|
||||||
|
if (slider) {
|
||||||
|
slider.value = volume;
|
||||||
|
slider.style.setProperty("--fill", slider.value * 100);
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onEsc);
|
||||||
|
return () => window.removeEventListener("keydown", onEsc);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="files">
|
||||||
|
<h2>Media Library</h2>
|
||||||
|
|
||||||
|
{#if files.length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<div style="font-size:42px"><i class="fa-solid fa-folder-open"></i></div>
|
||||||
|
<div style="font-weight:700">No media found</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="gallery">
|
||||||
|
{#each files as f}
|
||||||
|
<div class="media-card" on:click={() => openModal(f)}>
|
||||||
|
{#if f.thumbnail}
|
||||||
|
<img src={`${API}${f.thumbnail}`} alt={f.name} class="thumb" />
|
||||||
|
{:else}
|
||||||
|
<div class="thumb placeholder">
|
||||||
|
<i class="fa-regular fa-image"></i>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="info">
|
||||||
|
<div class="name">{cleanFileName(f.name)}</div>
|
||||||
|
<div class="size">{formatSize(f.size)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if showModal && selectedVideo}
|
||||||
|
<div class="modal-overlay" on:click={closeModal}>
|
||||||
|
<div class="modal-content" on:click|stopPropagation>
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="video-title">{selectedVideo.name}</div>
|
||||||
|
<button class="close-btn" on:click={closeModal}>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="custom-player">
|
||||||
|
<video
|
||||||
|
bind:this={videoEl}
|
||||||
|
src={`${API}/media/${encodeURIComponent(selectedVideo.name)}`}
|
||||||
|
class="video-element"
|
||||||
|
on:timeupdate={updateProgress}
|
||||||
|
on:loadedmetadata={() => {
|
||||||
|
updateDuration();
|
||||||
|
const slider = document.querySelector(".volume-slider");
|
||||||
|
if (slider) {
|
||||||
|
slider.value = volume;
|
||||||
|
slider.style.setProperty("--fill", slider.value * 100);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#if subtitleURL}
|
||||||
|
<track
|
||||||
|
kind="subtitles"
|
||||||
|
src={subtitleURL}
|
||||||
|
srclang={subtitleLang}
|
||||||
|
label={subtitleLabel}
|
||||||
|
default
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</video>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<div class="top-controls">
|
||||||
|
<button class="control-btn" on:click={togglePlay}>
|
||||||
|
{#if isPlaying}<i class="fa-solid fa-pause"></i>{:else}<i
|
||||||
|
class="fa-solid fa-play"
|
||||||
|
></i>{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="right-controls">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
bind:value={volume}
|
||||||
|
on:input={changeVolume}
|
||||||
|
class="volume-slider"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button class="control-btn" on:click={toggleFullscreen}>
|
||||||
|
<i class="fa-solid fa-expand"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={`${API}/downloads/${selectedVideo.name}`}
|
||||||
|
download={selectedVideo.name}
|
||||||
|
class="control-btn"
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-download"></i>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<label class="control-btn subtitle-icon" title="Add subtitles">
|
||||||
|
<i class="fa-solid fa-closed-captioning"></i>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".srt,.vtt"
|
||||||
|
on:change={handleSubtitleUpload}
|
||||||
|
style="display: none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bottom-controls">
|
||||||
|
<span class="time">
|
||||||
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max={duration}
|
||||||
|
step="0.1"
|
||||||
|
bind:value={currentTime}
|
||||||
|
on:input={seekVideo}
|
||||||
|
class="progress-slider"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* === GALERİ === */
|
||||||
|
.gallery {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.media-card {
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.media-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
.thumb {
|
||||||
|
width: 100%;
|
||||||
|
height: 150px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.thumb.placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 42px;
|
||||||
|
background: #ddd;
|
||||||
|
}
|
||||||
|
.info {
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.size {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === MODAL & PLAYER (Transfers.svelte ile birebir) === */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
width: 70%;
|
||||||
|
height: 70%;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 0 30px rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: #2a2a2a;
|
||||||
|
padding: 10px 16px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-title {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-player {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-element {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #000;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.video-element:focus {
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Kontroller === */
|
||||||
|
.controls {
|
||||||
|
background: #1c1c1c;
|
||||||
|
padding: 10px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.control-btn:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Ses Seviyesi Kaydırıcısı === */
|
||||||
|
.volume-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 100px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
#ff3b30 calc(var(--fill, 100%) * 1%),
|
||||||
|
rgba(255, 255, 255, 0.3) calc(var(--fill, 100%) * 1%)
|
||||||
|
);
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider::-webkit-slider-runnable-track {
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.volume-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: -4px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.volume-slider::-webkit-slider-thumb:hover {
|
||||||
|
transform: scale(1.3);
|
||||||
|
}
|
||||||
|
.volume-slider::-moz-range-thumb {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.volume-slider::-moz-range-thumb:hover {
|
||||||
|
transform: scale(1.3);
|
||||||
|
}
|
||||||
|
.volume-slider::-moz-range-progress {
|
||||||
|
height: 4px;
|
||||||
|
background: #ff3b30;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Alt Kontroller === */
|
||||||
|
.bottom-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-slider {
|
||||||
|
flex: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: #27ae60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 90px;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Responsive === */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.modal-content {
|
||||||
|
width: 90%;
|
||||||
|
height: 75%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.gallery {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
}
|
||||||
|
.modal-content {
|
||||||
|
width: 95%;
|
||||||
|
height: 70%;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.controls {
|
||||||
|
padding: 6px 10px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.volume-slider {
|
||||||
|
width: 70px;
|
||||||
|
}
|
||||||
|
.time {
|
||||||
|
font-size: 11px;
|
||||||
|
min-width: 70px;
|
||||||
|
}
|
||||||
|
.video-title {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.close-btn {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.modal-content {
|
||||||
|
width: 98%;
|
||||||
|
height: 75%;
|
||||||
|
}
|
||||||
|
.volume-slider {
|
||||||
|
width: 50px;
|
||||||
|
}
|
||||||
|
.bottom-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
4
client/src/routes/Sharing.svelte
Normal file
4
client/src/routes/Sharing.svelte
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<section class="files">
|
||||||
|
<h2>Sharing</h2>
|
||||||
|
<div class="empty"><p>No shared files yet.</p></div>
|
||||||
|
</section>
|
||||||
885
client/src/routes/Transfers.svelte
Normal file
885
client/src/routes/Transfers.svelte
Normal file
@@ -0,0 +1,885 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { API } from "../utils/api.js";
|
||||||
|
|
||||||
|
let torrents = [];
|
||||||
|
let ws;
|
||||||
|
|
||||||
|
// Modal / player state
|
||||||
|
let showModal = false;
|
||||||
|
let selectedVideo = null;
|
||||||
|
let subtitleURL = null;
|
||||||
|
let subtitleLang = "en";
|
||||||
|
let subtitleLabel = "Custom Subtitles";
|
||||||
|
|
||||||
|
// Player kontrolleri
|
||||||
|
let videoEl;
|
||||||
|
let isPlaying = false;
|
||||||
|
let currentTime = 0;
|
||||||
|
let duration = 0;
|
||||||
|
let volume = 1;
|
||||||
|
|
||||||
|
// --- WebSocket & API
|
||||||
|
function wsConnect() {
|
||||||
|
const url = API.replace("http", "ws");
|
||||||
|
ws = new WebSocket(url);
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
if (d.type === "progress") torrents = d.torrents || [];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function list() {
|
||||||
|
const r = await fetch(`${API}/api/torrents`);
|
||||||
|
torrents = await r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upload(e) {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (!f) return;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("torrent", f);
|
||||||
|
await fetch(`${API}/api/transfer`, { method: "POST", body: fd });
|
||||||
|
await list();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addMagnet() {
|
||||||
|
const m = prompt("Magnet linki:");
|
||||||
|
if (!m) return;
|
||||||
|
await fetch(`${API}/api/transfer`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ magnet: m })
|
||||||
|
});
|
||||||
|
await list();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectFile(hash, index) {
|
||||||
|
ws?.send(JSON.stringify({ type: "select", infoHash: hash, index }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeTorrent(hash) {
|
||||||
|
if (!confirm("Bu transferi silmek istediğine emin misin?")) return;
|
||||||
|
await fetch(`${API}/api/torrents/${hash}`, { method: "DELETE" });
|
||||||
|
await list();
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamURL(hash) {
|
||||||
|
return `${API}/stream/${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSpeed(bytesPerSec) {
|
||||||
|
if (!bytesPerSec || bytesPerSec <= 0) return "0 MB/s";
|
||||||
|
return (bytesPerSec / 1e6).toFixed(2) + " MB/s";
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal(t) {
|
||||||
|
// torrent içinde seçilmiş dosya var mı?
|
||||||
|
const selectedFile =
|
||||||
|
t.files?.find((f) => f.index === t.selectedIndex) || t.files?.[0];
|
||||||
|
if (!selectedFile) {
|
||||||
|
alert("Bu torrentte oynatılabilir video dosyası bulunamadı!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedVideo = {
|
||||||
|
...t,
|
||||||
|
fileIndex: selectedFile.index,
|
||||||
|
fileName: selectedFile.name
|
||||||
|
};
|
||||||
|
showModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
showModal = false;
|
||||||
|
selectedVideo = null;
|
||||||
|
subtitleURL = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Altyazı işlemleri ---
|
||||||
|
function detectSubtitleLang(text) {
|
||||||
|
const lower = (text || "").toLowerCase();
|
||||||
|
if (lower.includes("ş") || lower.includes("ğ") || lower.includes("ı"))
|
||||||
|
return { code: "tr", label: "Türkçe" };
|
||||||
|
if (lower.includes("é") || lower.includes("è") || lower.includes("à"))
|
||||||
|
return { code: "fr", label: "Français" };
|
||||||
|
if (lower.includes("¿") || lower.includes("¡") || lower.includes("ñ"))
|
||||||
|
return { code: "es", label: "Español" };
|
||||||
|
if (lower.includes("ß") || lower.includes("ä") || lower.includes("ü"))
|
||||||
|
return { code: "de", label: "Deutsch" };
|
||||||
|
return { code: "en", label: "English" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function srtToVtt(srtText) {
|
||||||
|
const utf8BOM = "\uFEFF";
|
||||||
|
return (
|
||||||
|
utf8BOM +
|
||||||
|
"WEBVTT\n\n" +
|
||||||
|
srtText
|
||||||
|
.replace(/\r+/g, "")
|
||||||
|
.replace(/^\s+|\s+$/g, "")
|
||||||
|
.split("\n\n")
|
||||||
|
.map((block) => {
|
||||||
|
const lines = block.split("\n");
|
||||||
|
if (lines.length >= 2) {
|
||||||
|
const time = lines[1]
|
||||||
|
.replace(/,/g, ".")
|
||||||
|
.replace(/(\d{2}):(\d{2}):(\d{2})/g, "$1:$2:$3");
|
||||||
|
return lines.slice(1).join("\n").replace(lines[1], time);
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
})
|
||||||
|
.join("\n\n")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubtitleUpload(e) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const ext = file.name.split(".").pop().toLowerCase();
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = (ev) => {
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
const content =
|
||||||
|
typeof ev.target.result === "string"
|
||||||
|
? ev.target.result
|
||||||
|
: decoder.decode(ev.target.result);
|
||||||
|
|
||||||
|
const detected = detectSubtitleLang(content);
|
||||||
|
subtitleLang = detected.code;
|
||||||
|
subtitleLabel = detected.label;
|
||||||
|
|
||||||
|
if (ext === "srt") {
|
||||||
|
const vttText = srtToVtt(content);
|
||||||
|
const blob = new Blob([vttText], {
|
||||||
|
type: "text/vtt;charset=utf-8"
|
||||||
|
});
|
||||||
|
subtitleURL = URL.createObjectURL(blob);
|
||||||
|
} else if (ext === "vtt") {
|
||||||
|
const blob = new Blob([content], {
|
||||||
|
type: "text/vtt;charset=utf-8"
|
||||||
|
});
|
||||||
|
subtitleURL = URL.createObjectURL(blob);
|
||||||
|
} else {
|
||||||
|
alert("Yalnızca .srt veya .vtt dosyaları destekleniyor.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsArrayBuffer(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESC ile kapatma
|
||||||
|
function onEsc(e) {
|
||||||
|
if (e.key === "Escape" && showModal) closeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Player kontrolleri
|
||||||
|
function togglePlay() {
|
||||||
|
if (!videoEl) return;
|
||||||
|
if (isPlaying) videoEl.pause();
|
||||||
|
else videoEl.play();
|
||||||
|
isPlaying = !isPlaying;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProgress() {
|
||||||
|
currentTime = videoEl?.currentTime || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDuration() {
|
||||||
|
duration = videoEl?.duration || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seekVideo(e) {
|
||||||
|
if (!videoEl) return;
|
||||||
|
const newTime = parseFloat(e.target.value);
|
||||||
|
if (Math.abs(videoEl.currentTime - newTime) > 0.2) {
|
||||||
|
videoEl.currentTime = newTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeVolume(e) {
|
||||||
|
if (!videoEl) return;
|
||||||
|
const val = parseFloat(e.target.value);
|
||||||
|
videoEl.volume = val;
|
||||||
|
// Slider dolum rengini CSS değişkeniyle güncelle
|
||||||
|
e.target.style.setProperty("--fill", (val || 0) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleFullscreen() {
|
||||||
|
if (!videoEl) return;
|
||||||
|
if (document.fullscreenElement) document.exitFullscreen();
|
||||||
|
else videoEl.requestFullscreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(seconds) {
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0");
|
||||||
|
const s = Math.floor(seconds % 60)
|
||||||
|
.toString()
|
||||||
|
.padStart(2, "0");
|
||||||
|
return `${m}:${s}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
list();
|
||||||
|
wsConnect();
|
||||||
|
|
||||||
|
// volume slider başlangıç dolumu
|
||||||
|
const slider = document.querySelector(".volume-slider");
|
||||||
|
if (slider) {
|
||||||
|
slider.value = volume; // 1
|
||||||
|
slider.style.setProperty("--fill", slider.value * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", onEsc);
|
||||||
|
return () => window.removeEventListener("keydown", onEsc);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="files">
|
||||||
|
<h2>Transfers</h2>
|
||||||
|
|
||||||
|
<div style="display:flex; gap:10px; margin-bottom:10px;">
|
||||||
|
<label class="btn-primary" style="cursor:pointer;">
|
||||||
|
<i class="fa-solid fa-plus btn-icon"></i> NEW TRANSFER
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".torrent"
|
||||||
|
on:change={upload}
|
||||||
|
style="display:none;"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button class="btn-primary" on:click={addMagnet}>
|
||||||
|
<i class="fa-solid fa-magnet btn-icon"></i> Magnet
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if torrents.length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<div style="font-size:42px">➕</div>
|
||||||
|
<div style="font-weight:700">No files whatsoever!</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="torrent-list">
|
||||||
|
{#each torrents as t (t.infoHash)}
|
||||||
|
<div class="torrent" on:click={() => openModal(t)}>
|
||||||
|
{#if t.thumbnail}
|
||||||
|
<img src={`${API}${t.thumbnail}`} alt="thumb" class="thumb" />
|
||||||
|
{:else}
|
||||||
|
<div class="thumb placeholder">📷</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="torrent-info">
|
||||||
|
<div class="torrent-header">
|
||||||
|
<div class="torrent-name">{t.name}</div>
|
||||||
|
<button
|
||||||
|
class="remove-btn"
|
||||||
|
on:click|stopPropagation={() => removeTorrent(t.infoHash)}
|
||||||
|
title="Sil">❌</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="torrent-hash">
|
||||||
|
Hash: {t.infoHash} | Tracker: {t.tracker ?? "Unknown"} | Added:
|
||||||
|
{t.added ? new Date(t.added).toLocaleString() : "Unknown"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="torrent-files">
|
||||||
|
{#each t.files as f}
|
||||||
|
<div class="file-row">
|
||||||
|
<button
|
||||||
|
on:click|stopPropagation={() =>
|
||||||
|
selectFile(t.infoHash, f.index)}
|
||||||
|
>
|
||||||
|
{f.index === t.selectedIndex ? "Selected" : "Select"}
|
||||||
|
</button>
|
||||||
|
<div class="filename">{f.name}</div>
|
||||||
|
<div class="filesize">
|
||||||
|
{(f.length / 1e6).toFixed(1)} MB
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div
|
||||||
|
class="progress"
|
||||||
|
style="width:{(t.progress || 0) * 100}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="progress-text">
|
||||||
|
{#if (t.progress || 0) < 1}
|
||||||
|
{(t.progress * 100).toFixed(1)}% •
|
||||||
|
{t.downloaded ? (t.downloaded / 1e6).toFixed(1) : 0} MB •
|
||||||
|
{formatSpeed(t.downloadSpeed)} ↓ •
|
||||||
|
{t.numPeers ?? 0} peers
|
||||||
|
{:else}
|
||||||
|
100.0% • {(t.downloaded / 1e6).toFixed(1)} MB
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if showModal && selectedVideo}
|
||||||
|
<div class="modal-overlay" on:click={closeModal}>
|
||||||
|
<div class="modal-content" on:click|stopPropagation>
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="video-title">{selectedVideo.name}</div>
|
||||||
|
<button class="close-btn" on:click={closeModal}>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="custom-player">
|
||||||
|
<video
|
||||||
|
bind:this={videoEl}
|
||||||
|
src={`${API}/stream/${selectedVideo.infoHash}?index=${selectedVideo.fileIndex}`}
|
||||||
|
class="video-element"
|
||||||
|
on:timeupdate={updateProgress}
|
||||||
|
on:loadedmetadata={() => {
|
||||||
|
updateDuration();
|
||||||
|
const slider = document.querySelector(".volume-slider");
|
||||||
|
if (slider) {
|
||||||
|
slider.value = volume;
|
||||||
|
slider.style.setProperty("--fill", slider.value * 100);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#if subtitleURL}
|
||||||
|
<track
|
||||||
|
kind="subtitles"
|
||||||
|
src={subtitleURL}
|
||||||
|
srclang={subtitleLang}
|
||||||
|
label={subtitleLabel}
|
||||||
|
default
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</video>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<div class="top-controls">
|
||||||
|
<button class="control-btn" on:click={togglePlay}>
|
||||||
|
{#if isPlaying}<i class="fa-solid fa-pause"></i>{:else}<i
|
||||||
|
class="fa-solid fa-play"
|
||||||
|
></i>{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="right-controls">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
bind:value={volume}
|
||||||
|
on:input={changeVolume}
|
||||||
|
class="volume-slider"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button class="control-btn" on:click={toggleFullscreen}>
|
||||||
|
<i class="fa-solid fa-expand"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={streamURL(selectedVideo.infoHash)}
|
||||||
|
download={selectedVideo.name}
|
||||||
|
class="control-btn"
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-download"></i>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<label class="control-btn subtitle-icon" title="Add subtitles">
|
||||||
|
<i class="fa-solid fa-closed-captioning"></i>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".srt,.vtt"
|
||||||
|
on:change={handleSubtitleUpload}
|
||||||
|
style="display: none"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bottom-controls">
|
||||||
|
<span class="time">
|
||||||
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max={duration}
|
||||||
|
step="0.1"
|
||||||
|
bind:value={currentTime}
|
||||||
|
on:input={seekVideo}
|
||||||
|
class="progress-slider"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* --- Torrent liste & satırları (eski App.svelte ile bire bir) --- */
|
||||||
|
.torrent {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 100px 1fr;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
background: #f6f6f6;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px 0 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.torrent-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.thumb {
|
||||||
|
width: 100px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 6px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #ddd;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.placeholder {
|
||||||
|
width: 100px;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.torrent-info {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.torrent-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.torrent-name {
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.remove-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.remove-btn:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
.torrent-hash {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #777;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-files {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.file-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.file-row button {
|
||||||
|
background: #eee;
|
||||||
|
border: none;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.file-row button:hover {
|
||||||
|
background: #ddd;
|
||||||
|
}
|
||||||
|
.filename {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.filesize {
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: #ddd;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.progress {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #27ae60, #2ecc71);
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
.progress-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #444;
|
||||||
|
text-align: right;
|
||||||
|
padding: 3px 0 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Modal & Player (eski ile bire bir) --- */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
.modal-content {
|
||||||
|
width: 70%;
|
||||||
|
height: 70%;
|
||||||
|
background: #1a1a1a;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 0 30px rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
background: #2a2a2a;
|
||||||
|
padding: 10px 16px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.video-title {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.close-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-player {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
.video-element {
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
background: #000;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.video-element:focus {
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
background: #1c1c1c;
|
||||||
|
padding: 10px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
}
|
||||||
|
.top-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.control-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
.control-btn:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.right-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Volume slider — kırmızı dolum, beyaz knob */
|
||||||
|
.volume-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 100px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
#ff3b30 calc(var(--fill, 100%) * 1%),
|
||||||
|
rgba(255, 255, 255, 0.3) calc(var(--fill, 100%) * 1%)
|
||||||
|
);
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
.volume-slider::-webkit-slider-runnable-track {
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.volume-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: -4px;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.volume-slider::-webkit-slider-thumb:hover {
|
||||||
|
transform: scale(1.3);
|
||||||
|
}
|
||||||
|
.volume-slider::-moz-range-track {
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.volume-slider::-moz-range-progress {
|
||||||
|
height: 4px;
|
||||||
|
background: #ff3b30;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.volume-slider::-moz-range-thumb {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.volume-slider::-moz-range-thumb:hover {
|
||||||
|
transform: scale(1.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle-icon {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.bottom-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.progress-slider {
|
||||||
|
flex: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: #27ae60;
|
||||||
|
}
|
||||||
|
.time {
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 90px;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* NEW TRANSFER / Magnet düğmeleri */
|
||||||
|
.btn-primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: #fdce45;
|
||||||
|
border: none;
|
||||||
|
color: #000;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
height: 36px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #fdce45;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.modal-content {
|
||||||
|
width: 95%;
|
||||||
|
height: 75%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 🔹 Responsive Düzenlemeler (hiçbir mevcut stili bozmadan eklenmiştir) */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.torrent {
|
||||||
|
grid-template-columns: 80px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-hash {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-files .file-row {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
width: 90%;
|
||||||
|
height: 75%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.files {
|
||||||
|
margin: 0 8px 12px 8px;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
width: 100%;
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-hash {
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-files {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-list {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 🎬 Modal video oynatıcı mobil optimizasyonu */
|
||||||
|
.modal-content {
|
||||||
|
width: 95%;
|
||||||
|
height: 70%;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
padding: 6px 10px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider {
|
||||||
|
width: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time {
|
||||||
|
font-size: 11px;
|
||||||
|
min-width: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-title {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.btn-primary {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-header {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.torrent-hash {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
width: 98%;
|
||||||
|
height: 75%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider {
|
||||||
|
width: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
4
client/src/routes/Trash.svelte
Normal file
4
client/src/routes/Trash.svelte
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<section class="files">
|
||||||
|
<h2>Trash</h2>
|
||||||
|
<div class="empty"><p>Trash is empty.</p></div>
|
||||||
|
</section>
|
||||||
322
client/src/styles/main.css
Normal file
322
client/src/styles/main.css
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
:root {
|
||||||
|
--yellow: #f5b333;
|
||||||
|
--yellow-dark: #e2a62f;
|
||||||
|
--sidebar: #f4f4f4;
|
||||||
|
--border: #e5e5e5;
|
||||||
|
--muted: #666;
|
||||||
|
--green: #4caf50;
|
||||||
|
}
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
margin: 0;
|
||||||
|
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Helvetica,
|
||||||
|
Arial, sans-serif;
|
||||||
|
color: #222;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.app {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 220px 1fr;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
/* Sidebar */
|
||||||
|
.sidebar {
|
||||||
|
background: var(--sidebar);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.sidebar .logo {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-weight: 900;
|
||||||
|
font-size: 28px;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.sidebar .menu {
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
.sidebar .menu .item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
color: #222;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.sidebar .menu .item.active {
|
||||||
|
background: #fff;
|
||||||
|
border-left: 3px solid var(--yellow);
|
||||||
|
}
|
||||||
|
.sidebar .menu .item .icon {
|
||||||
|
width: 18px;
|
||||||
|
text-align: center;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
/* Content */
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.search {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: #f8f8f8;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
.search input {
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--yellow);
|
||||||
|
border: 1px solid var(--yellow-dark);
|
||||||
|
color: #222;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-primary:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
|
/* Files */
|
||||||
|
.files {
|
||||||
|
margin: 0 16px 16px 16px;
|
||||||
|
flex: 1;
|
||||||
|
border-top: 2px solid #f0c24d;
|
||||||
|
padding-top: 14px;
|
||||||
|
}
|
||||||
|
.files h2 {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 260px;
|
||||||
|
gap: 10px;
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.create-folder {
|
||||||
|
background: var(--yellow);
|
||||||
|
border: 1px solid var(--yellow-dark);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
/* Transfers Page */
|
||||||
|
.torrent {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.torrent:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.progress {
|
||||||
|
height: 8px;
|
||||||
|
background: #eee;
|
||||||
|
border-radius: 99px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.progress > div {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--green);
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
.small {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
/* ====== Responsive & Off-Canvas Sidebar (EKLENDİ) ====== */
|
||||||
|
|
||||||
|
/* Hamburger butonunu varsayılan gizle; mobilde göstereceğiz */
|
||||||
|
.menu-toggle {
|
||||||
|
display: none;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar arkası için tıklanabilir backdrop (mobilde sidebar açıkken) */
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
z-index: 999; /* sidebar’ın üstünde */
|
||||||
|
}
|
||||||
|
.backdrop.show {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tablet ve aşağısında grid tek sütun; sidebar off-canvas olur */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.app {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: -240px;
|
||||||
|
width: 220px;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--sidebar);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
transition: left 0.25s ease;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.sidebar.open {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Genel içerik kenar boşluklarını sıkılaştır */
|
||||||
|
.files {
|
||||||
|
margin: 0 10px 14px 10px;
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transfers ve diğer sayfalar için liste öğelerini dikeyleştir */
|
||||||
|
.torrent {
|
||||||
|
/* Transfers’teki kutular */
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.thumb {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 180px !important;
|
||||||
|
}
|
||||||
|
.torrent-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.torrent-hash {
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: normal;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.file-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.progress-text {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Butonlar eş görünsün ve kolay dokunulsun */
|
||||||
|
.btn-primary {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
justify-content: center;
|
||||||
|
height: 36px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal video oynatıcı mobil uyum */
|
||||||
|
.modal-content {
|
||||||
|
width: 95% !important;
|
||||||
|
height: 72% !important;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.controls {
|
||||||
|
padding: 8px 12px;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.volume-slider {
|
||||||
|
width: 70px;
|
||||||
|
}
|
||||||
|
.time {
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 78px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* === Sidebar Hover & Active Effects === */
|
||||||
|
|
||||||
|
/* Hover efekti: hafif gri arka plan */
|
||||||
|
.sidebar .menu .item:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #000;
|
||||||
|
transition: background 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Aktif item: Daha koyu gri arka plan */
|
||||||
|
.sidebar .menu .item.active {
|
||||||
|
background: #e5e5e5; /* aktif olan menü item */
|
||||||
|
font-weight: 600;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover ve aktif durumlarda ikon da koyulaşsın */
|
||||||
|
.sidebar .menu .item:hover .icon,
|
||||||
|
.sidebar .menu .item.active .icon {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Daha küçük telefonlar */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.btn-primary {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
.torrent-hash {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.modal-content {
|
||||||
|
width: 98% !important;
|
||||||
|
height: 76% !important;
|
||||||
|
}
|
||||||
|
.volume-slider {
|
||||||
|
width: 56px;
|
||||||
|
}
|
||||||
|
.bottom-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
client/src/utils/api.js
Normal file
1
client/src/utils/api.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const API = import.meta.env.VITE_API || "http://localhost:3001";
|
||||||
110
client/src/utils/filename.js
Normal file
110
client/src/utils/filename.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
// utils/filename.js
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dosya adını temizler ve sadeleştirir.
|
||||||
|
* Örnek:
|
||||||
|
* The.Astronaut.2025.1080p.WEBRip.x265-KONTRAST
|
||||||
|
* → "The Astronaut (2025)"
|
||||||
|
*/
|
||||||
|
export function cleanFileName(fullPath) {
|
||||||
|
if (!fullPath) return "";
|
||||||
|
|
||||||
|
// 1️⃣ Klasör yolunu kaldır
|
||||||
|
let name = fullPath.split("/").pop();
|
||||||
|
|
||||||
|
// 2️⃣ Uzantıyı kaldır
|
||||||
|
name = name.replace(/\.[^.]+$/, "");
|
||||||
|
|
||||||
|
// 3️⃣ Noktaları boşluğa çevir
|
||||||
|
name = name.replace(/\./g, " ");
|
||||||
|
|
||||||
|
// 4️⃣ Gereksiz etiketleri kaldır
|
||||||
|
const trashWords = [
|
||||||
|
"1080p",
|
||||||
|
"720p",
|
||||||
|
"2160p",
|
||||||
|
"4k",
|
||||||
|
"bluray",
|
||||||
|
"web-dl",
|
||||||
|
"webrip",
|
||||||
|
"hdrip",
|
||||||
|
"x264",
|
||||||
|
"x265",
|
||||||
|
"hevc",
|
||||||
|
"aac",
|
||||||
|
"h264",
|
||||||
|
"h265",
|
||||||
|
"dvdrip",
|
||||||
|
"brrip",
|
||||||
|
"remux",
|
||||||
|
"multi",
|
||||||
|
"sub",
|
||||||
|
"subs",
|
||||||
|
"turkce",
|
||||||
|
"dublado",
|
||||||
|
"dubbed",
|
||||||
|
"extended",
|
||||||
|
"unrated",
|
||||||
|
"repack",
|
||||||
|
"proper",
|
||||||
|
"kontrast",
|
||||||
|
"yify",
|
||||||
|
"ettv",
|
||||||
|
"rarbg",
|
||||||
|
"hdtv",
|
||||||
|
"amzn",
|
||||||
|
"nf",
|
||||||
|
"netflix"
|
||||||
|
];
|
||||||
|
const trashRegex = new RegExp(`\\b(${trashWords.join("|")})\\b`, "gi");
|
||||||
|
name = name.replace(trashRegex, " ");
|
||||||
|
|
||||||
|
// 5️⃣ Köşeli parantez içindekileri kaldır
|
||||||
|
name = name.replace(/\[[^\]]*\]/g, "");
|
||||||
|
|
||||||
|
// 6️⃣ Parantez içindeki tarihleri kaldır
|
||||||
|
name = name
|
||||||
|
.replace(/\(\d{2}\.\d{2}\.\d{2,4}\)/g, "")
|
||||||
|
.replace(/\(\d{4}(-\d{2})?(-\d{2})?\)/g, "");
|
||||||
|
|
||||||
|
// 7️⃣ Fazla boşlukları temizle
|
||||||
|
name = name.replace(/\s{2,}/g, " ").trim();
|
||||||
|
|
||||||
|
// 8️⃣ Yılı tespit et (ör. 2024, 1999)
|
||||||
|
const yearMatch = name.match(/\b(19|20)\d{2}\b/);
|
||||||
|
let year = "";
|
||||||
|
if (yearMatch) {
|
||||||
|
year = yearMatch[0];
|
||||||
|
name = name.replace(year, "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9️⃣ Dizi formatı (S03E01) varsa koru
|
||||||
|
const match = name.match(/(.+?)\s*-\s*(S\d{2}E\d{2})/i);
|
||||||
|
if (match) {
|
||||||
|
const formatted = `${match[1].trim()} - ${match[2].toUpperCase()}`;
|
||||||
|
return year ? `${formatted} (${year})` : formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔟 Fazla tireleri ve tire + parantez boşluklarını düzelt
|
||||||
|
name = name
|
||||||
|
.replace(/[-_]+/g, " ") // birden fazla tireyi temizle
|
||||||
|
.replace(/\s-\s*\(/g, " (") // " - (" → " ("
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// 11️⃣ Baş harfleri büyüt
|
||||||
|
name = name
|
||||||
|
.split(" ")
|
||||||
|
.map(
|
||||||
|
(w) =>
|
||||||
|
w.length > 1
|
||||||
|
? w[0].toUpperCase() + w.slice(1).toLowerCase()
|
||||||
|
: w.toUpperCase()
|
||||||
|
)
|
||||||
|
.join(" ")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// 12️⃣ Yıl varsa sonuna ekle
|
||||||
|
if (year) name += ` (${year})`;
|
||||||
|
|
||||||
|
return name.trim();
|
||||||
|
}
|
||||||
12
client/vite.config.js
Normal file
12
client/vite.config.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [svelte()],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0', // dış erişim
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
watch: { usePolling: true } // bazen hot reload için gerekir
|
||||||
|
}
|
||||||
|
});
|
||||||
32
docker-compose.yml
Normal file
32
docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
dupe_network:
|
||||||
|
driver: bridge
|
||||||
|
|
||||||
|
services:
|
||||||
|
server:
|
||||||
|
build: ./server
|
||||||
|
container_name: dupe-server
|
||||||
|
ports:
|
||||||
|
- "3001:3001"
|
||||||
|
networks:
|
||||||
|
- dupe_network
|
||||||
|
volumes:
|
||||||
|
- ./downloads:/app/downloads
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
client:
|
||||||
|
build: ./client
|
||||||
|
container_name: dupe-client
|
||||||
|
depends_on:
|
||||||
|
- server
|
||||||
|
ports:
|
||||||
|
- "5173:5173"
|
||||||
|
networks:
|
||||||
|
- dupe_network
|
||||||
|
volumes:
|
||||||
|
- ./downloads:/app/downloads
|
||||||
|
environment:
|
||||||
|
- VITE_API=http://localhost:3001
|
||||||
|
restart: unless-stopped
|
||||||
8
server/Dockerfile
Normal file
8
server/Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
FROM node:22-slim
|
||||||
|
RUN apt-get update && apt-get install -y ffmpeg
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci || npm i
|
||||||
|
COPY . .
|
||||||
|
EXPOSE 3001
|
||||||
|
CMD ["npm","start"]
|
||||||
16
server/package.json
Normal file
16
server/package.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "dupe-server",
|
||||||
|
"version": "1.2.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"mime-types": "^2.1.35",
|
||||||
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"webtorrent": "^1.9.7",
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
316
server/server.js
Normal file
316
server/server.js
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
import express from "express";
|
||||||
|
import cors from "cors";
|
||||||
|
import multer from "multer";
|
||||||
|
import WebTorrent from "webtorrent";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import mime from "mime-types";
|
||||||
|
import { WebSocketServer } from "ws";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
import { exec } from "child_process"; // 🆕 ffmpeg çağırmak için
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const upload = multer({ dest: path.join(__dirname, "uploads") });
|
||||||
|
const client = new WebTorrent();
|
||||||
|
const torrents = new Map();
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
// --- İndirilen dosyalar için klasör oluştur ---
|
||||||
|
const DOWNLOAD_DIR = path.join(__dirname, "downloads");
|
||||||
|
if (!fs.existsSync(DOWNLOAD_DIR))
|
||||||
|
fs.mkdirSync(DOWNLOAD_DIR, { recursive: true });
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
app.use("/downloads", express.static(DOWNLOAD_DIR));
|
||||||
|
|
||||||
|
// --- En uygun video dosyasını seç ---
|
||||||
|
function pickBestVideoFile(torrent) {
|
||||||
|
const videoExts = [".mp4", ".webm", ".mkv", ".mov", ".m4v"];
|
||||||
|
const videos = torrent.files
|
||||||
|
.map((f, i) => ({ i, f }))
|
||||||
|
.filter(({ f }) => videoExts.includes(path.extname(f.name).toLowerCase()));
|
||||||
|
if (!videos.length) return 0;
|
||||||
|
videos.sort((a, b) => b.f.length - a.f.length);
|
||||||
|
return videos[0].i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Snapshot (thumbnail dahil, tracker + tarih eklendi) ---
|
||||||
|
function snapshot() {
|
||||||
|
return Array.from(torrents.values()).map(
|
||||||
|
({ torrent, selectedIndex, savePath, added }) => {
|
||||||
|
const thumbPath = path.join(savePath, "thumbnail.jpg");
|
||||||
|
const hasThumb = fs.existsSync(thumbPath);
|
||||||
|
return {
|
||||||
|
infoHash: torrent.infoHash,
|
||||||
|
name: torrent.name,
|
||||||
|
progress: torrent.progress,
|
||||||
|
downloaded: torrent.downloaded,
|
||||||
|
downloadSpeed: torrent.downloadSpeed,
|
||||||
|
uploadSpeed: torrent.uploadSpeed,
|
||||||
|
numPeers: torrent.numPeers,
|
||||||
|
tracker: torrent.announce?.[0] || null, // 🆕 ilk tracker
|
||||||
|
added, // 🆕 eklenme zamanı
|
||||||
|
files: torrent.files.map((f, i) => ({
|
||||||
|
index: i,
|
||||||
|
name: f.name,
|
||||||
|
length: f.length
|
||||||
|
})),
|
||||||
|
selectedIndex,
|
||||||
|
thumbnail: hasThumb ? `/thumbnail/${torrent.infoHash}` : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Torrent veya magnet ekleme ---
|
||||||
|
app.post("/api/transfer", upload.single("torrent"), (req, res) => {
|
||||||
|
try {
|
||||||
|
let source = req.body.magnet;
|
||||||
|
if (req.file) source = fs.readFileSync(req.file.path);
|
||||||
|
if (!source)
|
||||||
|
return res.status(400).json({ error: "magnet veya .torrent gerekli" });
|
||||||
|
|
||||||
|
// Her torrent için ayrı klasör
|
||||||
|
const savePath = path.join(DOWNLOAD_DIR, Date.now().toString());
|
||||||
|
fs.mkdirSync(savePath, { recursive: true });
|
||||||
|
|
||||||
|
const torrent = client.add(source, { announce: [], path: savePath });
|
||||||
|
|
||||||
|
// 🆕 Torrent eklendiği anda tarih kaydedelim
|
||||||
|
const added = Date.now();
|
||||||
|
|
||||||
|
torrents.set(torrent.infoHash, {
|
||||||
|
torrent,
|
||||||
|
selectedIndex: 0,
|
||||||
|
savePath,
|
||||||
|
added
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Metadata geldiğinde ---
|
||||||
|
torrent.on("ready", () => {
|
||||||
|
const selectedIndex = pickBestVideoFile(torrent);
|
||||||
|
torrents.set(torrent.infoHash, {
|
||||||
|
torrent,
|
||||||
|
selectedIndex,
|
||||||
|
savePath,
|
||||||
|
added
|
||||||
|
});
|
||||||
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
infoHash: torrent.infoHash,
|
||||||
|
name: torrent.name,
|
||||||
|
selectedIndex,
|
||||||
|
tracker: torrent.announce?.[0] || null, // 🆕
|
||||||
|
added, // 🆕
|
||||||
|
files: torrent.files.map((f, i) => ({
|
||||||
|
index: i,
|
||||||
|
name: f.name,
|
||||||
|
length: f.length
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- İndirme tamamlandığında thumbnail oluştur ---
|
||||||
|
torrent.on("done", () => {
|
||||||
|
const entry = torrents.get(torrent.infoHash);
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
const videoFile = torrent.files[entry.selectedIndex];
|
||||||
|
const videoPath = path.join(entry.savePath, videoFile.path);
|
||||||
|
const thumbnailPath = path.join(entry.savePath, "thumbnail.jpg");
|
||||||
|
|
||||||
|
const cmd = `ffmpeg -ss 00:00:30 -i "${videoPath}" -frames:v 1 -q:v 2 "${thumbnailPath}"`;
|
||||||
|
exec(cmd, (err) => {
|
||||||
|
if (err) console.warn(`⚠️ Thumbnail oluşturulamadı: ${err.message}`);
|
||||||
|
else console.log(`📸 Thumbnail oluşturuldu: ${thumbnailPath}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Thumbnail endpoint ---
|
||||||
|
app.get("/thumbnail/:hash", (req, res) => {
|
||||||
|
const entry = torrents.get(req.params.hash);
|
||||||
|
if (!entry) return res.status(404).end();
|
||||||
|
|
||||||
|
const thumbnailPath = path.join(entry.savePath, "thumbnail.jpg");
|
||||||
|
if (!fs.existsSync(thumbnailPath))
|
||||||
|
return res.status(404).send("Thumbnail yok");
|
||||||
|
|
||||||
|
res.sendFile(thumbnailPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Torrentleri listele ---
|
||||||
|
app.get("/api/torrents", (req, res) => {
|
||||||
|
res.json(snapshot());
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Seçili dosya değiştir ---
|
||||||
|
app.post("/api/torrents/:hash/select/:index", (req, res) => {
|
||||||
|
const entry = torrents.get(req.params.hash);
|
||||||
|
if (!entry) return res.status(404).json({ error: "torrent bulunamadı" });
|
||||||
|
entry.selectedIndex = Number(req.params.index) || 0;
|
||||||
|
res.json({ ok: true, selectedIndex: entry.selectedIndex });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Torrent silme (disk dahil) ---
|
||||||
|
app.delete("/api/torrents/:hash", (req, res) => {
|
||||||
|
const entry = torrents.get(req.params.hash);
|
||||||
|
if (!entry) return res.status(404).json({ error: "torrent bulunamadı" });
|
||||||
|
|
||||||
|
const { torrent, savePath } = entry;
|
||||||
|
torrent.destroy(() => {
|
||||||
|
torrents.delete(req.params.hash);
|
||||||
|
if (savePath && fs.existsSync(savePath)) {
|
||||||
|
try {
|
||||||
|
fs.rmSync(savePath, { recursive: true, force: true });
|
||||||
|
console.log(`🗑️ ${savePath} klasörü silindi`);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`⚠️ ${savePath} silinemedi:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/media/:path(*)", (req, res) => {
|
||||||
|
const fullPath = path.join(DOWNLOAD_DIR, req.params.path);
|
||||||
|
if (!fs.existsSync(fullPath)) return res.status(404).send("File not found");
|
||||||
|
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
|
const fileSize = stat.size;
|
||||||
|
const range = req.headers.range;
|
||||||
|
|
||||||
|
if (range) {
|
||||||
|
const [startStr, endStr] = range.replace(/bytes=/, "").split("-");
|
||||||
|
const start = parseInt(startStr, 10);
|
||||||
|
const end = endStr ? parseInt(endStr, 10) : fileSize - 1;
|
||||||
|
const chunkSize = end - start + 1;
|
||||||
|
const file = fs.createReadStream(fullPath, { start, end });
|
||||||
|
const head = {
|
||||||
|
"Content-Range": `bytes ${start}-${end}/${fileSize}`,
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Content-Length": chunkSize,
|
||||||
|
"Content-Type": "video/mp4"
|
||||||
|
};
|
||||||
|
res.writeHead(206, head);
|
||||||
|
file.pipe(res);
|
||||||
|
} else {
|
||||||
|
const head = {
|
||||||
|
"Content-Length": fileSize,
|
||||||
|
"Content-Type": "video/mp4"
|
||||||
|
};
|
||||||
|
res.writeHead(200, head);
|
||||||
|
fs.createReadStream(fullPath).pipe(res);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- 📁 Dosya gezgini: /downloads altındaki dosyaları listele ---
|
||||||
|
app.get("/api/files", (req, res) => {
|
||||||
|
const walk = (dir) => {
|
||||||
|
let result = [];
|
||||||
|
const list = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of list) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
const rel = path.relative(DOWNLOAD_DIR, full);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
result = result.concat(walk(full));
|
||||||
|
} else {
|
||||||
|
// thumbnail.jpg dosyasını listeleme
|
||||||
|
if (entry.name.toLowerCase() === "thumbnail.jpg") continue;
|
||||||
|
const size = fs.statSync(full).size;
|
||||||
|
const parts = rel.split(path.sep);
|
||||||
|
const rootHash = parts[0]; // ilk klasör adı
|
||||||
|
const thumbPath = path.join(DOWNLOAD_DIR, rootHash, "thumbnail.jpg");
|
||||||
|
|
||||||
|
// ✅ Thumbnail dosyası gerçekten varsa ekle
|
||||||
|
const thumb = fs.existsSync(thumbPath)
|
||||||
|
? `/downloads/${rootHash}/thumbnail.jpg`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
name: rel,
|
||||||
|
size,
|
||||||
|
thumbnail: thumb
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = walk(DOWNLOAD_DIR);
|
||||||
|
res.json(files);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("📁 Files API error:", err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Stream endpoint ---
|
||||||
|
app.get("/stream/:hash", (req, res) => {
|
||||||
|
const entry = torrents.get(req.params.hash);
|
||||||
|
if (!entry) return res.status(404).end();
|
||||||
|
|
||||||
|
const file =
|
||||||
|
entry.torrent.files[entry.selectedIndex] || entry.torrent.files[0];
|
||||||
|
const total = file.length;
|
||||||
|
const type = mime.lookup(file.name) || "video/mp4";
|
||||||
|
const range = req.headers.range;
|
||||||
|
|
||||||
|
if (!range) {
|
||||||
|
res.writeHead(200, {
|
||||||
|
"Content-Length": total,
|
||||||
|
"Content-Type": type,
|
||||||
|
"Accept-Ranges": "bytes"
|
||||||
|
});
|
||||||
|
return file.createReadStream().pipe(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [s, e] = range.replace(/bytes=/, "").split("-");
|
||||||
|
const start = parseInt(s, 10);
|
||||||
|
const end = e ? parseInt(e, 10) : total - 1;
|
||||||
|
|
||||||
|
res.writeHead(206, {
|
||||||
|
"Content-Range": `bytes ${start}-${end}/${total}`,
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
"Content-Length": end - start + 1,
|
||||||
|
"Content-Type": type
|
||||||
|
});
|
||||||
|
|
||||||
|
const stream = file.createReadStream({ start, end });
|
||||||
|
stream.on("error", (err) => console.warn("Stream error:", err.message));
|
||||||
|
res.on("close", () => stream.destroy());
|
||||||
|
stream.pipe(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("📂 Download path:", DOWNLOAD_DIR);
|
||||||
|
|
||||||
|
// --- WebSocket: anlık durum yayını ---
|
||||||
|
const server = app.listen(PORT, () =>
|
||||||
|
console.log(`✅ WebTorrent server ${PORT} portunda çalışıyor`)
|
||||||
|
);
|
||||||
|
const wss = new WebSocketServer({ server });
|
||||||
|
wss.on("connection", (ws) => {
|
||||||
|
ws.send(JSON.stringify({ type: "progress", torrents: snapshot() }));
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
const data = JSON.stringify({ type: "progress", torrents: snapshot() });
|
||||||
|
wss.clients.forEach((c) => c.readyState === 1 && c.send(data));
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
client.on("error", (err) => {
|
||||||
|
if (!String(err).includes("uTP"))
|
||||||
|
console.error("WebTorrent error:", err.message);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user