diff --git a/client/src/App.svelte b/client/src/App.svelte index 76b482c..351992d 100644 --- a/client/src/App.svelte +++ b/client/src/App.svelte @@ -8,12 +8,29 @@ import Sharing from "./routes/Sharing.svelte"; import Trash from "./routes/Trash.svelte"; import Movies from "./routes/Movies.svelte"; + import TvShows from "./routes/TvShows.svelte"; import Login from "./routes/Login.svelte"; + import { API } from "./utils/api.js"; import { refreshMovieCount } from "./stores/movieStore.js"; + import { refreshTvShowCount } from "./stores/tvStore.js"; const token = localStorage.getItem("token"); let menuOpen = false; + let wsCounts; + let refreshTimer = null; + + const scheduleMediaRefresh = () => { + if (refreshTimer) return; + refreshTimer = setTimeout(async () => { + refreshTimer = null; + try { + await Promise.all([refreshMovieCount(), refreshTvShowCount()]); + } catch (err) { + console.warn("Medya sayacı yenileme başarısız:", err); + } + }, 400); + }; // Menü aç/kapat (hamburger butonuyla) const toggleMenu = () => { @@ -28,7 +45,48 @@ onMount(() => { if (token) { refreshMovieCount(); + refreshTvShowCount(); + const authToken = localStorage.getItem("token"); + if (authToken) { + const wsUrl = `${API.replace("http", "ws")}?token=${authToken}`; + try { + wsCounts = new WebSocket(wsUrl); + wsCounts.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.type === "fileUpdate") { + scheduleMediaRefresh(); + } else if ( + msg.type === "progress" && + Array.isArray(msg.torrents) && + msg.torrents.some((t) => Number(t.progress) >= 1) + ) { + scheduleMediaRefresh(); + } + } catch (err) { + console.warn("WS mesajı çözümlenemedi:", err); + } + }; + wsCounts.onerror = () => scheduleMediaRefresh(); + } catch (err) { + console.warn("WS bağlantısı kurulamadı:", err); + } + } } + return () => { + if (wsCounts) { + try { + wsCounts.close(); + } catch (err) { + /* no-op */ + } + wsCounts = null; + } + if (refreshTimer) { + clearTimeout(refreshTimer); + refreshTimer = null; + } + }; }); @@ -45,6 +103,7 @@ + diff --git a/client/src/components/Sidebar.svelte b/client/src/components/Sidebar.svelte index 5d74a06..f41c026 100644 --- a/client/src/components/Sidebar.svelte +++ b/client/src/components/Sidebar.svelte @@ -2,17 +2,24 @@ import { Link } from "svelte-routing"; import { createEventDispatcher, onDestroy } from "svelte"; import { movieCount } from "../stores/movieStore.js"; + import { tvShowCount } from "../stores/tvStore.js"; export let menuOpen = false; const dispatch = createEventDispatcher(); let hasMovies = false; + let hasShows = false; - const unsubscribe = movieCount.subscribe((count) => { + const unsubscribeMovie = movieCount.subscribe((count) => { hasMovies = (count ?? 0) > 0; }); + const unsubscribeTv = tvShowCount.subscribe((count) => { + hasShows = (count ?? 0) > 0; + }); + onDestroy(() => { - unsubscribe(); + unsubscribeMovie(); + unsubscribeTv(); }); // Menü öğesine tıklanınca sidebar'ı kapat @@ -51,6 +58,20 @@ {/if} + {#if hasShows} + ({ + class: isCurrent ? "item active" : "item", + })} + on:click={handleLinkClick} + > + + Tv Shows + + {/if} + 0 && selectedItems.size === files.length; tryAutoPlay(); refreshMovieCount(); + refreshTvShowCount(); } function formatSize(bytes) { if (!bytes) return "0 MB"; @@ -336,7 +338,7 @@ let isPlaying = false; selectedItems = new Set(failed); allSelected = failed.length > 0 && failed.length === files.length; - await refreshMovieCount(); + await Promise.all([refreshMovieCount(), refreshTvShowCount()]); } onMount(async () => { await loadFiles(); // önce dosyaları getir diff --git a/client/src/routes/TvShows.svelte b/client/src/routes/TvShows.svelte new file mode 100644 index 0000000..b1215ca --- /dev/null +++ b/client/src/routes/TvShows.svelte @@ -0,0 +1,1523 @@ + + +
+
+
+

Tv Shows

