diff --git a/client/src/App.svelte b/client/src/App.svelte index 7ddf087..76b482c 100644 --- a/client/src/App.svelte +++ b/client/src/App.svelte @@ -1,12 +1,15 @@ {#if token} @@ -35,6 +44,7 @@ + diff --git a/client/src/components/Sidebar.svelte b/client/src/components/Sidebar.svelte index ad91f6b..5d74a06 100644 --- a/client/src/components/Sidebar.svelte +++ b/client/src/components/Sidebar.svelte @@ -1,9 +1,19 @@ + +
+
+
+

Movies

+ +
+ + {#if loading} +
Loading movies…
+ {:else if error} +
{error}
+ {:else if movies.length === 0} +
No movie metadata found yet.
+ {:else} +
+ {#each movies as movie} +
openMovie(movie)}> + {#if movie.poster} +
+ {movie.title} +
+ {:else} +
+ +
+ {/if} +
+
{movie.title}
+ {#if yearFromMovie(movie)} +
{yearFromMovie(movie)}
+ {/if} +
+
+ {/each} +
+ {/if} +
+ +{#if selectedMovie} +
+
+
+ + +
+
+ {#if selectedMovie.poster} + {selectedMovie.title} + {:else} +
+ +
+ {/if} +
+ +
+

{selectedMovie.title}

+
+ {#if yearFromMovie(selectedMovie)} + {yearFromMovie(selectedMovie)} + {/if} + {#if selectedRuntime} + • {selectedRuntime} + {/if} + {#if selectedMovie.voteAverage} + + • + + {selectedMovie.voteAverage.toFixed(1)} + {#if selectedMovie.voteCount} + ({selectedMovie.voteCount}) + {/if} + + {/if} +
+ {#if selectedMovie.genres?.length} +
+ {selectedMovie.genres.join(" • ")} +
+ {/if} +
+ {selectedMovie.overview || "No synopsis found."} +
+ {#if selectedVideoInfo || selectedAudioInfo} +
+ {#if selectedVideoInfo} +
+ + {selectedVideoInfo} +
+ {/if} + {#if selectedAudioInfo} +
+ + {selectedAudioInfo} +
+ {/if} +
+ {/if} + {#if selectedMovie.videoPath} +
+ +
+ {/if} +
+
+
+
+{/if} + +{#if showPlayerModal && selectedVideo} + +{/if} + + diff --git a/client/src/stores/movieStore.js b/client/src/stores/movieStore.js new file mode 100644 index 0000000..8bd17ae --- /dev/null +++ b/client/src/stores/movieStore.js @@ -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); + } +} diff --git a/client/src/utils/filename.js b/client/src/utils/filename.js index 240d34b..8577100 100644 --- a/client/src/utils/filename.js +++ b/client/src/utils/filename.js @@ -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(); } diff --git a/docker-compose.yml b/docker-compose.yml index a6da150..28c1312 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,3 +14,4 @@ services: environment: USERNAME: ${USERNAME} PASSWORD: ${PASSWORD} + TMDB_API_KEY: ${TMDB_API_KEY} diff --git a/server/package.json b/server/package.json index 62f7908..9ca3cf8 100644 --- a/server/package.json +++ b/server/package.json @@ -10,7 +10,8 @@ "express": "^4.19.2", "mime-types": "^2.1.35", "multer": "^1.4.5-lts.1", + "node-fetch": "^3.3.2", "webtorrent": "^1.9.7", "ws": "^8.18.0" } -} \ No newline at end of file +} diff --git a/server/server.js b/server/server.js index 1d691cd..b95c07d 100644 --- a/server/server.js +++ b/server/server.js @@ -30,8 +30,14 @@ const CACHE_DIR = path.join(__dirname, "cache"); const THUMBNAIL_DIR = path.join(CACHE_DIR, "thumbnails"); const VIDEO_THUMB_ROOT = path.join(THUMBNAIL_DIR, "videos"); const IMAGE_THUMB_ROOT = path.join(THUMBNAIL_DIR, "images"); +const MOVIE_DATA_ROOT = path.join(CACHE_DIR, "movie_data"); -for (const dir of [THUMBNAIL_DIR, VIDEO_THUMB_ROOT, IMAGE_THUMB_ROOT]) { +for (const dir of [ + THUMBNAIL_DIR, + VIDEO_THUMB_ROOT, + IMAGE_THUMB_ROOT, + MOVIE_DATA_ROOT +]) { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } @@ -39,6 +45,15 @@ const VIDEO_THUMBNAIL_TIME = process.env.VIDEO_THUMBNAIL_TIME || "00:00:05"; const VIDEO_EXTS = [".mp4", ".webm", ".mkv", ".mov", ".m4v"]; const generatingThumbnails = new Set(); const INFO_FILENAME = "info.json"; +const TMDB_API_KEY = process.env.TMDB_API_KEY; +const TMDB_BASE_URL = "https://api.themoviedb.org/3"; +const TMDB_IMG_BASE = + process.env.TMDB_IMAGE_BASE || "https://image.tmdb.org/t/p/original"; +const FFPROBE_PATH = process.env.FFPROBE_PATH || "ffprobe"; +const FFPROBE_MAX_BUFFER = + Number(process.env.FFPROBE_MAX_BUFFER) > 0 + ? Number(process.env.FFPROBE_MAX_BUFFER) + : 10 * 1024 * 1024; app.use(cors()); app.use(express.json()); @@ -93,6 +108,15 @@ function upsertInfoFile(savePath, partial) { ...partial, updatedAt: timestamp }; + if (partial && Object.prototype.hasOwnProperty.call(partial, "files")) { + if (partial.files && typeof partial.files === "object") { + next.files = partial.files; + } else { + delete next.files; + } + } else if (current.files && next.files === undefined) { + next.files = current.files; + } if (!next.createdAt) { next.createdAt = current.createdAt ?? partial?.createdAt ?? timestamp; @@ -169,6 +193,100 @@ function markGenerating(absThumb, add) { else generatingThumbnails.delete(absThumb); } +function toFiniteNumber(value) { + const num = Number(value); + return Number.isFinite(num) ? num : null; +} + +function parseFrameRate(value) { + if (!value) return null; + if (typeof value === "number") return Number.isFinite(value) ? value : null; + const parts = String(value).split("/"); + if (parts.length === 2) { + const numerator = Number(parts[0]); + const denominator = Number(parts[1]); + if ( + Number.isFinite(numerator) && + Number.isFinite(denominator) && + denominator !== 0 + ) { + return Number((numerator / denominator).toFixed(3)); + } + } + const num = Number(value); + return Number.isFinite(num) ? num : null; +} + +async function extractMediaInfo(filePath) { + if (!filePath || !fs.existsSync(filePath)) return null; + + return new Promise((resolve) => { + exec( + `${FFPROBE_PATH} -v quiet -print_format json -show_format -show_streams "${filePath}"`, + { maxBuffer: FFPROBE_MAX_BUFFER }, + (err, stdout) => { + if (err) { + console.warn( + `⚠️ ffprobe çalıştırılamadı (${filePath}): ${err.message}` + ); + return resolve(null); + } + try { + const parsed = JSON.parse(stdout); + const streams = Array.isArray(parsed?.streams) ? parsed.streams : []; + const format = parsed?.format || {}; + const videoStream = + streams.find((s) => s.codec_type === "video") || null; + const audioStream = + streams.find((s) => s.codec_type === "audio") || null; + + const mediaInfo = { + format: { + duration: toFiniteNumber(format.duration), + size: toFiniteNumber(format.size), + bitrate: toFiniteNumber(format.bit_rate) + }, + video: videoStream + ? { + codec: videoStream.codec_name || null, + profile: videoStream.profile || null, + width: toFiniteNumber(videoStream.width), + height: toFiniteNumber(videoStream.height), + resolution: videoStream.height + ? `${videoStream.height}p` + : videoStream.width + ? `${videoStream.width}px` + : null, + bitrate: toFiniteNumber(videoStream.bit_rate), + frameRate: parseFrameRate( + videoStream.avg_frame_rate || videoStream.r_frame_rate + ), + pixelFormat: videoStream.pix_fmt || null + } + : null, + audio: audioStream + ? { + codec: audioStream.codec_name || null, + channels: toFiniteNumber(audioStream.channels), + channelLayout: audioStream.channel_layout || null, + bitrate: toFiniteNumber(audioStream.bit_rate), + sampleRate: toFiniteNumber(audioStream.sample_rate) + } + : null + }; + + resolve(mediaInfo); + } catch (parseErr) { + console.warn( + `⚠️ ffprobe çıktısı parse edilemedi (${filePath}): ${parseErr.message}` + ); + resolve(null); + } + } + ); + }); +} + function queueVideoThumbnail(fullPath, relPath) { const { relThumb, absThumb } = getVideoThumbnailPaths(relPath); if (fs.existsSync(absThumb) || generatingThumbnails.has(absThumb)) return; @@ -283,6 +401,438 @@ function resolveThumbnailAbsolute(relThumbPath) { return resolved; } +function resolveMovieDataAbsolute(relPath) { + const normalized = sanitizeRelative(relPath); + const resolved = path.resolve(MOVIE_DATA_ROOT, normalized); + if ( + resolved !== MOVIE_DATA_ROOT && + !resolved.startsWith(MOVIE_DATA_ROOT + path.sep) + ) { + return null; + } + return resolved; +} + +function removeAllThumbnailsForRoot(rootFolder) { + const safe = sanitizeRelative(rootFolder); + if (!safe) return; + + const targets = [ + path.join(VIDEO_THUMB_ROOT, safe), + path.join(IMAGE_THUMB_ROOT, safe) + ]; + + for (const target of targets) { + try { + if (fs.existsSync(target)) { + fs.rmSync(target, { recursive: true, force: true }); + cleanupEmptyDirs(path.dirname(target)); + } + } catch (err) { + console.warn(`⚠️ Thumbnail klasörü silinemedi (${target}): ${err.message}`); + } + } +} + +function movieDataDir(rootFolder) { + return path.join(MOVIE_DATA_ROOT, sanitizeRelative(rootFolder)); +} + +function movieDataPaths(rootFolder) { + const dir = movieDataDir(rootFolder); + return { + dir, + metadata: path.join(dir, "metadata.json"), + poster: path.join(dir, "poster.jpg"), + backdrop: path.join(dir, "backdrop.jpg") + }; +} + +function isTmdbMetadata(metadata) { + if (!metadata) return false; + if (typeof metadata.id === "number") return true; + if (metadata._dupe?.source === "tmdb") return true; + return false; +} + +function parseTitleAndYear(rawName) { + if (!rawName) return { title: null, year: null }; + const withoutExt = rawName.replace(/\.[^/.]+$/, ""); + const cleaned = withoutExt.replace(/[\[\]\(\)\-]/g, " ").replace(/[._]/g, " "); + const tokens = cleaned + .split(/\s+/) + .map((t) => t.trim()) + .filter(Boolean); + + if (!tokens.length) { + return { title: withoutExt.trim(), year: null }; + } + + const ignoredExact = new Set( + [ + "hdrip", + "hdr", + "webrip", + "webdl", + "web", + "dl", + "bluray", + "bdrip", + "dvdrip", + "remux", + "multi", + "audio", + "aac", + "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 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 (ignoredExact.has(lower)) return false; + if (/^\d{3,4}p$/.test(lower)) return false; + if (/^(ac3|aac\d*|ddp\d*|dts|dubbed|dual|multi|hc)$/.test(lower)) + return false; + if (/^(x|h)?26[45]$/.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 (/^\d{3,4}p$/.test(lower)) continue; + if (lower === "web" && tokens[i + 1]?.toLowerCase() === "dl") { + i += 1; + continue; + } + if (/^(ac3|aac\d*|ddp\d*|dts|dubbed|dual|multi|hc)$/.test(lower)) continue; + if (/^(x|h)?26[45]$/.test(lower)) continue; + if (ignoredExact.has(lower)) continue; + if (lower.includes("hdrip") || lower.includes("web-dl")) continue; + if (lower.includes("multi-audio")) continue; + filtered.push(token); + } + + const title = filtered.join(" ").replace(/\s+/g, " ").trim(); + return { title: title || withoutExt.trim(), year }; +} + +async function tmdbFetch(endpoint, params = {}) { + if (!TMDB_API_KEY) return null; + const url = new URL(`${TMDB_BASE_URL}${endpoint}`); + url.searchParams.set("api_key", TMDB_API_KEY); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null && value !== "") { + url.searchParams.set(key, value); + } + } + + const resp = await fetch(url); + if (!resp.ok) { + console.warn(`⚠️ TMDB isteği başarısız (${url}): ${resp.status}`); + return null; + } + return resp.json(); +} + +async function fetchMovieMetadata(title, year) { + if (!TMDB_API_KEY || !title) return null; + console.log("🎬 TMDB araması yapılıyor:", { title, year }); + const search = await tmdbFetch("/search/movie", { + query: title, + year: year || undefined, + include_adult: false, + language: "en-US" + }); + if (!search?.results?.length) { + console.log("🎬 TMDB sonucu bulunamadı:", { title, year }); + return null; + } + + const match = search.results[0]; + const details = await tmdbFetch(`/movie/${match.id}`, { + language: "en-US", + append_to_response: "release_dates,credits,translations" + }); + if (!details) return null; + + if (details.translations?.translations?.length) { + const translations = details.translations.translations; + const turkish = translations.find( + (t) => t.iso_639_1 === "tr" && t.data + ); + if (turkish?.data) { + const data = turkish.data; + if (data.overview) details.overview = data.overview; + if (data.title) details.title = data.title; + if (data.tagline) details.tagline = data.tagline; + } + } + + console.log("🎬 TMDB sonucu bulundu:", { + title: match.title || match.name, + year: match.release_date ? match.release_date.slice(0, 4) : null, + id: match.id + }); + return { + ...details, + poster_path: match.poster_path || details.poster_path, + backdrop_path: match.backdrop_path || details.backdrop_path, + matched_title: match.title || match.name || title, + matched_year: match.release_date + ? Number(match.release_date.slice(0, 4)) + : year || null + }; +} + +async function downloadImage(url, targetPath) { + if (!url) return false; + try { + const resp = await fetch(url); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const arrayBuffer = await resp.arrayBuffer(); + ensureDirForFile(targetPath); + fs.writeFileSync(targetPath, Buffer.from(arrayBuffer)); + return true; + } catch (err) { + console.warn(`⚠️ Görsel indirilemedi (${url}): ${err.message}`); + return false; + } +} + +async function ensureMovieData( + rootFolder, + displayName, + bestVideoPath, + precomputedMediaInfo = null +) { + if (!TMDB_API_KEY) return precomputedMediaInfo || null; + console.log("🎬 ensureMovieData çağrıldı:", { rootFolder, displayName }); + + const paths = movieDataPaths(rootFolder); + const normalizedRoot = sanitizeRelative(rootFolder); + const normalizedVideoPath = bestVideoPath + ? bestVideoPath.replace(/\\/g, "/") + : null; + + let metadata = null; + if (fs.existsSync(paths.metadata)) { + try { + metadata = JSON.parse(fs.readFileSync(paths.metadata, "utf-8")); + console.log("🎬 Mevcut metadata bulundu:", rootFolder); + } catch (err) { + console.warn( + `⚠️ metadata.json okunamadı (${paths.metadata}): ${err.message}` + ); + } + } + + let fetchedMetadata = false; + const hasTmdbMetadata = isTmdbMetadata(metadata); + + if (!hasTmdbMetadata) { + const { title, year } = parseTitleAndYear(displayName); + console.log("🎬 TMDB araması için analiz:", { displayName, title, year }); + if (title) { + const fetched = await fetchMovieMetadata(title, year); + if (fetched) { + metadata = fetched; + fetchedMetadata = true; + } + } + } + + if (!isTmdbMetadata(metadata)) { + console.log( + "🎬 TMDB verisi bulunamadı, movie_data oluşturulmadı:", + rootFolder + ); + removeMovieData(rootFolder); + return precomputedMediaInfo || null; + } + + ensureDirForFile(paths.metadata); + + let videoPath = + (normalizedVideoPath && normalizedVideoPath.trim()) || + metadata._dupe?.videoPath || + guessPrimaryVideo(rootFolder) || + null; + if (videoPath) videoPath = videoPath.replace(/\\/g, "/"); + + const videoAbsPath = videoPath + ? path.join(DOWNLOAD_DIR, normalizedRoot, videoPath) + : null; + + let mediaInfo = metadata._dupe?.mediaInfo || precomputedMediaInfo || null; + if (!mediaInfo && videoAbsPath && fs.existsSync(videoAbsPath)) { + mediaInfo = await extractMediaInfo(videoAbsPath); + if (mediaInfo) { + console.log("🎬 Video teknik bilgileri elde edildi:", { + rootFolder, + mediaInfo + }); + } + } + + const posterUrl = metadata.poster_path + ? `${TMDB_IMG_BASE}${metadata.poster_path}` + : null; + const backdropUrl = metadata.backdrop_path + ? `${TMDB_IMG_BASE}${metadata.backdrop_path}` + : null; + + const enriched = { + ...metadata, + _dupe: { + ...(metadata._dupe || {}), + folder: rootFolder, + videoPath, + mediaInfo, + source: "tmdb", + fetchedAt: fetchedMetadata + ? Date.now() + : metadata._dupe?.fetchedAt || Date.now() + } + }; + + fs.writeFileSync(paths.metadata, JSON.stringify(enriched, null, 2), "utf-8"); + + if (posterUrl && (!fs.existsSync(paths.poster) || fetchedMetadata)) { + await downloadImage(posterUrl, paths.poster); + } + if (backdropUrl && (!fs.existsSync(paths.backdrop) || fetchedMetadata)) { + await downloadImage(backdropUrl, paths.backdrop); + } + + console.log(`🎬 TMDB verisi hazır: ${rootFolder}`); + return mediaInfo; +} + +function removeMovieData(rootFolder) { + const dir = movieDataDir(rootFolder); + if (fs.existsSync(dir)) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + console.log(`🧹 Movie metadata silindi: ${dir}`); + } catch (err) { + console.warn(`⚠️ Movie metadata temizlenemedi (${dir}): ${err.message}`); + } + } +} + +function purgeRootFolder(rootFolder) { + const safe = sanitizeRelative(rootFolder); + if (!safe) return false; + + const rootDir = path.join(DOWNLOAD_DIR, safe); + let removedDir = false; + + if (fs.existsSync(rootDir)) { + try { + fs.rmSync(rootDir, { recursive: true, force: true }); + removedDir = true; + console.log(`🧹 Kök klasör temizlendi: ${rootDir}`); + } catch (err) { + console.warn(`⚠️ Kök klasör silinemedi (${rootDir}): ${err.message}`); + } + } + + removeAllThumbnailsForRoot(safe); + removeMovieData(safe); + + const infoPath = path.join(DOWNLOAD_DIR, safe, INFO_FILENAME); + if (fs.existsSync(infoPath)) { + try { + fs.rmSync(infoPath, { force: true }); + } catch (err) { + console.warn(`⚠️ info.json kaldırılamadı (${infoPath}): ${err.message}`); + } + } + + return removedDir; +} + +function pruneInfoEntry(rootFolder, relativePath) { + if (!rootFolder) return; + const info = readInfoForRoot(rootFolder); + if (!info) return; + + let changed = false; + if (relativePath && info.files && info.files[relativePath]) { + delete info.files[relativePath]; + if (Object.keys(info.files).length === 0) delete info.files; + changed = true; + } + + if (relativePath && info.primaryVideoPath === relativePath) { + delete info.primaryVideoPath; + delete info.primaryMediaInfo; + changed = true; + } + + if (changed) { + const safe = sanitizeRelative(rootFolder); + if (!safe) return; + const target = path.join(DOWNLOAD_DIR, safe, INFO_FILENAME); + try { + info.updatedAt = Date.now(); + fs.writeFileSync(target, JSON.stringify(info, null, 2), "utf-8"); + } catch (err) { + console.warn(`⚠️ info.json güncellenemedi (${target}): ${err.message}`); + } + } +} + function broadcastFileUpdate(rootFolder) { if (!wss) return; const data = JSON.stringify({ @@ -420,7 +970,7 @@ app.post("/api/transfer", requireAuth, upload.single("torrent"), (req, res) => { }); // --- İndirme tamamlandığında thumbnail oluştur --- - torrent.on("done", () => { + torrent.on("done", async () => { const entry = torrents.get(torrent.infoHash); if (!entry) return; @@ -428,17 +978,55 @@ app.post("/api/transfer", requireAuth, upload.single("torrent"), (req, res) => { const rootFolder = path.basename(entry.savePath); - torrent.files.forEach((file) => { + const bestVideoIndex = pickBestVideoFile(torrent); + const bestVideo = + torrent.files[bestVideoIndex] || torrent.files[0] || null; + const displayName = bestVideo?.name || torrent.name || rootFolder; + const bestVideoPath = bestVideo?.path + ? bestVideo.path.replace(/\\/g, "/") + : null; + + const perFileMetadata = {}; + let primaryMediaInfo = null; + + for (const file of torrent.files) { const fullPath = path.join(entry.savePath, file.path); - const relPath = path.join(rootFolder, file.path); + const relPathWithRoot = path.join(rootFolder, file.path); + const normalizedRelPath = file.path.replace(/\\/g, "/"); const mimeType = mime.lookup(fullPath) || ""; + const ext = path.extname(file.name).replace(/^\./, "").toLowerCase(); if (mimeType.startsWith("video/")) { - queueVideoThumbnail(fullPath, relPath); + queueVideoThumbnail(fullPath, relPathWithRoot); } else if (mimeType.startsWith("image/")) { - queueImageThumbnail(fullPath, relPath); + queueImageThumbnail(fullPath, relPathWithRoot); } - }); + + let metaInfo = null; + if ( + mimeType.startsWith("video/") || + mimeType.startsWith("audio/") || + mimeType.startsWith("image/") + ) { + metaInfo = await extractMediaInfo(fullPath); + } + + if ( + !primaryMediaInfo && + bestVideoPath && + normalizedRelPath === bestVideoPath && + metaInfo + ) { + primaryMediaInfo = metaInfo; + } + + perFileMetadata[normalizedRelPath] = { + size: file.length, + extension: ext || null, + mimeType, + mediaInfo: metaInfo + }; + } // Eski thumbnail yapısını temizle try { @@ -451,11 +1039,23 @@ app.post("/api/transfer", requireAuth, upload.single("torrent"), (req, res) => { console.warn("⚠️ Eski thumbnail klasörü temizlenemedi:", err.message); } - upsertInfoFile(entry.savePath, { + const infoUpdate = { completedAt: Date.now(), totalBytes: torrent.downloaded, - fileCount: torrent.files.length - }); + fileCount: torrent.files.length, + files: perFileMetadata + }; + if (bestVideoPath) infoUpdate.primaryVideoPath = bestVideoPath; + + const ensuredMedia = await ensureMovieData( + rootFolder, + displayName, + bestVideoPath, + primaryMediaInfo + ); + if (ensuredMedia) infoUpdate.primaryMediaInfo = ensuredMedia; + + upsertInfoFile(entry.savePath, infoUpdate); broadcastFileUpdate(rootFolder); broadcastSnapshot(); @@ -474,6 +1074,14 @@ app.get("/thumbnails/:path(*)", requireAuth, (req, res) => { res.sendFile(fullPath); }); +app.get("/movie-data/:path(*)", requireAuth, (req, res) => { + const relPath = req.params.path || ""; + const fullPath = resolveMovieDataAbsolute(relPath); + if (!fullPath) return res.status(400).send("Geçersiz movie data yolu"); + if (!fs.existsSync(fullPath)) return res.status(404).send("Dosya bulunamadı"); + res.sendFile(fullPath); +}); + // --- Torrentleri listele --- app.get("/api/torrents", requireAuth, (req, res) => { res.json(snapshot()); @@ -508,7 +1116,7 @@ app.delete("/api/torrents/:hash", requireAuth, (req, res) => { } } if (rootFolder) { - removeThumbnailsForPath(rootFolder); + purgeRootFolder(rootFolder); broadcastFileUpdate(rootFolder); } } else { @@ -566,45 +1174,108 @@ app.delete("/api/file", requireAuth, (req, res) => { const filePath = req.query.path; if (!filePath) return res.status(400).json({ error: "path gerekli" }); - const fullPath = path.join(DOWNLOAD_DIR, filePath); - if (!fs.existsSync(fullPath)) + const safePath = sanitizeRelative(filePath); + const fullPath = path.join(DOWNLOAD_DIR, safePath); + const folderId = (safePath.split(/[\/]/)[0] || "").trim(); + const rootDir = folderId ? path.join(DOWNLOAD_DIR, folderId) : null; + + let stats = null; + try { + stats = fs.statSync(fullPath); + } catch (err) { + const message = err?.message || String(err); + console.warn(`⚠️ Silme sırasında stat alınamadı (${fullPath}): ${message}`); + } + + if (!stats || !fs.existsSync(fullPath)) { + if (folderId && (!rootDir || !fs.existsSync(rootDir))) { + purgeRootFolder(folderId); + broadcastFileUpdate(folderId); + return res.json({ ok: true, alreadyRemoved: true }); + } return res.status(404).json({ error: "Dosya bulunamadı" }); + } try { - // 1) Dosya/klasörü sil fs.rmSync(fullPath, { recursive: true, force: true }); console.log(`🗑️ Dosya/klasör silindi: ${fullPath}`); - removeThumbnailsForPath(filePath); + removeThumbnailsForPath(safePath); - // 2) İlk segment (klasör adı) => folderId (örn: "1730048432921") - const folderId = (filePath.split(/[\\/]/)[0] || "").trim(); + if (folderId) { + const relWithinRoot = safePath.split(/[\/]/).slice(1).join("/"); + const rootExists = rootDir && fs.existsSync(rootDir); - // 3) torrents Map’inde, savePath'in son klasörü folderId olan entry’yi bul - let matchedInfoHash = null; - for (const [infoHash, entry] of torrents.entries()) { - const lastDir = path.basename(entry.savePath); - if (lastDir === folderId) { - matchedInfoHash = infoHash; - break; + if (!relWithinRoot || !rootExists) { + purgeRootFolder(folderId); + } else { + const remaining = fs.readdirSync(rootDir); + const meaningful = remaining.filter((name) => { + if (!name) return false; + if (name === INFO_FILENAME) return false; + if (name.startsWith(".")) return false; + const full = path.join(rootDir, name); + try { + const stat = fs.statSync(full); + if (stat.isDirectory()) { + const subItems = fs.readdirSync(full); + return subItems.some((entry) => !entry.startsWith(".")); + } + } catch (err) { + return false; + } + return true; + }); + + if (meaningful.length === 0 || stats?.isDirectory?.()) { + purgeRootFolder(folderId); + } else { + pruneInfoEntry(folderId, relWithinRoot); + const infoAfter = readInfoForRoot(folderId); + const displayName = infoAfter?.name || folderId; + const primaryVideo = infoAfter?.primaryVideoPath || guessPrimaryVideo(folderId); + if (primaryVideo) { + const candidateMedia = + infoAfter?.files?.[primaryVideo]?.mediaInfo || + infoAfter?.primaryMediaInfo || + null; + ensureMovieData(folderId, displayName, primaryVideo, candidateMedia).catch( + (err) => + console.warn( + `⚠️ Movie metadata yenilenemedi (${folderId}): ${err?.message || err}` + ) + ); + } + } } + + broadcastFileUpdate(folderId); } - // 4) Eşleşen torrent varsa destroy + Map’ten sil + snapshot yayınla - if (matchedInfoHash) { - const entry = torrents.get(matchedInfoHash); - entry?.torrent?.destroy(() => { - torrents.delete(matchedInfoHash); - console.log(`🧹 Torrent kaydı da temizlendi: ${matchedInfoHash}`); + if (folderId) { + let matchedInfoHash = null; + for (const [infoHash, entry] of torrents.entries()) { + const lastDir = path.basename(entry.savePath); + if (lastDir === folderId) { + matchedInfoHash = infoHash; + break; + } + } + + if (matchedInfoHash) { + const entry = torrents.get(matchedInfoHash); + entry?.torrent?.destroy(() => { + torrents.delete(matchedInfoHash); + console.log(`🧹 Torrent kaydı da temizlendi: ${matchedInfoHash}`); + broadcastSnapshot(); + }); + } else { broadcastSnapshot(); - }); + } } else { - // Torrent eşleşmediyse de listeyi tazele (ör. sade dosya silinmiştir) broadcastSnapshot(); } - if (folderId) broadcastFileUpdate(folderId); - - res.json({ ok: true }); + res.json({ ok: true, filesRemoved: true }); } catch (err) { console.error("❌ Dosya silinemedi:", err.message); res.status(500).json({ error: err.message }); @@ -698,12 +1369,17 @@ app.get("/api/files", requireAuth, (req, res) => { const info = getInfo(safeRel) || {}; const rootFolder = rootFromRelPath(safeRel); - const added = - info.added ?? info.createdAt ?? null; + const added = info.added ?? info.createdAt ?? null; const completedAt = info.completedAt ?? null; const tracker = info.tracker ?? null; const torrentName = info.name ?? null; const infoHash = info.infoHash ?? null; + const relWithinRoot = safeRel.split(/[\\/]/).slice(1).join("/"); + const fileMeta = relWithinRoot + ? info.files?.[relWithinRoot] || null + : null; + const extensionForFile = fileMeta?.extension || path.extname(entry.name).replace(/^\./, "").toLowerCase() || null; + const mediaInfoForFile = fileMeta?.mediaInfo || null; result.push({ name: safeRel, @@ -716,7 +1392,11 @@ app.get("/api/files", requireAuth, (req, res) => { completedAt, tracker, torrentName, - infoHash + infoHash, + extension: extensionForFile, + mediaInfo: mediaInfoForFile, + primaryVideoPath: info.primaryVideoPath || null, + primaryMediaInfo: info.primaryMediaInfo || null }); } } @@ -733,6 +1413,136 @@ app.get("/api/files", requireAuth, (req, res) => { } }); +// --- 🎬 Film listesi --- +app.get("/api/movies", requireAuth, (req, res) => { + try { + if (!fs.existsSync(MOVIE_DATA_ROOT)) { + return res.json([]); + } + + const entries = fs + .readdirSync(MOVIE_DATA_ROOT, { withFileTypes: true }) + .filter((d) => d.isDirectory()); + + const movies = entries + .map((dirent) => { + const folder = dirent.name; + const paths = movieDataPaths(folder); + if (!fs.existsSync(paths.metadata)) return null; + try { + const metadata = JSON.parse( + fs.readFileSync(paths.metadata, "utf-8") + ); + if (!isTmdbMetadata(metadata)) { + removeMovieData(folder); + return null; + } + const encodedFolder = folder + .split(path.sep) + .map(encodeURIComponent) + .join("/"); + const posterExists = fs.existsSync(paths.poster); + const backdropExists = fs.existsSync(paths.backdrop); + const releaseDate = metadata.release_date || metadata.first_air_date; + const year = releaseDate + ? Number(releaseDate.slice(0, 4)) + : metadata.matched_year || null; + const runtimeMinutes = + metadata.runtime ?? + (Array.isArray(metadata.episode_run_time) && + metadata.episode_run_time.length + ? metadata.episode_run_time[0] + : null); + const dupe = metadata._dupe || {}; + + return { + folder, + id: metadata.id ?? folder, + title: metadata.title || metadata.matched_title || folder, + originalTitle: metadata.original_title || null, + year, + runtime: runtimeMinutes || null, + overview: metadata.overview || "", + voteAverage: metadata.vote_average || null, + voteCount: metadata.vote_count || null, + genres: Array.isArray(metadata.genres) + ? metadata.genres.map((g) => g.name) + : [], + poster: posterExists + ? `/movie-data/${encodedFolder}/poster.jpg` + : null, + backdrop: backdropExists + ? `/movie-data/${encodedFolder}/backdrop.jpg` + : null, + videoPath: dupe.videoPath || null, + mediaInfo: dupe.mediaInfo || null, + metadata + }; + } catch (err) { + console.warn( + `⚠️ metadata.json okunamadı (${paths.metadata}): ${err.message}` + ); + return null; + } + }) + .filter(Boolean); + + movies.sort((a, b) => { + const yearA = a.year || 0; + const yearB = b.year || 0; + if (yearA !== yearB) return yearB - yearA; + return a.title.localeCompare(b.title); + }); + + res.json(movies); + } catch (err) { + console.error("🎬 Movies API error:", err); + res.status(500).json({ error: err.message }); + } +}); + +app.post("/api/movies/refresh", requireAuth, async (req, res) => { + if (!TMDB_API_KEY) { + return res.status(400).json({ error: "TMDB API key tanımlı değil." }); + } + + try { + const folders = fs + .readdirSync(DOWNLOAD_DIR, { withFileTypes: true }) + .filter((d) => d.isDirectory()) + .map((d) => d.name); + + const processed = []; + for (const folder of folders) { + const info = readInfoForRoot(folder); + const displayName = info?.name || folder; + const primaryVideo = info?.primaryVideoPath || guessPrimaryVideo(folder); + const candidateMedia = + info?.files?.[primaryVideo]?.mediaInfo || info?.primaryMediaInfo || null; + const ensured = await ensureMovieData( + folder, + displayName, + primaryVideo, + candidateMedia + ); + if (primaryVideo || ensured) { + const update = {}; + if (primaryVideo) update.primaryVideoPath = primaryVideo; + if (ensured) update.primaryMediaInfo = ensured; + if (Object.keys(update).length) { + upsertInfoFile(path.join(DOWNLOAD_DIR, folder), update); + } + } + processed.push(folder); + } + + res.json({ ok: true, processed }); + } catch (err) { + console.error("🎬 Movies refresh error:", err); + res.status(500).json({ error: err.message }); + } +}); + // --- Stream endpoint (torrent içinden) --- app.get("/stream/:hash", requireAuth, (req, res) => { const entry = torrents.get(req.params.hash); @@ -772,10 +1582,6 @@ app.get("/stream/:hash", requireAuth, (req, 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`) -); // --- ✅ Client build (frontend) dosyalarını sun --- const publicDir = path.join(__dirname, "public"); @@ -787,6 +1593,10 @@ if (fs.existsSync(publicDir)) { }); } +const server = app.listen(PORT, () => + console.log(`✅ WebTorrent server ${PORT} portunda çalışıyor`) +); + wss = new WebSocketServer({ server }); wss.on("connection", (ws) => { ws.send(JSON.stringify({ type: "progress", torrents: snapshot() }));