TMDB Entegrasyonu
This commit is contained in:
@@ -1,12 +1,15 @@
|
||||
<script>
|
||||
import { Router, Route } from "svelte-routing";
|
||||
import { onMount } from "svelte";
|
||||
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";
|
||||
import Movies from "./routes/Movies.svelte";
|
||||
import Login from "./routes/Login.svelte";
|
||||
import { refreshMovieCount } from "./stores/movieStore.js";
|
||||
|
||||
const token = localStorage.getItem("token");
|
||||
|
||||
@@ -21,6 +24,12 @@
|
||||
function closeSidebar() {
|
||||
menuOpen = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (token) {
|
||||
refreshMovieCount();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if token}
|
||||
@@ -35,6 +44,7 @@
|
||||
|
||||
<Route path="/" component={Files} />
|
||||
<Route path="/files" component={Files} />
|
||||
<Route path="/movies" component={Movies} />
|
||||
<Route path="/transfers" component={Transfers} />
|
||||
<Route path="/sharing" component={Sharing} />
|
||||
<Route path="/trash" component={Trash} />
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
<script>
|
||||
import { Link } from "svelte-routing";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { createEventDispatcher, onDestroy } from "svelte";
|
||||
import { movieCount } from "../stores/movieStore.js";
|
||||
|
||||
export let menuOpen = false;
|
||||
const dispatch = createEventDispatcher();
|
||||
let hasMovies = false;
|
||||
|
||||
const unsubscribe = movieCount.subscribe((count) => {
|
||||
hasMovies = (count ?? 0) > 0;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
// Menü öğesine tıklanınca sidebar'ı kapat
|
||||
function handleLinkClick() {
|
||||
@@ -27,6 +37,20 @@
|
||||
Files
|
||||
</Link>
|
||||
|
||||
{#if hasMovies}
|
||||
<Link
|
||||
to="/movies"
|
||||
class="item"
|
||||
getProps={({ isCurrent }) => ({
|
||||
class: isCurrent ? "item active" : "item",
|
||||
})}
|
||||
on:click={handleLinkClick}
|
||||
>
|
||||
<i class="fa-solid fa-film icon"></i>
|
||||
Movies
|
||||
</Link>
|
||||
{/if}
|
||||
|
||||
<Link
|
||||
to="/transfers"
|
||||
class="item"
|
||||
|
||||
@@ -2,18 +2,42 @@
|
||||
import { onMount, tick } from "svelte";
|
||||
import { API, apiFetch } from "../utils/api.js";
|
||||
import { cleanFileName } from "../utils/filename.js";
|
||||
import { refreshMovieCount } from "../stores/movieStore.js";
|
||||
let files = [];
|
||||
let showModal = false;
|
||||
let selectedVideo = null;
|
||||
let subtitleURL = null;
|
||||
let subtitleLang = "en";
|
||||
let subtitleLabel = "Custom Subtitles";
|
||||
let viewMode = "grid";
|
||||
let selectedItems = new Set();
|
||||
let allSelected = false;
|
||||
// 🎬 Player kontrolleri
|
||||
let videoEl;
|
||||
let isPlaying = false;
|
||||
const VIEW_KEY = "filesViewMode";
|
||||
let viewMode = "grid";
|
||||
if (typeof window !== "undefined") {
|
||||
const storedView = window.localStorage.getItem(VIEW_KEY);
|
||||
if (storedView === "grid" || storedView === "list") {
|
||||
viewMode = storedView;
|
||||
}
|
||||
}
|
||||
let selectedItems = new Set();
|
||||
let allSelected = false;
|
||||
let pendingPlayTarget = null;
|
||||
if (typeof window !== "undefined") {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const playParam = params.get("play");
|
||||
if (playParam) {
|
||||
try {
|
||||
pendingPlayTarget = decodeURIComponent(playParam);
|
||||
} catch (err) {
|
||||
pendingPlayTarget = playParam;
|
||||
}
|
||||
params.delete("play");
|
||||
const search = params.toString();
|
||||
const newUrl = `${window.location.pathname}${search ? `?${search}` : ""}${window.location.hash}`;
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
}
|
||||
}
|
||||
// 🎬 Player kontrolleri
|
||||
let videoEl;
|
||||
let isPlaying = false;
|
||||
let currentTime = 0;
|
||||
let duration = 0;
|
||||
let volume = 1;
|
||||
@@ -39,6 +63,8 @@
|
||||
[...selectedItems].filter((name) => existing.has(name)),
|
||||
);
|
||||
allSelected = files.length > 0 && selectedItems.size === files.length;
|
||||
tryAutoPlay();
|
||||
refreshMovieCount();
|
||||
}
|
||||
function formatSize(bytes) {
|
||||
if (!bytes) return "0 MB";
|
||||
@@ -52,8 +78,33 @@
|
||||
if (Number.isNaN(date.getTime())) return "—";
|
||||
return date.toLocaleString();
|
||||
}
|
||||
function formatVideoCodec(info) {
|
||||
if (!info) return null;
|
||||
const codec = info.codec ? info.codec.toUpperCase() : null;
|
||||
const resolution =
|
||||
info.resolution || (info.height ? `${info.height}p` : null);
|
||||
return [codec, resolution].filter(Boolean).join(" · ");
|
||||
}
|
||||
function formatAudioCodec(info) {
|
||||
if (!info) return null;
|
||||
const codec = info.codec ? info.codec.toUpperCase() : null;
|
||||
let channels = null;
|
||||
if (info.channelLayout) channels = info.channelLayout.toUpperCase();
|
||||
else if (info.channels) {
|
||||
channels =
|
||||
info.channels === 6
|
||||
? "5.1"
|
||||
: info.channels === 2
|
||||
? "2.0"
|
||||
: `${info.channels}`;
|
||||
}
|
||||
return [codec, channels].filter(Boolean).join(" · ");
|
||||
}
|
||||
function toggleView() {
|
||||
viewMode = viewMode === "grid" ? "list" : "grid";
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(VIEW_KEY, viewMode);
|
||||
}
|
||||
}
|
||||
function toggleSelection(file) {
|
||||
const next = new Set(selectedItems);
|
||||
@@ -80,6 +131,27 @@
|
||||
selectedItems = new Set();
|
||||
allSelected = false;
|
||||
}
|
||||
|
||||
function tryAutoPlay() {
|
||||
if (!pendingPlayTarget || files.length === 0) return;
|
||||
const normalizedTarget = pendingPlayTarget
|
||||
.replace(/^\.?\//, "")
|
||||
.replace(/\\/g, "/");
|
||||
const candidate =
|
||||
files.find((f) => {
|
||||
const normalizedName = f.name
|
||||
.replace(/^\.?\//, "")
|
||||
.replace(/\\/g, "/");
|
||||
return (
|
||||
normalizedName === normalizedTarget ||
|
||||
normalizedName.endsWith(normalizedTarget)
|
||||
);
|
||||
}) || null;
|
||||
if (candidate) {
|
||||
pendingPlayTarget = null;
|
||||
openModal(candidate);
|
||||
}
|
||||
}
|
||||
async function openModal(f) {
|
||||
stopCurrentVideo();
|
||||
videoEl = null;
|
||||
@@ -251,6 +323,7 @@
|
||||
|
||||
selectedItems = new Set(failed);
|
||||
allSelected = failed.length > 0 && failed.length === files.length;
|
||||
await refreshMovieCount();
|
||||
}
|
||||
onMount(async () => {
|
||||
await loadFiles(); // önce dosyaları getir
|
||||
@@ -288,6 +361,32 @@
|
||||
}
|
||||
};
|
||||
function handleKey(e) {
|
||||
const active = document.activeElement;
|
||||
const tag = active?.tagName;
|
||||
const type = active?.type?.toLowerCase();
|
||||
const isTextInput =
|
||||
tag === "INPUT" &&
|
||||
[
|
||||
"text",
|
||||
"search",
|
||||
"email",
|
||||
"password",
|
||||
"number",
|
||||
"url",
|
||||
"tel"
|
||||
].includes(type);
|
||||
const isEditable =
|
||||
(tag === "TEXTAREA" || isTextInput || active?.isContentEditable) ?? false;
|
||||
|
||||
if (e.metaKey && e.key && e.key.toLowerCase() === "backspace") {
|
||||
if (isEditable) return;
|
||||
if (selectedItems.size > 0) {
|
||||
e.preventDefault();
|
||||
deleteSelectedFiles();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const isCmd = e.metaKey || e.ctrlKey;
|
||||
if (isCmd && e.key.toLowerCase() === "a") {
|
||||
e.preventDefault();
|
||||
@@ -312,8 +411,13 @@
|
||||
|
||||
<section class="files" on:click={handleFilesClick}>
|
||||
<div class="files-header">
|
||||
<h2>Media Library</h2>
|
||||
<div class="header-title">
|
||||
<h2>Media Library</h2>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
{#if files.length > 0 && selectedItems.size > 0}
|
||||
<span class="selection-count">{selectedItems.size} dosya seçildi</span>
|
||||
{/if}
|
||||
{#if files.length > 0 && selectedItems.size > 0}
|
||||
<button
|
||||
class="select-all-btn"
|
||||
@@ -394,6 +498,35 @@
|
||||
{f.tracker ? f.tracker : "Bilinmiyor"}
|
||||
</span>
|
||||
</div>
|
||||
{#if f.mediaInfo?.video || f.mediaInfo?.audio}
|
||||
<div class="meta-line codecs">
|
||||
{#if f.extension}
|
||||
<span class="codec-chip file-type">
|
||||
{#if f.type?.startsWith("image/")}
|
||||
<i class="fa-solid fa-file-image"></i>
|
||||
{:else}
|
||||
<i class="fa-solid fa-file-video"></i>
|
||||
{/if}
|
||||
{f.extension.toUpperCase()}
|
||||
</span>
|
||||
{/if}
|
||||
{#if f.mediaInfo?.video}
|
||||
<span class="codec-chip">
|
||||
<i class="fa-solid fa-film"></i>
|
||||
{formatVideoCodec(f.mediaInfo.video)}
|
||||
</span>
|
||||
{/if}
|
||||
{#if f.mediaInfo?.video && f.mediaInfo?.audio}
|
||||
<span class="codec-separator">|</span>
|
||||
{/if}
|
||||
{#if f.mediaInfo?.audio}
|
||||
<span class="codec-chip">
|
||||
<i class="fa-solid fa-volume-high"></i>
|
||||
{formatAudioCodec(f.mediaInfo.audio)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="media-type-icon">
|
||||
@@ -733,6 +866,16 @@
|
||||
margin-bottom: 18px;
|
||||
gap: 12px;
|
||||
}
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
.selection-count {
|
||||
font-size: 13px;
|
||||
color: #6a6a6a;
|
||||
font-weight: 500;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -967,6 +1110,32 @@
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.meta-line.codecs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #5a5a5a;
|
||||
font-size: 13px;
|
||||
}
|
||||
.codec-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #4e4e4e;
|
||||
font-weight: 500;
|
||||
}
|
||||
.codec-chip.file-type {
|
||||
color: #1f1f1f;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.codec-chip i {
|
||||
color: #ffc107;
|
||||
font-size: 12px;
|
||||
}
|
||||
.codec-separator {
|
||||
color: #7a7a7a;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status-badge {
|
||||
color: #2f8a4d;
|
||||
font-weight: 600;
|
||||
|
||||
1047
client/src/routes/Movies.svelte
Normal file
1047
client/src/routes/Movies.svelte
Normal file
File diff suppressed because it is too large
Load Diff
16
client/src/stores/movieStore.js
Normal file
16
client/src/stores/movieStore.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { writable } from "svelte/store";
|
||||
import { apiFetch } from "../utils/api.js";
|
||||
|
||||
export const movieCount = writable(0);
|
||||
|
||||
export async function refreshMovieCount() {
|
||||
try {
|
||||
const resp = await apiFetch("/api/movies");
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
const list = await resp.json();
|
||||
movieCount.set(Array.isArray(list) ? list.length : 0);
|
||||
} catch (err) {
|
||||
console.warn("⚠️ Movie count güncellenemedi:", err?.message || err);
|
||||
movieCount.set(0);
|
||||
}
|
||||
}
|
||||
@@ -1,98 +1,176 @@
|
||||
const LOWERCASE_WORDS = new Set(["di", "da", "de", "of", "and", "the", "la"]);
|
||||
|
||||
const IGNORED_TOKENS = new Set(
|
||||
[
|
||||
"hdrip",
|
||||
"hdr",
|
||||
"webrip",
|
||||
"webdl",
|
||||
"dl",
|
||||
"web-dl",
|
||||
"blu",
|
||||
"bluray",
|
||||
"bdrip",
|
||||
"dvdrip",
|
||||
"remux",
|
||||
"multi",
|
||||
"audio",
|
||||
"aac",
|
||||
"ac3",
|
||||
"ddp",
|
||||
"dts",
|
||||
"xvid",
|
||||
"x264",
|
||||
"x265",
|
||||
"x266",
|
||||
"h264",
|
||||
"h265",
|
||||
"hevc",
|
||||
"hdr10",
|
||||
"hdr10plus",
|
||||
"amzn",
|
||||
"nf",
|
||||
"netflix",
|
||||
"disney",
|
||||
"imax",
|
||||
"atmos",
|
||||
"dubbed",
|
||||
"dublado",
|
||||
"ita",
|
||||
"eng",
|
||||
"turkce",
|
||||
"multi-audio",
|
||||
"eazy",
|
||||
"tbmovies",
|
||||
"tbm",
|
||||
"bone"
|
||||
].map((t) => t.toLowerCase())
|
||||
);
|
||||
|
||||
const QUALITY_PATTERNS = [
|
||||
/^\d{3,4}p$/,
|
||||
/^s\d{1,2}e\d{1,2}$/i,
|
||||
/^(ac3|aac\d*|ddp\d*|dts|dubbed|dual|multi|hc)$/i,
|
||||
/^(x|h)?26[45]$/i
|
||||
];
|
||||
|
||||
export function extractTitleAndYear(rawName) {
|
||||
if (!rawName) return { title: "", year: null };
|
||||
|
||||
const withoutExt = rawName.replace(/\.[^/.]+$/, "");
|
||||
const normalized = withoutExt
|
||||
.replace(/[\[\]\(\)\-]/g, " ")
|
||||
.replace(/[._]/g, " ");
|
||||
|
||||
const tokens = normalized
|
||||
.split(/\s+/)
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!tokens.length) {
|
||||
return { title: withoutExt.trim(), year: null };
|
||||
}
|
||||
|
||||
const yearIndex = tokens.findIndex((token) => /^(19|20)\d{2}$/.test(token));
|
||||
if (yearIndex > 0) {
|
||||
const yearToken = tokens[yearIndex];
|
||||
const candidateTokens = tokens.slice(0, yearIndex);
|
||||
const filteredTitleTokens = candidateTokens.filter((token) => {
|
||||
const lower = token.toLowerCase();
|
||||
if (IGNORED_TOKENS.has(lower)) return false;
|
||||
if (/^\d{3,4}p$/.test(lower)) return false;
|
||||
if (/^(ac3|aac\d*|ddp\d*|dts|dubbed|dual|multi|hc)$/i.test(lower))
|
||||
return false;
|
||||
if (/^(x|h)?26[45]$/i.test(lower)) return false;
|
||||
if (lower.includes("hdrip") || lower.includes("web-dl")) return false;
|
||||
if (lower.includes("multi-audio")) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const titleTokens = filteredTitleTokens.length
|
||||
? filteredTitleTokens
|
||||
: candidateTokens;
|
||||
const title = titleTokens.join(" ").replace(/\s+/g, " ").trim();
|
||||
|
||||
return {
|
||||
title: title || withoutExt.trim(),
|
||||
year: Number(yearToken)
|
||||
};
|
||||
}
|
||||
|
||||
let year = null;
|
||||
const filtered = [];
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
const lower = token.toLowerCase();
|
||||
|
||||
if (!year && /^(19|20)\d{2}$/.test(lower)) {
|
||||
year = Number(lower);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (QUALITY_PATTERNS.some((pattern) => pattern.test(token))) continue;
|
||||
if (lower === "web" && tokens[i + 1]?.toLowerCase() === "dl") {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (IGNORED_TOKENS.has(lower)) continue;
|
||||
if (lower.includes("multi-audio")) continue;
|
||||
|
||||
filtered.push(token);
|
||||
}
|
||||
|
||||
const title = filtered.join(" ").replace(/\s+/g, " ").trim();
|
||||
return {
|
||||
title: title || withoutExt.trim(),
|
||||
year
|
||||
};
|
||||
}
|
||||
|
||||
function titleCase(value) {
|
||||
if (!value) return "";
|
||||
|
||||
return value
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.map((segment, index) => {
|
||||
const lower = segment.toLowerCase();
|
||||
if (index !== 0 && LOWERCASE_WORDS.has(lower)) {
|
||||
return lower;
|
||||
}
|
||||
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Dosya adını temizler ve sadeleştirir.
|
||||
* Örnek:
|
||||
* The.Astronaut.2025.1080p.WEBRip.x265-KONTRAST
|
||||
* → "The Astronaut (2025)"
|
||||
* 1761244874124/Gen.V.S02E08.Cavallo.di.Troia.ITA.ENG.1080p.AMZN.WEB-DL.DDP5.1.H.264-MeM.GP.mkv
|
||||
* → "Gen V S02E08 Cavallo Di Troia"
|
||||
* Dosya adını temizleyip başlık/yıl bilgisi çıkarır.
|
||||
* Örn: The.Astronaut.2025.1080p.WEBRip → "The Astronaut (2025)"
|
||||
*/
|
||||
export function cleanFileName(fullPath) {
|
||||
if (!fullPath) return "";
|
||||
|
||||
// 1️⃣ Klasör yolunu kaldır
|
||||
let name = fullPath.split("/").pop();
|
||||
const baseName = fullPath.split("/").pop() || "";
|
||||
const withoutExt = baseName.replace(/\.[^.]+$/, "");
|
||||
|
||||
// 2️⃣ Uzantıyı kaldır
|
||||
name = name.replace(/\.[^.]+$/, "");
|
||||
const episodeMatch = withoutExt.match(/(S\d{1,2}E\d{1,2})/i);
|
||||
const { title, year } = extractTitleAndYear(withoutExt);
|
||||
|
||||
// 3️⃣ Noktaları ve alt tireleri boşluğa çevir
|
||||
name = name.replace(/[._]+/g, " ");
|
||||
let result = titleCase(title);
|
||||
|
||||
// 4️⃣ Gereksiz etiketleri kaldır
|
||||
const trashWords = [
|
||||
"1080p",
|
||||
"720p",
|
||||
"2160p",
|
||||
"4k",
|
||||
"bluray",
|
||||
"web[- ]?dl",
|
||||
"webrip",
|
||||
"hdrip",
|
||||
"x264",
|
||||
"x265",
|
||||
"hevc",
|
||||
"aac",
|
||||
"h264",
|
||||
"h265",
|
||||
"ddp5",
|
||||
"dvdrip",
|
||||
"brrip",
|
||||
"remux",
|
||||
"multi",
|
||||
"sub",
|
||||
"subs",
|
||||
"turkce",
|
||||
"ita",
|
||||
"eng",
|
||||
"dublado",
|
||||
"dubbed",
|
||||
"extended",
|
||||
"unrated",
|
||||
"repack",
|
||||
"proper",
|
||||
"kontrast",
|
||||
"yify",
|
||||
"ettv",
|
||||
"rarbg",
|
||||
"hdtv",
|
||||
"amzn",
|
||||
"nf",
|
||||
"netflix",
|
||||
"mem",
|
||||
"gp"
|
||||
];
|
||||
const trashRegex = new RegExp(`\\b(${trashWords.join("|")})\\b`, "gi");
|
||||
name = name.replace(trashRegex, " ");
|
||||
|
||||
// 5️⃣ Parantez veya köşeli parantez içindekileri kaldır
|
||||
name = name.replace(/[\[\(].*?[\]\)]/g, " ");
|
||||
|
||||
// 6️⃣ Fazla tireleri ve sayıları temizle
|
||||
name = name
|
||||
.replace(/[-]+/g, " ")
|
||||
.replace(/\b\d{3,4}\b/g, " ") // tek başına 1080, 2025 gibi
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim();
|
||||
|
||||
// 7️⃣ Dizi formatını (S02E08) koru
|
||||
const episodeMatch = name.match(/(S\d{1,2}E\d{1,2})/i);
|
||||
if (episodeMatch) {
|
||||
const epTag = episodeMatch[0].toUpperCase();
|
||||
// örnek: Gen V S02E08 Cavallo di Troia
|
||||
name = name.replace(episodeMatch[0], epTag);
|
||||
const tag = episodeMatch[0].toUpperCase();
|
||||
result = result
|
||||
? `${result} ${tag}`
|
||||
: tag;
|
||||
} else if (year) {
|
||||
result = result ? `${result} (${year})` : `${withoutExt} (${year})`;
|
||||
}
|
||||
|
||||
// 8️⃣ Baş harfleri büyüt (küçük kelimeleri koruyarak)
|
||||
name = name
|
||||
.split(" ")
|
||||
.filter((w) => w.length > 0)
|
||||
.map((w) => {
|
||||
if (["di", "da", "de", "of", "and", "the"].includes(w.toLowerCase()))
|
||||
return w.toLowerCase();
|
||||
return w[0].toUpperCase() + w.slice(1).toLowerCase();
|
||||
})
|
||||
.join(" ")
|
||||
.trim();
|
||||
if (!result) {
|
||||
result = withoutExt.replace(/[._]+/g, " ");
|
||||
}
|
||||
|
||||
return name;
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user