+ +
+ + {#if loading} +
Loading shows…
+ {:else if error} +
{error}
+ {:else if shows.length === 0} +
No TV metadata found yet.
+ {:else} +
+ {#each shows as show} +
openShow(show)}> + {#if show.poster} +
+ {show.title} +
+ {:else} +
+ +
+ {/if} +
+
{show.title}
+ {#if show.year} +
{show.year}
+ {/if} +
+
+ {/each} +
+ {/if} +
+ +{#if selectedShow} +
+
+
+ +
+
+ {#if selectedShow.poster} + {selectedShow.title} + {:else} +
+ +
+ {/if} +
+
+

{selectedShow.title}

+
+ {#if selectedShow.year} + {selectedShow.year} + {/if} + {#if selectedShow.status} + • {selectedShow.status} + {/if} + {#if selectedSeason} + + • + {selectedSeason.name || `Season ${selectedSeason.seasonNumber}`} + + {/if} +
+ {#if selectedShow.genres?.length} +
+ {selectedShow.genres.join(" • ")} +
+ {/if} +
+ {selectedShow.overview || "No synopsis found."} +
+ {#if selectedRuntime || selectedVideoInfo || selectedAudioInfo || selectedAirDate} +
+ {#if selectedRuntime} +
+ + {selectedRuntime} +
+ {/if} + {#if selectedAirDate} +
+ + {selectedAirDate} +
+ {/if} + {#if selectedVideoInfo} +
+ + {selectedVideoInfo} +
+ {/if} + {#if selectedAudioInfo} +
+ + {selectedAudioInfo} +
+ {/if} +
+ {/if} + +
+
+ + {#if selectedShow.seasons?.length} +
+ {#each selectedShow.seasons as season} + + {/each} +
+ {/if} + +
+ {#if selectedSeason?.episodes?.length} + {#each selectedSeason.episodes as episode} +
playEpisodeFromCard(episode)} + > +
+ {#if episode.still} + {`${selectedShow.title} + {:else} +
+ +
+ {/if} +
+ +
+
+
+
+ {formatEpisodeCode(episode)} · {episode.title || "Untitled"} +
+
+ {#if episodeRuntime(episode)} + + + {episodeRuntime(episode)} + + {/if} + {#if formatAirDate(episode.aired)} + + + {formatAirDate(episode.aired)} + + {/if} + {#if formatVideoInfo(episode.mediaInfo?.video)} + + + {formatVideoInfo(episode.mediaInfo.video)} + + {/if} + {#if formatAudioInfo(episode.mediaInfo?.audio)} + + + {formatAudioInfo(episode.mediaInfo.audio)} + + {/if} +
+
+ {episode.overview || "No overview available."} +
+
+
+ {/each} + {:else} +
No episodes found for this season.
+ {/if} +
+
+
+{/if} + +{#if showPlayerModal && selectedVideo} + +{/if} + + diff --git a/client/src/stores/tvStore.js b/client/src/stores/tvStore.js new file mode 100644 index 0000000..612fcab --- /dev/null +++ b/client/src/stores/tvStore.js @@ -0,0 +1,16 @@ +import { writable } from "svelte/store"; +import { apiFetch } from "../utils/api.js"; + +export const tvShowCount = writable(0); + +export async function refreshTvShowCount() { + try { + const resp = await apiFetch("/api/tvshows"); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const list = await resp.json(); + tvShowCount.set(Array.isArray(list) ? list.length : 0); + } catch (err) { + console.warn("⚠️ TV show count güncellenemedi:", err?.message || err); + tvShowCount.set(0); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 1fa012c..20f4d44 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,5 +15,5 @@ services: USERNAME: ${USERNAME} PASSWORD: ${PASSWORD} TMDB_API_KEY: ${TMDB_API_KEY} - TTVDB_API_KEY: ${TTVDB_API_KEY} + TVDB_API_KEY: ${TVDB_API_KEY} VIDEO_THUMBNAIL_TIME: ${VIDEO_THUMBNAIL_TIME} diff --git a/server/server.js b/server/server.js index f6b438e..f867a1c 100644 --- a/server/server.js +++ b/server/server.js @@ -31,12 +31,14 @@ 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"); +const TV_DATA_ROOT = path.join(CACHE_DIR, "tv_data"); for (const dir of [ THUMBNAIL_DIR, VIDEO_THUMB_ROOT, IMAGE_THUMB_ROOT, - MOVIE_DATA_ROOT + MOVIE_DATA_ROOT, + TV_DATA_ROOT ]) { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } @@ -49,6 +51,12 @@ 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 TVDB_API_KEY = + process.env.TTVDB_API_KEY || process.env.TVDB_API_KEY || null; +const TVDB_USER_TOKEN = process.env.TVDB_USER_TOKEN || null; +const TVDB_BASE_URL = "https://api4.thetvdb.com/v4"; +const TVDB_IMAGE_BASE = + process.env.TVDB_IMAGE_BASE || "https://artworks.thetvdb.com"; const FFPROBE_PATH = process.env.FFPROBE_PATH || "ffprobe"; const FFPROBE_MAX_BUFFER = Number(process.env.FFPROBE_MAX_BUFFER) > 0 @@ -75,6 +83,141 @@ function ensureDirForFile(filePath) { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); } +function tvdbImageUrl(pathSegment) { + if (!pathSegment) return null; + if (pathSegment.startsWith("http")) return pathSegment; + if (pathSegment.startsWith("/")) + return `${TVDB_IMAGE_BASE}${pathSegment}`; + return `${TVDB_IMAGE_BASE}/${pathSegment}`; +} + +async function downloadTvdbImage(imagePath, targetPath) { + const url = tvdbImageUrl(imagePath); + if (!url) return false; + try { + const resp = await fetch(url); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + ensureDirForFile(targetPath); + const arr = await resp.arrayBuffer(); + fs.writeFileSync(targetPath, Buffer.from(arr)); + return true; + } catch (err) { + console.warn(`⚠️ TVDB görsel indirilemedi (${url}): ${err.message}`); + return false; + } +} + +function titleCase(value) { + if (!value) return ""; + return value + .toLowerCase() + .split(/\s+/) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function normalizeTvdbId(value) { + if (value === null || value === undefined) return null; + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string") { + const match = value.match(/\d+/); + if (match) { + const num = Number(match[0]); + if (Number.isFinite(num)) return num; + } + } + return null; +} + +function normalizeTvdbEpisode(raw) { + if (!raw || typeof raw !== "object") return null; + const seasonNumber = toFiniteNumber( + raw.seasonNumber ?? + raw.season ?? + raw.airedSeason ?? + raw.season_number ?? + raw.seasonNum + ); + const episodeNumber = toFiniteNumber( + raw.number ?? + raw.episodeNumber ?? + raw.airedEpisodeNumber ?? + raw.episode_number ?? + raw.episodeNum + ); + return { + id: normalizeTvdbId( + raw.id ?? raw.tvdb_id ?? raw.episodeId ?? raw.episode_id + ), + seasonId: normalizeTvdbId(raw.seasonId ?? raw.season_id ?? raw.parentId), + seriesId: normalizeTvdbId(raw.seriesId ?? raw.series_id), + seasonNumber: Number.isFinite(seasonNumber) ? seasonNumber : null, + episodeNumber: Number.isFinite(episodeNumber) ? episodeNumber : null, + name: + raw.name ?? + raw.episodeName ?? + raw.title ?? + raw.episodeTitle ?? + null, + overview: + raw.overview ?? raw.description ?? raw.synopsis ?? raw.plot ?? "", + image: + raw.image ?? + raw.filename ?? + raw.fileName ?? + raw.thumb ?? + raw.thumbnail ?? + raw.imageUrl ?? + raw.image_url ?? + null, + aired: + raw.aired ?? + raw.firstAired ?? + raw.airDate ?? + raw.air_date ?? + raw.released ?? + null, + runtime: toFiniteNumber( + raw.runtime ?? + raw.length ?? + raw.duration ?? + raw.runTime ?? + raw.runtimeMinutes ?? + raw.runtime_minutes + ), + slug: raw.slug ?? null, + translations: raw.translations || null, + raw + }; +} + +function normalizeTvdbSeason(raw) { + if (!raw || typeof raw !== "object") return null; + const seasonNumber = toFiniteNumber( + raw.number ?? + raw.seasonNumber ?? + raw.season ?? + raw.airedSeason ?? + raw.seasonNum + ); + return { + id: normalizeTvdbId(raw.id ?? raw.tvdb_id ?? raw.seasonId ?? raw.season_id), + number: Number.isFinite(seasonNumber) ? seasonNumber : null, + name: raw.name ?? raw.title ?? raw.translation ?? null, + overview: raw.overview ?? raw.description ?? "", + image: + raw.image ?? + raw.poster ?? + raw.filename ?? + raw.fileName ?? + raw.thumb ?? + raw.thumbnail ?? + null, + translations: raw.translations || null, + raw + }; +} + function infoFilePath(savePath) { return path.join(savePath, INFO_FILENAME); } @@ -413,6 +556,18 @@ function resolveMovieDataAbsolute(relPath) { return resolved; } +function resolveTvDataAbsolute(relPath) { + const normalized = sanitizeRelative(relPath); + const resolved = path.resolve(TV_DATA_ROOT, normalized); + if ( + resolved !== TV_DATA_ROOT && + !resolved.startsWith(TV_DATA_ROOT + path.sep) + ) { + return null; + } + return resolved; +} + function removeAllThumbnailsForRoot(rootFolder) { const safe = sanitizeRelative(rootFolder); if (!safe) return; @@ -566,6 +721,33 @@ function parseTitleAndYear(rawName) { return { title: title || withoutExt.trim(), year }; } +function parseSeriesInfo(rawName) { + if (!rawName) return null; + const withoutExt = rawName.replace(/\.[^/.]+$/, ""); + const match = withoutExt.match(/(.+?)[\s._-]*S(\d{1,2})[\s._-]*E(\d{1,2})/i); + if (!match) return null; + + const rawTitle = match[1] + .replace(/[._]+/g, " ") + .replace(/\s+-\s+/g, " - ") + .replace(/[-_]+/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (!rawTitle) return null; + + const season = Number(match[2]); + const episode = Number(match[3]); + if (!Number.isFinite(season) || !Number.isFinite(episode)) return null; + + return { + title: titleCase(rawTitle), + searchTitle: rawTitle, + season, + episode, + key: `S${String(season).padStart(2, "0")}E${String(episode).padStart(2, "0")}` + }; +} + async function tmdbFetch(endpoint, params = {}) { if (!TMDB_API_KEY) return null; const url = new URL(`${TMDB_BASE_URL}${endpoint}`); @@ -654,6 +836,120 @@ async function downloadImage(url, targetPath) { } } +let tvdbAuthState = { token: null, expires: 0 }; +const TVDB_TOKEN_TTL = 1000 * 60 * 60 * 20; // 20 saat + +async function getTvdbToken(force = false) { + if (!TVDB_API_KEY) return null; + + if (!TVDB_USER_TOKEN) { + // V4 kişisel token senaryosu: doğrudan API key'i Bearer olarak kullan + tvdbAuthState = { + token: TVDB_API_KEY, + expires: Date.now() + TVDB_TOKEN_TTL + }; + console.log("📺 TVDB token (kişisel anahtar) kullanılıyor."); + return tvdbAuthState.token; + } + + const now = Date.now(); + if ( + !force && + tvdbAuthState.token && + now < tvdbAuthState.expires - 60 * 1000 + ) { + return tvdbAuthState.token; + } + try { + const resp = await fetch(`${TVDB_BASE_URL}/login`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ + apikey: TVDB_API_KEY, + userkey: TVDB_USER_TOKEN + }) + }); + if (!resp.ok) { + console.warn(`⚠️ TVDB login başarısız: ${resp.status}`); + return null; + } + const json = await resp.json(); + const token = json?.data?.token; + if (!token) { + console.warn("⚠️ TVDB login yanıtında token bulunamadı"); + return null; + } + console.log("📺 TVDB token alındı (login)."); + tvdbAuthState = { + token, + expires: Date.now() + TVDB_TOKEN_TTL + }; + return token; + } catch (err) { + console.warn(`⚠️ TVDB login hatası: ${err.message}`); + return null; + } +} + +async function tvdbFetch(pathname, options = {}, retry = true) { + if (!TVDB_API_KEY) return null; + const token = await getTvdbToken(); + if (!token) return null; + + const url = pathname.startsWith("http") + ? pathname + : `${TVDB_BASE_URL}${pathname}`; + + const headers = { + Accept: "application/json", + ...(options.headers || {}), + Authorization: `Bearer ${token}` + }; + + let body = options.body; + if (body && typeof body === "object" && !(body instanceof Buffer)) { + body = JSON.stringify(body); + headers["Content-Type"] = "application/json"; + } + + try { + const resp = await fetch(url, { + ...options, + headers, + body + }); + const rawText = await resp.text(); + let json = null; + if (rawText) { + try { + json = JSON.parse(rawText); + } catch (err) { + console.warn( + `⚠️ TVDB yanıtı JSON parse edilemedi (${url}): ${err.message}` + ); + } + } + console.log("📺 TVDB fetch:", { + url: String(url), + status: resp.status, + hasData: Boolean(json?.data), + message: json?.status?.message ?? null + }); + if (resp.status === 401 && retry) { + await getTvdbToken(true); + return tvdbFetch(pathname, options, false); + } + if (!resp.ok) { + console.warn(`⚠️ TVDB isteği başarısız (${url}): ${resp.status}`); + return null; + } + return json; + } catch (err) { + console.warn(`⚠️ TVDB isteği hatası (${url}): ${err.message}`); + return null; + } +} + function guessPrimaryVideo(rootFolder) { const safe = sanitizeRelative(rootFolder); if (!safe) return null; @@ -721,6 +1017,18 @@ async function ensureMovieData( if (!TMDB_API_KEY) return precomputedMediaInfo || null; console.log("🎬 ensureMovieData çağrıldı:", { rootFolder, displayName }); + const isSeriesPattern = + /S\d{1,2}E\d{1,2}/i.test(displayName || "") || + (bestVideoPath && /S\d{1,2}E\d{1,2}/i.test(bestVideoPath)); + if (isSeriesPattern) { + console.log( + "🎬 TMDB atlandı (TV bölümü tespit edildi, movie_data oluşturulmadı):", + { rootFolder, displayName } + ); + removeMovieData(rootFolder); + return precomputedMediaInfo || null; + } + const paths = movieDataPaths(rootFolder); const normalizedRoot = sanitizeRelative(rootFolder); const normalizedVideoPath = bestVideoPath @@ -833,6 +1141,747 @@ function removeMovieData(rootFolder) { } } +const tvdbSeriesCache = new Map(); +const tvdbEpisodeCache = new Map(); +const tvdbEpisodeDetailCache = new Map(); + +async function searchTvdbSeries(title) { + if (!title) return null; + const key = title.toLowerCase(); + if (tvdbSeriesCache.has(key)) return tvdbSeriesCache.get(key); + console.log("📺 TVDB seri araması yapılıyor:", title); + const params = new URLSearchParams({ type: "series", query: title }); + const resp = await tvdbFetch(`/search?${params.toString()}`); + const series = resp?.data?.[0] || null; + tvdbSeriesCache.set(key, series); + return series; +} + +async function fetchTvdbSeriesExtended(seriesId) { + if (!seriesId) return null; + const cacheKey = `series-${seriesId}`; + if (tvdbSeriesCache.has(cacheKey)) return tvdbSeriesCache.get(cacheKey); + console.log("📺 TVDB extended isteniyor:", seriesId); + const resp = await tvdbFetch( + `/series/${seriesId}/extended?meta=episodes,artworks,translations&short=false` + ); + const data = resp?.data || null; + tvdbSeriesCache.set(cacheKey, data); + return data; +} + +async function fetchTvdbEpisodesForSeason(seriesId, seasonNumber) { + if (!seriesId || !Number.isFinite(seasonNumber)) return new Map(); + const cacheKey = `${seriesId}-S${seasonNumber}`; + if (tvdbEpisodeCache.has(cacheKey)) return tvdbEpisodeCache.get(cacheKey); + + console.log("📺 TVDB sezon bölümleri çekiliyor:", { + seriesId, + seasonNumber + }); + + let page = 0; + const seasonMap = new Map(); + while (page < 50) { + const resp = await tvdbFetch( + `/series/${seriesId}/episodes/default?page=${page}&lang=eng` + ); + const payload = resp?.data; + const items = Array.isArray(payload) + ? payload + : Array.isArray(payload?.episodes) + ? payload.episodes + : []; + if (!Array.isArray(items) || !items.length) break; + for (const item of items) { + const normalized = normalizeTvdbEpisode(item); + if (!normalized) continue; + const season = normalized.seasonNumber; + const episode = normalized.episodeNumber; + if (!Number.isFinite(season) || !Number.isFinite(episode)) continue; + const key = `${seriesId}-S${season}`; + let seasonEntry = tvdbEpisodeCache.get(key); + if (!seasonEntry) { + seasonEntry = new Map(); + tvdbEpisodeCache.set(key, seasonEntry); + } + if (!seasonEntry.has(episode)) seasonEntry.set(episode, normalized); + if (!seasonMap.has(episode) && season === seasonNumber) { + seasonMap.set(episode, normalized); + } + } + const links = resp?.links || payload?.links || {}; + const nextRaw = + links?.next !== undefined ? links.next : links?.nextPage ?? null; + const nextPage = toFiniteNumber(nextRaw); + if (!Number.isFinite(nextPage) || nextPage <= page) break; + page = nextPage; + } + tvdbEpisodeCache.set(cacheKey, seasonMap); + return seasonMap; +} + +async function fetchTvdbEpisode(seriesId, season, episode) { + const cacheKey = `${seriesId}-S${season}`; + const cache = tvdbEpisodeCache.get(cacheKey); + if (cache?.has(episode)) return cache.get(episode); + console.log("📺 TVDB tekil bölüm çekiliyor:", { + seriesId, + season, + episode + }); + const seasonEpisodes = await fetchTvdbEpisodesForSeason(seriesId, season); + return seasonEpisodes.get(episode) || null; +} + +async function fetchTvdbEpisodeExtended(episodeId) { + if (!episodeId) return null; + const cacheKey = `episode-${episodeId}-extended`; + if (tvdbEpisodeDetailCache.has(cacheKey)) + return tvdbEpisodeDetailCache.get(cacheKey); + const resp = await tvdbFetch( + `/episodes/${episodeId}/extended?meta=artworks,translations&short=false` + ); + const payload = resp?.data || null; + if (!payload) { + tvdbEpisodeDetailCache.set(cacheKey, null); + return null; + } + const base = + normalizeTvdbEpisode(payload.episode || payload) || + normalizeTvdbEpisode(payload); + if (!base) { + tvdbEpisodeDetailCache.set(cacheKey, null); + return null; + } + + const artworks = Array.isArray(payload.artworks) + ? payload.artworks + : Array.isArray(payload.episode?.artworks) + ? payload.episode.artworks + : []; + if (!base.image) { + const stillArtwork = artworks.find((a) => { + const type = String( + a?.type || a?.artworkType || a?.name || "" + ).toLowerCase(); + return ( + type.includes("still") || + type.includes("screencap") || + type.includes("episode") || + type.includes("thumb") + ); + }); + if (stillArtwork?.image) base.image = stillArtwork.image; + } + + const translations = + payload.translations || + payload.episode?.translations || + {}; + const overviewTranslations = + translations.overviewTranslations || + translations.overviews || + []; + const nameTranslations = + translations.nameTranslations || translations.names || []; + + const pickTranslation = (list, field) => { + if (!Array.isArray(list)) return null; + const preferred = ["tr", "turkish", "tr-tr", "tr_tur"]; + const fallback = ["en", "english", "en-us", "eng"]; + const pickByLang = (langs) => + langs + .map((lng) => lng.toLowerCase()) + .map((lng) => + list.find((item) => { + const code = String( + item?.language || + item?.iso6391 || + item?.iso_639_1 || + item?.locale || + item?.languageCode || + "" + ).toLowerCase(); + return code === lng; + }) + ) + .find(Boolean); + const preferredMatch = pickByLang(preferred) || pickByLang(fallback); + if (!preferredMatch) return null; + return ( + preferredMatch[field] ?? + preferredMatch.value ?? + preferredMatch.translation?.[field] ?? + null + ); + }; + + if (!base.overview) { + const localizedOverview = pickTranslation(overviewTranslations, "overview"); + if (localizedOverview) base.overview = localizedOverview; + } + + if (!base.name) { + const localizedName = pickTranslation(nameTranslations, "name"); + if (localizedName) base.name = localizedName; + } + + tvdbEpisodeDetailCache.set(cacheKey, base); + return base; +} + +function buildTvShowDir(rootFolder) { + const dir = tvSeriesDir(rootFolder); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +function ensureSeasonContainer(seriesData, seasonNumber) { + if (!seriesData.seasons) seriesData.seasons = {}; + const key = String(seasonNumber); + if (!seriesData.seasons[key]) { + seriesData.seasons[key] = { + seasonNumber, + name: `Season ${seasonNumber}`, + episodes: {} + }; + } + const container = seriesData.seasons[key]; + if (!container.episodes) container.episodes = {}; + if (container.seasonNumber === undefined) { + container.seasonNumber = seasonNumber; + } + if (!container.name) { + container.name = `Season ${seasonNumber}`; + } + if (!("poster" in container)) container.poster = null; + if (!("overview" in container)) container.overview = ""; + if (!("slug" in container)) container.slug = null; + if (!("tvdbId" in container)) container.tvdbId = null; + if (!("episodeCount" in container)) container.episodeCount = null; + return container; +} + +function encodeTvDataPath(rootFolder, relativePath) { + const encoded = sanitizeRelative(rootFolder) + .split(path.sep) + .map(encodeURIComponent) + .join("/"); + return relativePath + ? `/tv-data/${encoded}/${relativePath.split(path.sep).map(encodeURIComponent).join("/")}` + : `/tv-data/${encoded}`; +} + +async function ensureSeriesData( + rootFolder, + relativeFilePath, + seriesInfo, + mediaInfo +) { + if (!TVDB_API_KEY || !seriesInfo) { + console.log("📺 TVDB atlandı (eksik anahtar ya da seriesInfo yok):", { + rootFolder, + relativeFilePath + }); + return null; + } + + console.log("📺 ensureSeriesData başladı:", { + rootFolder, + relativeFilePath, + seriesInfo + }); + + const showDir = buildTvShowDir(rootFolder); + const seriesMetaPath = path.join(showDir, "series.json"); + let seriesData = {}; + if (fs.existsSync(seriesMetaPath)) { + try { + seriesData = JSON.parse(fs.readFileSync(seriesMetaPath, "utf-8")) || {}; + } catch (err) { + console.warn(`⚠️ series.json okunamadı (${seriesMetaPath}): ${err.message}`); + } + } + + let seriesId = seriesData.id ?? seriesData.tvdbId ?? null; + if (!seriesId) { + const searchResult = await searchTvdbSeries(seriesInfo.searchTitle); + console.log("📺 TVDB arama sonucu:", { + search: seriesInfo.searchTitle, + resultId: searchResult?.id ?? searchResult?.tvdb_id ?? null + }); + const normalizedId = normalizeTvdbId( + searchResult?.tvdb_id ?? searchResult?.id ?? searchResult?.seriesId + ); + if (!normalizedId) { + console.warn("⚠️ TVDB seri kimliği çözülmedi:", searchResult); + return null; + } + seriesId = normalizedId; + seriesData.id = seriesId; + seriesData.tvdbId = seriesId; + } + + const extended = await fetchTvdbSeriesExtended(seriesId); + const container = extended || {}; + console.log("📺 TVDB extended yükleme:", { + seriesId, + keys: Object.keys(container || {}) + }); + const info = + (container.series && typeof container.series === "object" + ? container.series + : container) || {}; + const translations = + container.translations || + info.translations || + {}; + const nameTranslations = + translations.nameTranslations || + translations.names || + []; + const overviewTranslations = + translations.overviewTranslations || + translations.overviews || + []; + const localizedName = + nameTranslations.find((t) => + ["tr", "tur", "turkish"].includes(String(t?.language || t?.iso6391).toLowerCase()) + )?.value || + nameTranslations.find((t) => + ["en", "eng", "english"].includes(String(t?.language || t?.iso6391).toLowerCase()) + )?.value || + null; + const localizedOverview = + overviewTranslations.find((t) => + ["tr", "tur", "turkish"].includes(String(t?.language || t?.iso6391).toLowerCase()) + )?.overview || + overviewTranslations.find((t) => + ["en", "eng", "english"].includes(String(t?.language || t?.iso6391).toLowerCase()) + )?.overview || + null; + + seriesData.name = + seriesData.name || + info.name || + info.seriesName || + localizedName || + seriesInfo.title; + seriesData.slug = seriesData.slug || info.slug || info.slugged || null; + seriesData.overview = + seriesData.overview || info.overview || localizedOverview || ""; + const firstAired = + info.firstAired || + info.firstAirDate || + info.first_air_date || + info.premiere || + null; + if (!seriesData.firstAired) seriesData.firstAired = firstAired || null; + if (!seriesData.year && firstAired) { + const yearMatch = String(firstAired).match(/(\d{4})/); + if (yearMatch) seriesData.year = Number(yearMatch[1]); + } + if (!seriesData.year) { + for (const candidate of [info.year, info.startYear, info.start_year]) { + const numeric = Number(candidate); + if (Number.isFinite(numeric) && numeric > 0) { + seriesData.year = numeric; + break; + } + } + } + if ( + !seriesData.genres || + !Array.isArray(seriesData.genres) || + !seriesData.genres.length + ) { + seriesData.genres = Array.isArray(info.genres) + ? info.genres + .map((g) => + typeof g === "string" + ? g + : g?.name || g?.genre || g?.slug || null + ) + .filter(Boolean) + : []; + } + if (!seriesData.status) { + const status = + info.status?.name || + info.status || + container.status || + null; + seriesData.status = + typeof status === "string" ? status : null; + } + seriesData._dupe = { + ...(seriesData._dupe || {}), + folder: rootFolder + }; + seriesData.updatedAt = Date.now(); + + const artworksRaw = Array.isArray(container.artworks) + ? container.artworks + : Array.isArray(info.artworks) + ? info.artworks + : []; + + const posterArtwork = + artworksRaw.find((a) => { + const type = String( + a?.type || + a?.artworkType || + a?.type2 || + a?.name || + a?.artwork + ).toLowerCase(); + return type.includes("poster") || type === "series" || type === "2"; + }) || artworksRaw[0]; + + const backdropArtwork = artworksRaw.find((a) => { + const type = String( + a?.type || + a?.artworkType || + a?.type2 || + a?.name || + a?.artwork + ).toLowerCase(); + return ( + type.includes("fanart") || + type.includes("background") || + type === "1" + ); + }); + + const posterImage = + posterArtwork?.image || + posterArtwork?.file || + posterArtwork?.fileName || + posterArtwork?.thumbnail || + posterArtwork?.url || + null; + const backdropImage = + backdropArtwork?.image || + backdropArtwork?.file || + backdropArtwork?.fileName || + backdropArtwork?.thumbnail || + backdropArtwork?.url || + null; + + const posterPath = path.join(showDir, "poster.jpg"); + const backdropPath = path.join(showDir, "backdrop.jpg"); + + if (posterImage && !fs.existsSync(posterPath)) { + await downloadTvdbImage(posterImage, posterPath); + } + if (backdropImage && !fs.existsSync(backdropPath)) { + await downloadTvdbImage(backdropImage, backdropPath); + } + + const seasonPaths = seasonAssetPaths(rootFolder, seriesInfo.season); + const seasonsRaw = Array.isArray(container.seasons) + ? container.seasons + : Array.isArray(container.series?.seasons) + ? container.series.seasons + : []; + const normalizedSeasons = seasonsRaw + .map(normalizeTvdbSeason) + .filter(Boolean); + const seasonMeta = normalizedSeasons.find( + (season) => season.number === seriesInfo.season + ); + + const seasonKey = String(seriesInfo.season); + const seasonContainer = ensureSeasonContainer(seriesData, seriesInfo.season); + if (seasonMeta) { + if (seasonMeta.name) seasonContainer.name = seasonMeta.name; + if (seasonMeta.overview) seasonContainer.overview = seasonMeta.overview; + if (seasonMeta.id && !seasonContainer.tvdbId) { + seasonContainer.tvdbId = seasonMeta.id; + } + if (seasonMeta.slug && !seasonContainer.slug) { + seasonContainer.slug = seasonMeta.slug; + } + if ( + seasonMeta.raw?.episodeCount !== undefined && + seasonMeta.raw?.episodeCount !== null + ) { + const count = Number(seasonMeta.raw.episodeCount); + if (Number.isFinite(count)) seasonContainer.episodeCount = count; + } + const seasonImage = + seasonMeta.image || + seasonMeta.raw?.image || + seasonMeta.raw?.poster || + seasonMeta.raw?.filename || + seasonMeta.raw?.fileName || + null; + if (seasonImage && !fs.existsSync(seasonPaths.poster)) { + await downloadTvdbImage(seasonImage, seasonPaths.poster); + } + } + if (fs.existsSync(seasonPaths.poster)) { + const relPoster = path.relative(showDir, seasonPaths.poster); + if (!relPoster.startsWith("..")) { + seasonContainer.poster = encodeTvDataPath(rootFolder, relPoster); + } + } + if (!seasonContainer.overview) seasonContainer.overview = ""; + + const episodeData = await fetchTvdbEpisode( + seriesData.id, + seriesInfo.season, + seriesInfo.episode + ); + + let detailedEpisode = episodeData; + if ( + detailedEpisode?.id && + (!detailedEpisode.overview || + !detailedEpisode.image || + !detailedEpisode.name) + ) { + const extendedEpisode = await fetchTvdbEpisodeExtended( + detailedEpisode.id + ); + if (extendedEpisode) { + detailedEpisode = { + ...detailedEpisode, + ...extendedEpisode, + image: extendedEpisode.image || detailedEpisode.image, + overview: extendedEpisode.overview || detailedEpisode.overview, + name: extendedEpisode.name || detailedEpisode.name, + aired: extendedEpisode.aired || detailedEpisode.aired, + runtime: extendedEpisode.runtime || detailedEpisode.runtime + }; + } + } + + const episodeDetails = detailedEpisode || episodeData || {}; + if ( + episodeDetails && + episodeData && + episodeDetails !== episodeData + ) { + const seasonCacheKey = `${seriesData.id}-S${seriesInfo.season}`; + const seasonCache = tvdbEpisodeCache.get(seasonCacheKey); + if (seasonCache) { + seasonCache.set(seriesInfo.episode, episodeDetails); + } + } + + const episodeKey = String(seriesInfo.episode); + const stillDir = path.join( + showDir, + "episodes", + `season-${String(seriesInfo.season).padStart(2, "0")}` + ); + const stillFileName = `episode-${String(seriesInfo.episode).padStart(2, "0")}.jpg`; + const stillPath = path.join(stillDir, stillFileName); + + const episodeImage = + episodeDetails.image || + episodeDetails.filename || + episodeDetails.thumb || + episodeDetails.thumbnail || + episodeDetails.imageUrl || + (episodeDetails.raw && ( + episodeDetails.raw.image || + episodeDetails.raw.filename || + episodeDetails.raw.thumb || + episodeDetails.raw.thumbnail + )) || + null; + + if (episodeImage && !fs.existsSync(stillPath)) { + await downloadTvdbImage(episodeImage, stillPath); + } + + const episodeCode = + seriesInfo.key || + `S${String(seriesInfo.season).padStart(2, "0")}E${String( + seriesInfo.episode + ).padStart(2, "0")}`; + const episodeTitle = + episodeDetails.name || + episodeDetails.raw?.name || + episodeDetails.raw?.episodeName || + `Episode ${seriesInfo.episode}`; + const episodeOverview = + episodeDetails.overview || + episodeDetails.raw?.overview || + ""; + const episodeRuntime = + toFiniteNumber(episodeDetails.runtime) || + toFiniteNumber(episodeDetails.raw?.runtime) || + toFiniteNumber(episodeDetails.raw?.length) || + null; + const episodeAirDate = + episodeDetails.aired || + episodeDetails.raw?.aired || + episodeDetails.raw?.firstAired || + null; + const episodeSlug = + episodeDetails.slug || episodeDetails.raw?.slug || null; + const episodeTvdbId = normalizeTvdbId( + episodeDetails.id ?? episodeDetails.raw?.id + ); + const episodeSeasonId = normalizeTvdbId( + episodeDetails.seasonId ?? episodeDetails.raw?.seasonId + ); + + seasonContainer.episodes[episodeKey] = { + episodeNumber: seriesInfo.episode, + seasonNumber: seriesInfo.season, + code: episodeCode, + title: episodeTitle, + overview: episodeOverview, + runtime: episodeRuntime, + aired: episodeAirDate, + still: fs.existsSync(stillPath) + ? encodeTvDataPath(rootFolder, path.relative(showDir, stillPath)) + : null, + file: relativeFilePath, + mediaInfo: mediaInfo || null, + tvdbEpisodeId: episodeTvdbId, + slug: episodeSlug, + seasonId: + seasonContainer.tvdbId || + episodeSeasonId || + null + }; + seasonContainer.episodeCount = Object.keys(seasonContainer.episodes).length; + seasonContainer.updatedAt = Date.now(); + + ensureDirForFile(seriesMetaPath); + fs.writeFileSync(seriesMetaPath, JSON.stringify(seriesData, null, 2), "utf-8"); + + return { + show: { + id: seriesData.id || null, + title: seriesData.name || seriesInfo.title, + year: seriesData.year || null, + overview: seriesData.overview || "", + poster: fs.existsSync(path.join(showDir, "poster.jpg")) + ? encodeTvDataPath(rootFolder, "poster.jpg") + : null, + backdrop: fs.existsSync(path.join(showDir, "backdrop.jpg")) + ? encodeTvDataPath(rootFolder, "backdrop.jpg") + : null + }, + season: { + seasonNumber: seasonContainer.seasonNumber, + name: seasonContainer.name || `Season ${seriesInfo.season}`, + overview: seasonContainer.overview || "", + poster: seasonContainer.poster || null, + tvdbSeasonId: seasonContainer.tvdbId || null, + slug: seasonContainer.slug || null + }, + episode: seasonContainer.episodes[episodeKey] + }; +} + +function tvSeriesDir(rootFolder) { + return path.join(TV_DATA_ROOT, sanitizeRelative(rootFolder)); +} + +function tvSeriesPaths(rootFolder) { + const dir = tvSeriesDir(rootFolder); + return { + dir, + metadata: path.join(dir, "series.json"), + poster: path.join(dir, "poster.jpg"), + backdrop: path.join(dir, "backdrop.jpg"), + episodesDir: path.join(dir, "episodes"), + seasonsDir: path.join(dir, "seasons") + }; +} + +function seasonAssetPaths(rootFolder, seasonNumber) { + const baseDir = tvSeriesDir(rootFolder); + const padded = String(seasonNumber).padStart(2, "0"); + const dir = path.join(baseDir, "seasons", `season-${padded}`); + return { + dir, + poster: path.join(dir, "poster.jpg") + }; +} + +function removeSeriesData(rootFolder) { + const dir = tvSeriesDir(rootFolder); + if (fs.existsSync(dir)) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + console.log(`🧹 TV metadata silindi: ${dir}`); + } catch (err) { + console.warn(`⚠️ TV metadata temizlenemedi (${dir}): ${err.message}`); + } + } +} + +function removeSeriesEpisode(rootFolder, relativeFilePath) { + if (!rootFolder || !relativeFilePath) return; + + const safeRoot = sanitizeRelative(rootFolder); + if (!safeRoot) return; + + const seriesMetaPath = tvSeriesPaths(safeRoot).metadata; + if (!fs.existsSync(seriesMetaPath)) return; + + let seriesData; + try { + seriesData = JSON.parse(fs.readFileSync(seriesMetaPath, "utf-8")); + } catch (err) { + console.warn( + `⚠️ series.json okunamadı (${seriesMetaPath}): ${err.message}` + ); + return; + } + + const seasons = seriesData?.seasons || {}; + let removed = false; + + for (const [seasonKey, season] of Object.entries(seasons)) { + if (!season?.episodes) continue; + + let seasonChanged = false; + for (const episodeKey of Object.keys(season.episodes)) { + const episode = season.episodes[episodeKey]; + if (episode?.file === relativeFilePath) { + delete season.episodes[episodeKey]; + seasonChanged = true; + removed = true; + } + } + + if ( + seasonChanged && + (!season.episodes || Object.keys(season.episodes).length === 0) + ) { + delete seasons[seasonKey]; + } + } + + if (!removed) return; + + if (!Object.keys(seasons).length) { + removeSeriesData(safeRoot); + return; + } + + seriesData.seasons = seasons; + seriesData.updatedAt = Date.now(); + + try { + fs.writeFileSync(seriesMetaPath, JSON.stringify(seriesData, null, 2), "utf-8"); + } catch (err) { + console.warn( + `⚠️ series.json güncellenemedi (${seriesMetaPath}): ${err.message}` + ); + } +} + function purgeRootFolder(rootFolder) { const safe = sanitizeRelative(rootFolder); if (!safe) return false; @@ -852,6 +1901,7 @@ function purgeRootFolder(rootFolder) { removeAllThumbnailsForRoot(safe); removeMovieData(safe); + removeSeriesData(safe); const infoPath = path.join(DOWNLOAD_DIR, safe, INFO_FILENAME); if (fs.existsSync(infoPath)) { @@ -877,6 +1927,18 @@ function pruneInfoEntry(rootFolder, relativePath) { changed = true; } + if ( + relativePath && + info.seriesEpisodes && + info.seriesEpisodes[relativePath] + ) { + delete info.seriesEpisodes[relativePath]; + if (Object.keys(info.seriesEpisodes).length === 0) { + delete info.seriesEpisodes; + } + changed = true; + } + if (relativePath && info.primaryVideoPath === relativePath) { delete info.primaryVideoPath; delete info.primaryMediaInfo; @@ -1050,6 +2112,7 @@ app.post("/api/transfer", requireAuth, upload.single("torrent"), (req, res) => { : null; const perFileMetadata = {}; + const seriesEpisodes = {}; let primaryMediaInfo = null; for (const file of torrent.files) { @@ -1089,6 +2152,44 @@ app.post("/api/transfer", requireAuth, upload.single("torrent"), (req, res) => { mimeType, mediaInfo: metaInfo }; + + const seriesInfo = parseSeriesInfo(file.name); + if (seriesInfo) { + try { + const ensured = await ensureSeriesData( + rootFolder, + normalizedRelPath, + seriesInfo, + metaInfo + ); + if (ensured?.show && ensured?.episode) { + seriesEpisodes[normalizedRelPath] = { + season: seriesInfo.season, + episode: seriesInfo.episode, + key: seriesInfo.key, + title: ensured.episode.title || seriesInfo.title, + showId: ensured.show.id || null, + showTitle: ensured.show.title || seriesInfo.title, + seasonName: + ensured.season?.name || `Season ${seriesInfo.season}`, + seasonId: ensured.season?.tvdbSeasonId || null, + seasonPoster: ensured.season?.poster || null, + overview: ensured.episode.overview || "", + aired: ensured.episode.aired || null, + runtime: ensured.episode.runtime || null, + still: ensured.episode.still || null, + episodeId: ensured.episode.tvdbEpisodeId || null, + slug: ensured.episode.slug || null + }; + } + } catch (err) { + console.warn( + `⚠️ TV metadata oluşturulamadı (${rootFolder} - ${file.name}): ${ + err?.message || err + }` + ); + } + } } // Eski thumbnail yapısını temizle @@ -1109,6 +2210,9 @@ app.post("/api/transfer", requireAuth, upload.single("torrent"), (req, res) => { files: perFileMetadata }; if (bestVideoPath) infoUpdate.primaryVideoPath = bestVideoPath; + if (Object.keys(seriesEpisodes).length) { + infoUpdate.seriesEpisodes = seriesEpisodes; + } const ensuredMedia = await ensureMovieData( rootFolder, @@ -1145,6 +2249,14 @@ app.get("/movie-data/:path(*)", requireAuth, (req, res) => { res.sendFile(fullPath); }); +app.get("/tv-data/:path(*)", requireAuth, (req, res) => { + const relPath = req.params.path || ""; + const fullPath = resolveTvDataAbsolute(relPath); + if (!fullPath) return res.status(400).send("Geçersiz tv 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()); @@ -1308,6 +2420,7 @@ app.delete("/api/file", requireAuth, (req, res) => { ) ); } + removeSeriesEpisode(folderId, relWithinRoot); } } @@ -1443,6 +2556,9 @@ app.get("/api/files", requireAuth, (req, res) => { : null; const extensionForFile = fileMeta?.extension || path.extname(entry.name).replace(/^\./, "").toLowerCase() || null; const mediaInfoForFile = fileMeta?.mediaInfo || null; + const seriesEpisodeInfo = relWithinRoot + ? info.seriesEpisodes?.[relWithinRoot] || null + : null; result.push({ name: safeRel, @@ -1459,7 +2575,8 @@ app.get("/api/files", requireAuth, (req, res) => { extension: extensionForFile, mediaInfo: mediaInfoForFile, primaryVideoPath: info.primaryVideoPath || null, - primaryMediaInfo: info.primaryMediaInfo || null + primaryMediaInfo: info.primaryMediaInfo || null, + seriesEpisode: seriesEpisodeInfo }); } } @@ -1606,6 +2723,416 @@ app.post("/api/movies/refresh", requireAuth, async (req, res) => { } }); +// --- 📺 TV dizileri listesi --- +app.get("/api/tvshows", requireAuth, (req, res) => { + try { + if (!fs.existsSync(TV_DATA_ROOT)) { + return res.json([]); + } + + const dirEntries = fs + .readdirSync(TV_DATA_ROOT, { withFileTypes: true }) + .filter((d) => d.isDirectory()); + + const aggregated = new Map(); + + const mergeEpisode = (existing, incoming) => { + if (!existing) return incoming; + const merged = { + ...existing, + ...incoming + }; + if (existing.still && !incoming.still) merged.still = existing.still; + if (!existing.still && incoming.still) merged.still = incoming.still; + if (existing.mediaInfo && !incoming.mediaInfo) + merged.mediaInfo = existing.mediaInfo; + if (!existing.mediaInfo && incoming.mediaInfo) + merged.mediaInfo = incoming.mediaInfo; + if (existing.overview && !incoming.overview) + merged.overview = existing.overview; + return merged; + }; + + for (const dirent of dirEntries) { + const folder = sanitizeRelative(dirent.name); + if (!folder) continue; + const paths = tvSeriesPaths(folder); + if (!fs.existsSync(paths.metadata)) continue; + + let seriesData; + try { + seriesData = JSON.parse(fs.readFileSync(paths.metadata, "utf-8")); + } catch (err) { + console.warn( + `⚠️ series.json okunamadı (${paths.metadata}): ${err.message}` + ); + continue; + } + + const seasonsObj = seriesData?.seasons || {}; + if (!Object.keys(seasonsObj).length) { + removeSeriesData(folder); + continue; + } + + let dataChanged = false; + + const encodedFolder = folder + .split(path.sep) + .map(encodeURIComponent) + .join("/"); + + const showId = + seriesData.id ?? seriesData.tvdbId ?? seriesData.slug ?? seriesData.name ?? folder; + const showKey = String(showId).toLowerCase(); + const record = + aggregated.get(showKey) || + (() => { + const base = { + id: seriesData.id ?? seriesData.tvdbId ?? folder, + title: seriesData.name || folder, + overview: seriesData.overview || "", + year: seriesData.year || null, + status: seriesData.status || null, + poster: fs.existsSync(paths.poster) + ? `/tv-data/${encodedFolder}/poster.jpg` + : null, + backdrop: fs.existsSync(paths.backdrop) + ? `/tv-data/${encodedFolder}/backdrop.jpg` + : null, + genres: new Set( + Array.isArray(seriesData.genres) + ? seriesData.genres + .map((g) => + typeof g === "string" ? g : g?.name || null + ) + .filter(Boolean) + : [] + ), + seasons: new Map(), + primaryFolder: folder, + folders: new Set([folder]) + }; + aggregated.set(showKey, base); + return base; + })(); + + record.folders.add(folder); + if ( + seriesData.overview && + seriesData.overview.length > (record.overview?.length || 0) + ) { + record.overview = seriesData.overview; + } + if (!record.status && seriesData.status) record.status = seriesData.status; + if ( + !record.year || + (seriesData.year && Number(seriesData.year) < Number(record.year)) + ) { + record.year = seriesData.year || record.year; + } + if (!record.poster && fs.existsSync(paths.poster)) { + record.poster = `/tv-data/${encodedFolder}/poster.jpg`; + } + if (!record.backdrop && fs.existsSync(paths.backdrop)) { + record.backdrop = `/tv-data/${encodedFolder}/backdrop.jpg`; + } + if (Array.isArray(seriesData.genres)) { + seriesData.genres + .map((g) => (typeof g === "string" ? g : g?.name || null)) + .filter(Boolean) + .forEach((genre) => record.genres.add(genre)); + } + + for (const [seasonKey, rawSeason] of Object.entries(seasonsObj)) { + if (!rawSeason?.episodes) continue; + const seasonNumber = toFiniteNumber( + rawSeason.seasonNumber ?? rawSeason.number ?? seasonKey + ); + if (!Number.isFinite(seasonNumber)) continue; + + const seasonPaths = seasonAssetPaths(folder, seasonNumber); + + const rawEpisodes = rawSeason.episodes || {}; + for (const [episodeKey, rawEpisode] of Object.entries(rawEpisodes)) { + if (!rawEpisode || typeof rawEpisode !== "object") continue; + const relativeFile = (rawEpisode.file || "").replace(/\\/g, "/"); + if (relativeFile) { + const absEpisodePath = path.join( + DOWNLOAD_DIR, + folder, + relativeFile + ); + if (!fs.existsSync(absEpisodePath)) { + delete rawEpisodes[episodeKey]; + dataChanged = true; + } + } + } + + if (!Object.keys(rawSeason.episodes || {}).length) { + delete seasonsObj[seasonKey]; + dataChanged = true; + continue; + } + + let seasonRecord = record.seasons.get(seasonNumber); + if (!seasonRecord) { + seasonRecord = { + seasonNumber, + name: rawSeason.name || `Season ${seasonNumber}`, + overview: rawSeason.overview || "", + poster: rawSeason.poster || null, + tvdbId: rawSeason.tvdbId || null, + slug: rawSeason.slug || null, + episodeCount: rawSeason.episodeCount || null, + episodes: new Map() + }; + record.seasons.set(seasonNumber, seasonRecord); + } else { + if (!seasonRecord.name && rawSeason.name) + seasonRecord.name = rawSeason.name; + if (!seasonRecord.overview && rawSeason.overview) + seasonRecord.overview = rawSeason.overview; + if (!seasonRecord.poster && rawSeason.poster) + seasonRecord.poster = rawSeason.poster; + if (!seasonRecord.tvdbId && rawSeason.tvdbId) + seasonRecord.tvdbId = rawSeason.tvdbId; + if (!seasonRecord.slug && rawSeason.slug) + seasonRecord.slug = rawSeason.slug; + if (!seasonRecord.episodeCount && rawSeason.episodeCount) + seasonRecord.episodeCount = rawSeason.episodeCount; + } + + if (!seasonRecord.poster && fs.existsSync(seasonPaths.poster)) { + const relPoster = path.relative( + tvSeriesDir(folder), + seasonPaths.poster + ); + seasonRecord.poster = encodeTvDataPath(folder, relPoster); + } + + for (const [episodeKey, rawEpisode] of Object.entries( + rawSeason.episodes + )) { + if (!rawEpisode || typeof rawEpisode !== "object") continue; + const episodeNumber = toFiniteNumber( + rawEpisode.episodeNumber ?? rawEpisode.number ?? episodeKey + ); + if (!Number.isFinite(episodeNumber)) continue; + + const normalizedEpisode = { + ...rawEpisode + }; + normalizedEpisode.seasonNumber = seasonNumber; + normalizedEpisode.episodeNumber = episodeNumber; + if (!normalizedEpisode.code) { + normalizedEpisode.code = `S${String(seasonNumber).padStart( + 2, + "0" + )}E${String(episodeNumber).padStart(2, "0")}`; + } + const relativeFile = + normalizedEpisode.file || normalizedEpisode.videoPath || ""; + if (!normalizedEpisode.videoPath && relativeFile) { + const joined = relativeFile.includes("/") + ? relativeFile + : `${folder}/${relativeFile}`; + normalizedEpisode.videoPath = joined.replace(/\\/g, "/"); + } else if (normalizedEpisode.videoPath) { + normalizedEpisode.videoPath = normalizedEpisode.videoPath.replace( + /\\/g, + "/" + ); + } + normalizedEpisode.folder = folder; + + const existingEpisode = seasonRecord.episodes.get(episodeNumber); + seasonRecord.episodes.set( + episodeNumber, + mergeEpisode(existingEpisode, normalizedEpisode) + ); + } + + if (!seasonRecord.episodeCount && seasonRecord.episodes.size) { + seasonRecord.episodeCount = seasonRecord.episodes.size; + } + } + + if (dataChanged) { + try { + seriesData.seasons = seasonsObj; + seriesData.updatedAt = Date.now(); + fs.writeFileSync( + paths.metadata, + JSON.stringify(seriesData, null, 2), + "utf-8" + ); + } catch (err) { + console.warn( + `⚠️ series.json güncellenemedi (${paths.metadata}): ${err.message}` + ); + } + } + } + + const shows = Array.from(aggregated.values()) + .map((record) => { + const seasons = Array.from(record.seasons.values()) + .map((season) => { + const episodes = Array.from(season.episodes.values()).sort( + (a, b) => a.episodeNumber - b.episodeNumber + ); + return { + seasonNumber: season.seasonNumber, + name: season.name || `Season ${season.seasonNumber}`, + overview: season.overview || "", + poster: season.poster || null, + tvdbSeasonId: season.tvdbId || null, + slug: season.slug || null, + episodeCount: season.episodeCount || episodes.length, + episodes + }; + }) + .sort((a, b) => a.seasonNumber - b.seasonNumber); + + return { + folder: record.primaryFolder, + id: record.id || record.title, + title: record.title, + overview: record.overview || "", + year: record.year || null, + genres: Array.from(record.genres).filter(Boolean), + status: record.status || null, + poster: record.poster || null, + backdrop: record.backdrop || null, + seasons + }; + }) + .filter((show) => show.seasons.length > 0); + + shows.sort((a, b) => a.title.localeCompare(b.title, "en")); + res.json(shows); + } catch (err) { + console.error("📺 TvShows API error:", err); + res.status(500).json({ error: err.message }); + } +}); + +app.post("/api/tvshows/refresh", requireAuth, async (req, res) => { + if (!TVDB_API_KEY) { + return res + .status(400) + .json({ error: "TVDB API erişimi için gerekli anahtar 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 safeFolder = sanitizeRelative(folder); + if (!safeFolder) continue; + const rootDir = path.join(DOWNLOAD_DIR, safeFolder); + if (!fs.existsSync(rootDir)) continue; + + const info = readInfoForRoot(safeFolder) || {}; + const infoFiles = info.files || {}; + const detected = {}; + + const walkDir = async (currentDir, relativeBase = "") => { + const entries = fs.readdirSync(currentDir, { withFileTypes: true }); + for (const entry of entries) { + const relPath = relativeBase + ? `${relativeBase}/${entry.name}` + : entry.name; + const absPath = path.join(currentDir, entry.name); + + if (entry.isDirectory()) { + await walkDir(absPath, relPath); + continue; + } + + if (entry.name.toLowerCase() === INFO_FILENAME) continue; + const ext = path.extname(entry.name).toLowerCase(); + if (!VIDEO_EXTS.includes(ext)) continue; + + const seriesInfo = parseSeriesInfo(entry.name); + if (!seriesInfo) continue; + + const normalizedRel = relPath.replace(/\\/g, "/"); + let mediaInfo = + infoFiles?.[normalizedRel]?.mediaInfo || null; + if (!mediaInfo) { + try { + mediaInfo = await extractMediaInfo(absPath); + } catch (err) { + console.warn( + `⚠️ Media info alınamadı (${absPath}): ${err?.message || err}` + ); + } + } + + try { + const ensured = await ensureSeriesData( + safeFolder, + normalizedRel, + seriesInfo, + mediaInfo + ); + if (ensured?.show && ensured?.episode) { + detected[normalizedRel] = { + season: seriesInfo.season, + episode: seriesInfo.episode, + key: seriesInfo.key, + title: ensured.episode.title || seriesInfo.title, + showId: ensured.show.id || null, + showTitle: ensured.show.title || seriesInfo.title, + seasonName: + ensured.season?.name || `Season ${seriesInfo.season}`, + seasonId: ensured.season?.tvdbSeasonId || null, + seasonPoster: ensured.season?.poster || null, + overview: ensured.episode.overview || "", + aired: ensured.episode.aired || null, + runtime: ensured.episode.runtime || null, + still: ensured.episode.still || null, + episodeId: ensured.episode.tvdbEpisodeId || null, + slug: ensured.episode.slug || null + }; + } + } catch (err) { + console.warn( + `⚠️ TV metadata yenilenemedi (${safeFolder} - ${entry.name}): ${ + err?.message || err + }` + ); + } + } + }; + + await walkDir(rootDir); + + if (Object.keys(detected).length) { + upsertInfoFile(rootDir, { seriesEpisodes: detected }); + } + + processed.push({ + folder: safeFolder, + episodes: Object.keys(detected).length + }); + } + + res.json({ ok: true, processed }); + } catch (err) { + console.error("📺 TvShows 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);