feat(rabbit): gelişmiş video oynatıcı ve gerçek zamanlı güncelleme desteği ekle
Video oynatıcıya özel kontroller, WebSocket desteği, arama fonksiyonu ve altyazı yükleme özelliği eklendi. Metadata yönetimi güçlendirildi, dosya silme ve geri yükleme işlemlerinde Rabbit listesi otomatik güncelleniyor.
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
import { refreshMovieCount } from "./stores/movieStore.js";
|
||||
import { refreshTvShowCount } from "./stores/tvStore.js";
|
||||
import { refreshMusicCount } from "./stores/musicStore.js";
|
||||
import { refreshRabbitCount } from "./stores/rabbitStore.js";
|
||||
import { fetchTrashItems } from "./stores/trashStore.js";
|
||||
import { setAvatarUrl } from "./stores/avatarStore.js";
|
||||
|
||||
@@ -35,6 +36,7 @@
|
||||
refreshMovieCount(),
|
||||
refreshTvShowCount(),
|
||||
refreshMusicCount(),
|
||||
refreshRabbitCount(),
|
||||
fetchTrashItems()
|
||||
]);
|
||||
} catch (err) {
|
||||
@@ -86,6 +88,7 @@
|
||||
refreshMovieCount();
|
||||
refreshTvShowCount();
|
||||
refreshMusicCount();
|
||||
refreshRabbitCount();
|
||||
fetchTrashItems();
|
||||
loadUserProfile();
|
||||
const authToken = getAccessToken();
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { onMount, tick } from "svelte";
|
||||
import { apiFetch, withToken, API } from "../utils/api.js";
|
||||
import { setRabbitCount } from "../stores/rabbitStore.js";
|
||||
import { cleanFileName } from "../utils/filename.js";
|
||||
import { getAccessToken } from "../utils/api.js";
|
||||
|
||||
let items = [];
|
||||
let loading = true;
|
||||
@@ -9,6 +11,24 @@
|
||||
let selected = null;
|
||||
let showPlayer = false;
|
||||
|
||||
// Video player değişkenleri
|
||||
let playerItems = [];
|
||||
let selectedVideo = null;
|
||||
let videoEl;
|
||||
let isPlaying = false;
|
||||
let currentTime = 0;
|
||||
let duration = 0;
|
||||
let volume = 1;
|
||||
let subtitleURL = null;
|
||||
let subtitleLabel = "Custom Subtitles";
|
||||
let currentIndex = -1;
|
||||
let searchTerm = "";
|
||||
let hasSearch = false;
|
||||
let filteredItems = [];
|
||||
|
||||
$: selectedName = selectedVideo?.name ?? "";
|
||||
$: encName = selectedName ? encodeURIComponent(selectedName) : "";
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
error = null;
|
||||
@@ -25,7 +45,68 @@
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
let wsRabbit = null;
|
||||
|
||||
onMount(() => {
|
||||
load();
|
||||
|
||||
// WebSocket bağlantısı - fileUpdate mesajlarını dinle
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
const wsUrl = `${API.replace("http", "ws")}?token=${token}`;
|
||||
try {
|
||||
wsRabbit = new WebSocket(wsUrl);
|
||||
wsRabbit.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "fileUpdate") {
|
||||
console.log("📹 Rabbit fileUpdate mesajı alındı, listeyi yeniliyor");
|
||||
load();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("WS mesajı çözümlenemedi:", err);
|
||||
}
|
||||
};
|
||||
wsRabbit.onerror = () => {
|
||||
console.warn("WS bağlantısı başarısız (Rabbit)");
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn("WS bağlantısı kurulamadı (Rabbit):", err);
|
||||
}
|
||||
}
|
||||
|
||||
const handleKey = (event) => {
|
||||
if (!showPlayer) return;
|
||||
if (isEditableTarget(event.target)) return;
|
||||
|
||||
const isCmd = event.metaKey || event.ctrlKey;
|
||||
if (isCmd && event.key.toLowerCase() === "a") {
|
||||
if (isEditableTarget(event.target)) return;
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closePlayer();
|
||||
} else if (event.key === " " || event.key === "Spacebar") {
|
||||
event.preventDefault();
|
||||
togglePlay();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKey);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKey);
|
||||
if (wsRabbit) {
|
||||
try {
|
||||
wsRabbit.close();
|
||||
} catch (err) {
|
||||
/* no-op */
|
||||
}
|
||||
wsRabbit = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
function thumbUrl(item) {
|
||||
if (!item.thumbnail) return null;
|
||||
@@ -33,39 +114,225 @@
|
||||
return withToken(url);
|
||||
}
|
||||
|
||||
function videoUrl(item) {
|
||||
if (!item?.file || !item?.folderId) return null;
|
||||
const safeFolder = encodeURIComponent(item.folderId.trim());
|
||||
function mapRabbitToPlayerItem(item) {
|
||||
if (!item?.file) {
|
||||
console.warn("⚠️ Rabbit item eksik (file yok):", item);
|
||||
return null;
|
||||
}
|
||||
// API'den gelen id alanını folderId olarak kullan
|
||||
const folderId = item.id || item.folderId;
|
||||
if (!folderId) {
|
||||
console.warn("⚠️ Rabbit item eksik (id/folderId yok):", item);
|
||||
return null;
|
||||
}
|
||||
const safeFolder = encodeURIComponent(folderId.trim());
|
||||
const segments = String(item.file)
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((seg) => encodeURIComponent(seg.trim()));
|
||||
const relPath = segments.join("/");
|
||||
const url = `${API}/downloads/${safeFolder}/${relPath}`;
|
||||
|
||||
// Debug: console'a URL yazdır
|
||||
console.log("🎥 Video URL Debug:", {
|
||||
folderId: item.folderId,
|
||||
file: item.file,
|
||||
safeFolder,
|
||||
relPath,
|
||||
finalUrl: withToken(url)
|
||||
});
|
||||
|
||||
return withToken(url);
|
||||
const fileName = segments.join("/");
|
||||
// folderId/filename formatında path oluştur
|
||||
const name = `${safeFolder}/${fileName}`;
|
||||
const ext = fileName.split(".").pop()?.toLowerCase() || "";
|
||||
const inferredType = ext ? `video/${ext}` : "video/mp4";
|
||||
const size = Number(item.size) || null;
|
||||
console.log("📹 Rabbit player item oluşturuldu:", { id: item.id, folderId, file: item.file, name });
|
||||
return {
|
||||
name,
|
||||
type: inferredType.startsWith("video/") ? inferredType : "video/mp4",
|
||||
size,
|
||||
item
|
||||
};
|
||||
}
|
||||
|
||||
$: playerItems = items.map(mapRabbitToPlayerItem).filter(Boolean);
|
||||
|
||||
$: searchTerm = searchTerm;
|
||||
$: hasSearch = searchTerm.trim().length > 0;
|
||||
$: filteredItems = (() => {
|
||||
const query = searchTerm.trim().toLowerCase();
|
||||
if (!query) return items;
|
||||
return items.filter((item) => {
|
||||
const fields = [
|
||||
item.title,
|
||||
item.folderId,
|
||||
item.file
|
||||
];
|
||||
return fields
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase().includes(query));
|
||||
});
|
||||
})();
|
||||
|
||||
function playItem(item) {
|
||||
selected = item;
|
||||
if (!item) return;
|
||||
openPlayerForItem(item);
|
||||
}
|
||||
|
||||
async function openPlayerForItem(item) {
|
||||
if (!playerItems.length) return;
|
||||
const index = playerItems.findIndex(
|
||||
(playerItem) => playerItem.item.id === item.id
|
||||
);
|
||||
if (index === -1) return;
|
||||
await openVideoAtIndex(index);
|
||||
}
|
||||
|
||||
async function openVideoAtIndex(index) {
|
||||
if (!playerItems.length) return;
|
||||
const safeIndex =
|
||||
((index % playerItems.length) + playerItems.length) % playerItems.length;
|
||||
stopCurrentVideo();
|
||||
currentIndex = safeIndex;
|
||||
selectedVideo = playerItems[safeIndex];
|
||||
subtitleURL = null;
|
||||
await tick();
|
||||
showPlayer = true;
|
||||
isPlaying = false;
|
||||
currentTime = 0;
|
||||
duration = 0;
|
||||
}
|
||||
|
||||
function stopCurrentVideo() {
|
||||
if (videoEl) {
|
||||
try {
|
||||
videoEl.pause();
|
||||
videoEl.src = "";
|
||||
videoEl.load();
|
||||
} catch (err) {
|
||||
console.warn("Video stop error:", err?.message || err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closePlayer() {
|
||||
stopCurrentVideo();
|
||||
showPlayer = false;
|
||||
selectedVideo = null;
|
||||
subtitleURL = null;
|
||||
isPlaying = false;
|
||||
}
|
||||
|
||||
function getVideoURL() {
|
||||
if (!selectedName) return "";
|
||||
const token = localStorage.getItem("token");
|
||||
return `${API}/media/${encName}?token=${token}`;
|
||||
}
|
||||
|
||||
async function togglePlay() {
|
||||
if (!videoEl) return;
|
||||
if (videoEl.paused) {
|
||||
try {
|
||||
await videoEl.play();
|
||||
isPlaying = true;
|
||||
} catch (err) {
|
||||
console.warn("Play rejected:", err?.message || err);
|
||||
isPlaying = false;
|
||||
}
|
||||
} else {
|
||||
videoEl.pause();
|
||||
isPlaying = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateProgress() {
|
||||
currentTime = videoEl?.currentTime || 0;
|
||||
}
|
||||
|
||||
function updateDuration() {
|
||||
duration = videoEl?.duration || 0;
|
||||
}
|
||||
|
||||
function seekVideo(event) {
|
||||
if (!videoEl) return;
|
||||
const newTime = parseFloat(event.target.value);
|
||||
if (Number.isFinite(newTime) && Math.abs(videoEl.currentTime - newTime) > 0.2) {
|
||||
videoEl.currentTime = newTime;
|
||||
}
|
||||
}
|
||||
|
||||
function changeVolume(event) {
|
||||
if (!videoEl) return;
|
||||
const nextVolume = parseFloat(event.target.value);
|
||||
videoEl.volume = nextVolume;
|
||||
event.target.style.setProperty("--fill", (nextVolume || 0) * 100);
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (!videoEl) return;
|
||||
if (document.fullscreenElement) document.exitFullscreen();
|
||||
else videoEl.requestFullscreen();
|
||||
}
|
||||
|
||||
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 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);
|
||||
}
|
||||
|
||||
const TEXT_INPUT_TYPES = new Set([
|
||||
"text",
|
||||
"search",
|
||||
"email",
|
||||
"password",
|
||||
"number",
|
||||
"url",
|
||||
"tel"
|
||||
]);
|
||||
|
||||
function isEditableTarget(target) {
|
||||
if (!target) return false;
|
||||
const tag = target.tagName;
|
||||
const type = target.type?.toLowerCase();
|
||||
if (tag === "TEXTAREA") return true;
|
||||
if (tag === "INPUT" && TEXT_INPUT_TYPES.has(type)) return true;
|
||||
return Boolean(target.isContentEditable);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="rabbit-page">
|
||||
<div class="section-accent"></div>
|
||||
<div class="header">
|
||||
<h2>Rabbit</h2>
|
||||
<button class="btn" on:click={load} disabled={loading}>
|
||||
<i class="fa-solid fa-rotate"></i> Yenile
|
||||
<button class="refresh-btn" on:click={load} disabled={loading}>
|
||||
<i class="fa-solid fa-rotate"></i> {loading ? "Yükleniyor..." : "Yenile"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -75,12 +342,14 @@
|
||||
<div class="state error">{error}</div>
|
||||
{:else if items.length === 0}
|
||||
<div class="state">Henüz içerik yok.</div>
|
||||
{:else if hasSearch && filteredItems.length === 0}
|
||||
<div class="state">Aramanıza uyan video bulunamadı.</div>
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each items as item (item.id)}
|
||||
{#each filteredItems as item (item.id)}
|
||||
<div class="card" on:click={() => playItem(item)}>
|
||||
{#if thumbUrl(item)}
|
||||
<img class="thumb" src={thumbUrl(item)} alt={item.title} />
|
||||
<img class="thumb" src={thumbUrl(item)} alt={item.title} loading="lazy" />
|
||||
{:else}
|
||||
<div class="thumb placeholder"><i class="fa-regular fa-image"></i></div>
|
||||
{/if}
|
||||
@@ -89,156 +358,477 @@
|
||||
{#if item.added}
|
||||
<div class="meta">{new Date(item.added).toLocaleString()}</div>
|
||||
{/if}
|
||||
{#if item.size}
|
||||
<div class="meta">{formatSize(item.size)}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if showPlayer && selected}
|
||||
<div class="player" on:click={() => (showPlayer = false)}>
|
||||
<div class="player-content" on:click|stopPropagation>
|
||||
<button class="player-close" on:click={() => (showPlayer = false)}>Kapat</button>
|
||||
<div class="player-title">{selected.title}</div>
|
||||
{#if videoUrl(selected)}
|
||||
<video
|
||||
controls
|
||||
autoplay
|
||||
src={videoUrl(selected)}
|
||||
on:error={(e) => {
|
||||
console.error("🎥 Video Error:", e.target.error);
|
||||
console.log("🎥 Failed src:", videoUrl(selected));
|
||||
}}
|
||||
on:loadeddata={() => {
|
||||
console.log("🎥 Video loaded successfully");
|
||||
}}
|
||||
></video>
|
||||
{:else}
|
||||
<div class="state error">Video yolu bulunamadı</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if showPlayer && selectedVideo}
|
||||
<div class="modal-overlay" on:click={closePlayer}>
|
||||
<button class="global-close-btn" on:click|stopPropagation={closePlayer}>
|
||||
✕
|
||||
</button>
|
||||
<div class="modal-content" on:click|stopPropagation>
|
||||
<div class="modal-header">
|
||||
<div class="video-title">{selectedVideo?.item?.title || cleanFileName(selectedName)}</div>
|
||||
<div class="video-meta">
|
||||
<span>{formatSize(selectedVideo.size || 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="custom-player">
|
||||
{#key encName}
|
||||
<video
|
||||
bind:this={videoEl}
|
||||
src={getVideoURL()}
|
||||
class="video-element"
|
||||
playsinline
|
||||
on:timeupdate={updateProgress}
|
||||
on:loadedmetadata={async () => {
|
||||
isPlaying = false;
|
||||
currentTime = 0;
|
||||
updateDuration();
|
||||
const slider = document.querySelector(".volume-slider");
|
||||
if (slider) {
|
||||
slider.value = volume;
|
||||
slider.style.setProperty("--fill", slider.value * 100);
|
||||
}
|
||||
try {
|
||||
await videoEl.play();
|
||||
isPlaying = true;
|
||||
} catch (err) {
|
||||
console.warn("Autoplay engellendi:", err?.message || err);
|
||||
isPlaying = false;
|
||||
}
|
||||
}}
|
||||
on:ended={() => (isPlaying = false)}
|
||||
>
|
||||
{#if subtitleURL}
|
||||
<track
|
||||
kind="subtitles"
|
||||
src={subtitleURL}
|
||||
label={subtitleLabel}
|
||||
default
|
||||
/>
|
||||
{/if}
|
||||
</video>
|
||||
{/key}
|
||||
<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={getVideoURL()}
|
||||
download={selectedName ? selectedName.split("/").pop() : undefined}
|
||||
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>
|
||||
.rabbit-page {
|
||||
padding: 16px;
|
||||
padding: 20px 26px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.section-accent {
|
||||
height: 2px;
|
||||
width: calc(100% + 52px);
|
||||
background: var(--yellow, #f5b333);
|
||||
border-radius: 999px;
|
||||
margin: -20px -26px 18px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.btn {
|
||||
|
||||
.refresh-btn {
|
||||
background: #2e2e2e;
|
||||
color: #f5f5f5;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #dcdcdc;
|
||||
border-radius: 8px;
|
||||
background: #f7f7f7;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.refresh-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.refresh-btn:not(:disabled):hover {
|
||||
background: #3a3a3a;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: relative;
|
||||
background: #f5f5f5;
|
||||
background: #f7f7f7;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
isolation: isolate;
|
||||
transition: box-shadow 0.18s ease;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
transform 0.2s ease,
|
||||
background 0.2s ease;
|
||||
cursor: pointer;
|
||||
border: 1px solid #e1e1e1;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: #cfcfcf;
|
||||
background: #f0f0f0;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.thumb {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
.thumb.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
font-size: 28px;
|
||||
background: #ececec;
|
||||
}
|
||||
|
||||
.info {
|
||||
padding: 8px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: #1c1c1c;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
color: #777;
|
||||
}
|
||||
.player {
|
||||
|
||||
.state {
|
||||
padding: 40px 0;
|
||||
color: #7a7a7a;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.state.error {
|
||||
color: #d9534f;
|
||||
}
|
||||
|
||||
/* Video Player Styles */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.65);
|
||||
backdrop-filter: blur(4px);
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 999;
|
||||
z-index: 6000;
|
||||
}
|
||||
.player-content {
|
||||
background: #111;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
max-width: 900px;
|
||||
width: 90%;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.35);
|
||||
|
||||
.modal-content {
|
||||
width: 70%;
|
||||
height: 70%;
|
||||
background: #1a1a1a;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 30px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
.player video {
|
||||
width: 100%;
|
||||
max-height: 520px;
|
||||
border-radius: 8px;
|
||||
|
||||
.global-close-btn {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
color: #fff;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
z-index: 10;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.global-close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #2a2a2a;
|
||||
padding: 10px 16px;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.video-title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.video-meta {
|
||||
font-size: 13px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.custom-player {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-height: 0;
|
||||
background: #000;
|
||||
}
|
||||
.player-title {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
color: #fff;
|
||||
}
|
||||
.player-close {
|
||||
align-self: flex-end;
|
||||
background: #fff;
|
||||
|
||||
.video-element {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 100%;
|
||||
min-height: 0;
|
||||
object-fit: contain;
|
||||
background: #000;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
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;
|
||||
flex-shrink: 0;
|
||||
border-top: 1px solid #333;
|
||||
}
|
||||
|
||||
.top-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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 {
|
||||
-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-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);
|
||||
}
|
||||
|
||||
.bottom-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.progress-slider {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
accent-color: #27ae60;
|
||||
}
|
||||
|
||||
.time {
|
||||
color: #ccc;
|
||||
font-size: 13px;
|
||||
min-width: 90px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subtitle-icon {
|
||||
cursor: pointer;
|
||||
}
|
||||
.state {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.rabbit-page {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.section-accent {
|
||||
width: calc(100% + 36px);
|
||||
margin: -16px -18px 18px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 96vw;
|
||||
height: 80vh;
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
width: 60px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.global-close-btn {
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.state.error {
|
||||
color: #b00020;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { apiFetch } from "../utils/api.js";
|
||||
|
||||
const countStore = writable(0);
|
||||
export const rabbitCount = countStore;
|
||||
@@ -6,3 +7,19 @@ export const rabbitCount = countStore;
|
||||
export function setRabbitCount(count) {
|
||||
countStore.set(Number(count) || 0);
|
||||
}
|
||||
|
||||
// Rabbit listesini yenile
|
||||
export async function refreshRabbitCount() {
|
||||
try {
|
||||
const resp = await apiFetch("/api/rabbit");
|
||||
if (!resp.ok) {
|
||||
console.warn("Rabbit listesi yenilenemedi:", resp.status);
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const count = data?.items?.length || 0;
|
||||
setRabbitCount(count);
|
||||
} catch (err) {
|
||||
console.warn("Rabbit sayısı yenilenemedi:", err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
125
server/server.js
125
server/server.js
@@ -1458,6 +1458,98 @@ function writeRabbitMetadata(job, absMedia, mediaInfo, infoJson) {
|
||||
}
|
||||
}
|
||||
|
||||
function removeRabbitMetadata(folderId) {
|
||||
const safeFolder = sanitizeRelative(folderId);
|
||||
if (!safeFolder) return false;
|
||||
const targetDir = path.join(RABBIT_DATA_ROOT, safeFolder);
|
||||
if (!fs.existsSync(targetDir)) return false;
|
||||
try {
|
||||
fs.rmSync(targetDir, { recursive: true, force: true });
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn("⚠️ Rabbit metadata silinemedi:", err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function extractPornhubViewKey(folderId) {
|
||||
if (!folderId || typeof folderId !== "string") return null;
|
||||
if (!folderId.startsWith("ph_")) return null;
|
||||
const parts = folderId.split("_");
|
||||
if (parts.length < 2) return null;
|
||||
return parts[1] || null;
|
||||
}
|
||||
|
||||
async function rebuildRabbitMetadataForFolder(folderId) {
|
||||
const safeFolder = sanitizeRelative(folderId);
|
||||
if (!safeFolder) return false;
|
||||
const rootDir = path.join(DOWNLOAD_DIR, safeFolder);
|
||||
if (!fs.existsSync(rootDir)) return false;
|
||||
|
||||
const hasCompleteFlag = fs.existsSync(path.join(rootDir, ".ph_complete"));
|
||||
const mediaFile = findYoutubeMediaFile(rootDir, false);
|
||||
if (!mediaFile) return false;
|
||||
const absMedia = path.join(rootDir, mediaFile);
|
||||
if (!fs.existsSync(absMedia)) return false;
|
||||
|
||||
const infoJsonName = findYoutubeInfoJson(rootDir);
|
||||
let infoJson = null;
|
||||
if (infoJsonName) {
|
||||
try {
|
||||
infoJson = JSON.parse(fs.readFileSync(path.join(rootDir, infoJsonName), "utf-8"));
|
||||
} catch (err) {
|
||||
console.warn("⚠️ Rabbit info.json okunamadı:", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
const extractor = String(infoJson?.extractor || infoJson?.extractor_key || "").toLowerCase();
|
||||
const webpageUrl = String(infoJson?.webpage_url || infoJson?.original_url || "");
|
||||
const isPornhub =
|
||||
hasCompleteFlag ||
|
||||
safeFolder.startsWith("ph_") ||
|
||||
extractor.includes("pornhub") ||
|
||||
webpageUrl.includes("pornhub.com");
|
||||
if (!isPornhub) return false;
|
||||
|
||||
const viewKey = extractPornhubViewKey(safeFolder) || infoJson?.id || null;
|
||||
const title = infoJson?.title || deriveYoutubeTitle(mediaFile, viewKey);
|
||||
const url =
|
||||
infoJson?.webpage_url ||
|
||||
infoJson?.original_url ||
|
||||
(viewKey ? `https://www.pornhub.com/view_video.php?viewkey=${viewKey}` : null);
|
||||
let addedAt = Date.now();
|
||||
if (Number.isFinite(infoJson?.timestamp)) {
|
||||
addedAt = Number(infoJson.timestamp) * 1000;
|
||||
} else {
|
||||
try {
|
||||
addedAt = fs.statSync(absMedia).mtimeMs || Date.now();
|
||||
} catch (err) {
|
||||
/* no-op */
|
||||
}
|
||||
}
|
||||
const stats = fs.statSync(absMedia);
|
||||
const mediaInfo = await extractMediaInfo(absMedia).catch(() => null);
|
||||
|
||||
const job = {
|
||||
id: safeFolder,
|
||||
folderId: safeFolder,
|
||||
savePath: rootDir,
|
||||
url,
|
||||
added: addedAt,
|
||||
title,
|
||||
files: [
|
||||
{
|
||||
index: 0,
|
||||
name: mediaFile.replace(/\\/g, "/"),
|
||||
length: stats.size
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
writeRabbitMetadata(job, absMedia, mediaInfo, infoJson);
|
||||
return true;
|
||||
}
|
||||
|
||||
function countRabbitItems() {
|
||||
try {
|
||||
const entries = fs
|
||||
@@ -4528,6 +4620,14 @@ function detectMediaFlagsForPath(info, relWithinRoot, isDirectory) {
|
||||
const flags = { movies: false, tv: false };
|
||||
if (!info || typeof info !== "object") return flags;
|
||||
|
||||
const infoFiles = info.files || {};
|
||||
const isExternalMedia =
|
||||
info.tracker === "youtube" ||
|
||||
info.source === "youtube" ||
|
||||
info.source === "pornhub" ||
|
||||
Object.values(infoFiles).some((meta) => Boolean(meta?.youtube));
|
||||
if (isExternalMedia) return flags;
|
||||
|
||||
const normalized = normalizeTrashPath(relWithinRoot);
|
||||
const matchesPath = (candidate) => {
|
||||
const normalizedCandidate = normalizeTrashPath(candidate);
|
||||
@@ -4541,8 +4641,7 @@ function detectMediaFlagsForPath(info, relWithinRoot, isDirectory) {
|
||||
return normalizedCandidate === normalized;
|
||||
};
|
||||
|
||||
const files = info.files || {};
|
||||
for (const [key, meta] of Object.entries(files)) {
|
||||
for (const [key, meta] of Object.entries(infoFiles)) {
|
||||
if (!meta) continue;
|
||||
if (!matchesPath(key)) continue;
|
||||
if (meta.movieMatch) flags.movies = true;
|
||||
@@ -5518,6 +5617,10 @@ app.delete("/api/file", requireAuth, (req, res) => {
|
||||
trashStateCache.delete(folderId);
|
||||
}
|
||||
|
||||
if (folderId && removeRabbitMetadata(folderId)) {
|
||||
broadcastRabbitCount();
|
||||
}
|
||||
|
||||
if (folderId) {
|
||||
let matchedInfoHash = null;
|
||||
for (const [infoHash, entry] of torrents.entries()) {
|
||||
@@ -5995,7 +6098,7 @@ app.get("/api/trash", requireAuth, (req, res) => {
|
||||
});
|
||||
|
||||
// --- 🗑️ Çöpten geri yükleme API (.trash flag sistemi) ---
|
||||
app.post("/api/trash/restore", requireAuth, (req, res) => {
|
||||
app.post("/api/trash/restore", requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { trashName } = req.body;
|
||||
|
||||
@@ -6021,6 +6124,9 @@ app.post("/api/trash/restore", requireAuth, (req, res) => {
|
||||
console.log(`♻️ Öğe geri yüklendi: ${safeName}`);
|
||||
|
||||
broadcastFileUpdate(rootFolder);
|
||||
if (await rebuildRabbitMetadataForFolder(rootFolder)) {
|
||||
broadcastRabbitCount();
|
||||
}
|
||||
if (mediaFlags.movies || mediaFlags.tv) {
|
||||
queueMediaRescan({
|
||||
movies: mediaFlags.movies,
|
||||
@@ -6463,9 +6569,22 @@ app.get("/api/rabbit", requireAuth, (req, res) => {
|
||||
const items = [];
|
||||
for (const folder of entries) {
|
||||
const metaPath = path.join(RABBIT_DATA_ROOT, folder, "metadata.json");
|
||||
const downloadRoot = path.join(DOWNLOAD_DIR, folder);
|
||||
if (!fs.existsSync(metaPath) || !fs.existsSync(downloadRoot)) {
|
||||
removeRabbitMetadata(folder);
|
||||
continue;
|
||||
}
|
||||
if (!fs.existsSync(metaPath)) continue;
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(metaPath, "utf-8"));
|
||||
const fileRel = data?.file ? String(data.file) : null;
|
||||
if (fileRel) {
|
||||
const filePath = path.join(downloadRoot, fileRel);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
removeRabbitMetadata(folder);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
items.push({
|
||||
id: data.id || folder,
|
||||
title: data.title || folder,
|
||||
|
||||
Reference in New Issue
Block a user