From e68cba27edcf47102a7e8a1192ef88135817da5a Mon Sep 17 00:00:00 2001 From: szbk Date: Sun, 2 Nov 2025 22:20:54 +0300 Subject: [PATCH] =?UTF-8?q?Klas=C3=B6r=20ta=C5=9F=C4=B1ma=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/routes/Movies.svelte | 8 +- server/server.js | 1301 ++++++++++++++++++++++--------- 2 files changed, 960 insertions(+), 349 deletions(-) diff --git a/client/src/routes/Movies.svelte b/client/src/routes/Movies.svelte index 3f1577e..220261f 100644 --- a/client/src/routes/Movies.svelte +++ b/client/src/routes/Movies.svelte @@ -117,7 +117,9 @@ $: playerItems = movies $: if (showPlayerModal && selectedVideo) { const idx = playerItems.findIndex( - (item) => item.movie.folder === selectedVideo.movie.folder + (item) => + item.movie.folder === selectedVideo.movie.folder && + item.movie.videoPath === selectedVideo.movie.videoPath ); if (idx === -1) { closePlayer(); @@ -155,7 +157,9 @@ async function handlePlay(movie) { async function openPlayerForMovie(movie) { if (!playerItems.length) return; const index = playerItems.findIndex( - (item) => item.movie.folder === movie.folder + (item) => + item.movie.folder === movie.folder && + item.movie.videoPath === movie.videoPath ); if (index === -1) return; await openVideoAtIndex(index); diff --git a/server/server.js b/server/server.js index 43b94b9..8966199 100644 --- a/server/server.js +++ b/server/server.js @@ -86,6 +86,63 @@ function pickBestVideoFile(torrent) { return videos[0].i; } +function enumerateVideoFiles(rootFolder) { + const safe = sanitizeRelative(rootFolder); + if (!safe) return []; + + const baseDir = path.join(DOWNLOAD_DIR, safe); + if (!fs.existsSync(baseDir)) return []; + + const videos = []; + const stack = [""]; + + while (stack.length) { + const currentRel = stack.pop(); + const currentDir = path.join(baseDir, currentRel); + let dirEntries = []; + try { + dirEntries = fs.readdirSync(currentDir, { withFileTypes: true }); + } catch (err) { + console.warn(`⚠️ Klasör okunamadı (${currentDir}): ${err.message}`); + continue; + } + + for (const entry of dirEntries) { + const name = entry.name; + if (name.startsWith(".")) continue; + if (name === INFO_FILENAME) continue; + + const relPath = path.join(currentRel, name); + const absPath = path.join(currentDir, name); + + if (entry.isDirectory()) { + stack.push(relPath); + continue; + } + + if (!entry.isFile()) continue; + + const ext = path.extname(name).toLowerCase(); + if (!VIDEO_EXTS.includes(ext)) continue; + + let size = 0; + try { + size = fs.statSync(absPath).size; + } catch (err) { + console.warn(`⚠️ Dosya boyutu alınamadı (${absPath}): ${err.message}`); + } + + videos.push({ + relPath: relPath.replace(/\\/g, "/"), + size + }); + } + } + + videos.sort((a, b) => b.size - a.size); + return videos; +} + function ensureDirForFile(filePath) { const dir = path.dirname(filePath); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); @@ -911,17 +968,62 @@ function removeAllThumbnailsForRoot(rootFolder) { } } -function movieDataDir(rootFolder) { - return path.join(MOVIE_DATA_ROOT, sanitizeRelative(rootFolder)); +function movieDataKey(rootFolder, videoRelPath = null) { + const normalizedRoot = rootFolder ? sanitizeRelative(rootFolder) : null; + if (!normalizedRoot) return null; + if (!videoRelPath) return normalizedRoot; + const normalizedVideo = normalizeTrashPath(videoRelPath); + if (!normalizedVideo) return normalizedRoot; + const hash = crypto + .createHash("sha1") + .update(normalizedVideo) + .digest("hex") + .slice(0, 12); + const baseSegment = + normalizedVideo + .split("/") + .filter(Boolean) + .pop() || "video"; + const safeSegment = baseSegment + .replace(/[^a-z0-9]+/gi, "-") + .replace(/^-+|-+$/g, "") + .toLowerCase() + .slice(0, 60); + const suffix = safeSegment ? `${safeSegment}-${hash}` : hash; + return `${normalizedRoot}__${suffix}`; } -function movieDataPaths(rootFolder) { - const dir = movieDataDir(rootFolder); +function movieDataLegacyDir(rootFolder) { + const normalizedRoot = sanitizeRelative(rootFolder); + if (!normalizedRoot) return null; + return path.join(MOVIE_DATA_ROOT, normalizedRoot); +} + +function movieDataDir(rootFolder, videoRelPath = null) { + const key = movieDataKey(rootFolder, videoRelPath); + if (!key) return MOVIE_DATA_ROOT; + return path.join(MOVIE_DATA_ROOT, key); +} + +function movieDataPaths(rootFolder, videoRelPath = null) { + const dir = movieDataDir(rootFolder, videoRelPath); return { dir, metadata: path.join(dir, "metadata.json"), poster: path.join(dir, "poster.jpg"), - backdrop: path.join(dir, "backdrop.jpg") + backdrop: path.join(dir, "backdrop.jpg"), + key: movieDataKey(rootFolder, videoRelPath) + }; +} + +function movieDataPathsByKey(key) { + const dir = path.join(MOVIE_DATA_ROOT, key); + return { + dir, + metadata: path.join(dir, "metadata.json"), + poster: path.join(dir, "poster.jpg"), + backdrop: path.join(dir, "backdrop.jpg"), + key }; } @@ -1280,61 +1382,8 @@ async function tvdbFetch(pathname, options = {}, retry = true) { } function guessPrimaryVideo(rootFolder) { - const safe = sanitizeRelative(rootFolder); - if (!safe) return null; - - const baseDir = path.join(DOWNLOAD_DIR, safe); - if (!fs.existsSync(baseDir)) return null; - - let bestRelPath = null; - let bestSize = 0; - - const stack = [""]; - while (stack.length) { - const currentRel = stack.pop(); - const currentDir = path.join(baseDir, currentRel); - let dirEntries = []; - try { - dirEntries = fs.readdirSync(currentDir, { withFileTypes: true }); - } catch (err) { - console.warn(`⚠️ Klasör okunamadı (${currentDir}): ${err.message}`); - continue; - } - - for (const entry of dirEntries) { - const name = entry.name; - if (name.startsWith(".")) continue; - if (name === INFO_FILENAME) continue; - - const relPath = path.join(currentRel, name); - const absPath = path.join(currentDir, name); - - if (entry.isDirectory()) { - stack.push(relPath); - continue; - } - - if (!entry.isFile()) continue; - - const ext = path.extname(name).toLowerCase(); - if (!VIDEO_EXTS.includes(ext)) continue; - - let size = 0; - try { - size = fs.statSync(absPath).size; - } catch (err) { - console.warn(`⚠️ Dosya boyutu alınamadı (${absPath}): ${err.message}`); - continue; - } - - if (size >= bestSize) { - bestSize = size; - bestRelPath = relPath; - } - } - } - - return bestRelPath ? bestRelPath.replace(/\\/g, "/") : null; + const videos = enumerateVideoFiles(rootFolder); + return videos.length ? videos[0].relPath : null; } async function ensureMovieData( @@ -1343,8 +1392,20 @@ async function ensureMovieData( bestVideoPath, precomputedMediaInfo = null ) { - if (!TMDB_API_KEY) return precomputedMediaInfo || null; - console.log("🎬 ensureMovieData çağrıldı:", { rootFolder, displayName }); + const normalizedRoot = sanitizeRelative(rootFolder); + if (!TMDB_API_KEY) { + return { + mediaInfo: precomputedMediaInfo || null, + metadata: null, + cacheKey: null, + videoPath: bestVideoPath ? normalizeTrashPath(bestVideoPath) : null + }; + } + console.log("🎬 ensureMovieData çağrıldı:", { + rootFolder, + displayName, + bestVideoPath + }); const isSeriesPattern = /S\d{1,2}E\d{1,2}/i.test(displayName || "") || @@ -1354,15 +1415,39 @@ async function ensureMovieData( "🎬 TMDB atlandı (TV bölümü tespit edildi, movie_data oluşturulmadı):", { rootFolder, displayName } ); - removeMovieData(rootFolder); - return precomputedMediaInfo || null; + removeMovieData(normalizedRoot || rootFolder); + return { + mediaInfo: precomputedMediaInfo || null, + metadata: null, + cacheKey: null, + videoPath: bestVideoPath ? normalizeTrashPath(bestVideoPath) : null + }; + } + let normalizedVideoPath = bestVideoPath + ? normalizeTrashPath(bestVideoPath) + : null; + + if (!normalizedVideoPath) { + normalizedVideoPath = guessPrimaryVideo(normalizedRoot || rootFolder); } - const paths = movieDataPaths(rootFolder); - const normalizedRoot = sanitizeRelative(rootFolder); - const normalizedVideoPath = bestVideoPath - ? bestVideoPath.replace(/\\/g, "/") - : null; + if (!normalizedVideoPath) { + console.log( + "🎬 TMDB jeçildi (uygun video dosyası bulunamadı):", + normalizedRoot || rootFolder + ); + removeMovieData(normalizedRoot || rootFolder); + return { + mediaInfo: precomputedMediaInfo || null, + metadata: null, + cacheKey: null, + videoPath: null + }; + } + + const rootForPaths = normalizedRoot || rootFolder; + const paths = movieDataPaths(rootForPaths, normalizedVideoPath); + const legacyPaths = movieDataPaths(rootForPaths); let metadata = null; if (fs.existsSync(paths.metadata)) { @@ -1374,19 +1459,68 @@ async function ensureMovieData( `⚠️ metadata.json okunamadı (${paths.metadata}): ${err.message}` ); } + } else if ( + legacyPaths.metadata !== paths.metadata && + fs.existsSync(legacyPaths.metadata) + ) { + try { + metadata = JSON.parse(fs.readFileSync(legacyPaths.metadata, "utf-8")); + console.log("🎬 Legacy metadata bulundu:", legacyPaths.metadata); + } catch (err) { + console.warn( + `⚠️ metadata.json okunamadı (${legacyPaths.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); + const searchCandidates = []; + + if (normalizedVideoPath) { + const videoFileName = path.basename(normalizedVideoPath); + const { title: videoTitle, year: videoYear } = + parseTitleAndYear(videoFileName); + if (videoTitle) { + searchCandidates.push({ + title: videoTitle, + year: videoYear, + source: "video" + }); + } + } + + if (displayName) { + const { title: folderTitle, year: folderYear } = + parseTitleAndYear(displayName); + if (folderTitle) { + searchCandidates.push({ + title: folderTitle, + year: folderYear, + source: "folder" + }); + } else { + searchCandidates.push({ + title: displayName, + year: null, + source: "folderRaw" + }); + } + } + + const tried = new Set(); + for (const candidate of searchCandidates) { + const key = `${candidate.title}::${candidate.year ?? ""}`; + if (tried.has(key)) continue; + tried.add(key); + console.log("🎬 TMDB araması için analiz:", candidate); + const fetched = await fetchMovieMetadata(candidate.title, candidate.year); if (fetched) { metadata = fetched; fetchedMetadata = true; + break; } } } @@ -1396,8 +1530,13 @@ async function ensureMovieData( "🎬 TMDB verisi bulunamadı, movie_data oluşturulmadı:", rootFolder ); - removeMovieData(rootFolder); - return precomputedMediaInfo || null; + removeMovieData(normalizedRoot, normalizedVideoPath); + return { + mediaInfo: precomputedMediaInfo || null, + metadata: null, + cacheKey: null, + videoPath: normalizedVideoPath + }; } ensureDirForFile(paths.metadata); @@ -1435,9 +1574,11 @@ async function ensureMovieData( ...metadata, _dupe: { ...(metadata._dupe || {}), - folder: rootFolder, + folder: normalizedRoot, videoPath, mediaInfo, + cacheKey: paths.key, + displayName: displayName || null, source: "tmdb", fetchedAt: fetchedMetadata ? Date.now() @@ -1454,13 +1595,65 @@ async function ensureMovieData( await downloadImage(backdropUrl, paths.backdrop); } - console.log(`🎬 TMDB verisi hazır: ${rootFolder}`); - return mediaInfo; + console.log(`🎬 TMDB verisi hazır: ${rootFolder}`, { + videoPath, + cacheKey: paths.key + }); + if ( + legacyPaths.metadata !== paths.metadata && + fs.existsSync(legacyPaths.metadata) + ) { + try { + fs.rmSync(legacyPaths.dir, { recursive: true, force: true }); + console.log(`🧹 Legacy movie metadata kaldırıldı: ${legacyPaths.dir}`); + } catch (err) { + console.warn( + `⚠️ Legacy movie metadata kaldırılamadı (${legacyPaths.dir}): ${err.message}` + ); + } + } + + return { + mediaInfo, + metadata: enriched, + cacheKey: paths.key, + videoPath + }; } -function removeMovieData(rootFolder) { - const dir = movieDataDir(rootFolder); - if (fs.existsSync(dir)) { +function removeMovieData(rootFolder, videoRelPath = null) { + const normalizedRoot = rootFolder ? sanitizeRelative(rootFolder) : null; + if (!normalizedRoot) return; + + const targetDirs = new Set(); + + if (videoRelPath) { + targetDirs.add(movieDataDir(normalizedRoot, videoRelPath)); + } else { + targetDirs.add(movieDataLegacyDir(normalizedRoot)); + try { + const entries = fs.readdirSync(MOVIE_DATA_ROOT, { + withFileTypes: true + }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if ( + entry.name === normalizedRoot || + entry.name.startsWith(`${normalizedRoot}__`) + ) { + targetDirs.add(path.join(MOVIE_DATA_ROOT, entry.name)); + } + } + } catch (err) { + console.warn( + `⚠️ Movie metadata dizini listelenemedi (${MOVIE_DATA_ROOT}): ${err.message}` + ); + } + } + + for (const dir of targetDirs) { + if (!dir) continue; + if (!fs.existsSync(dir)) continue; try { fs.rmSync(dir, { recursive: true, force: true }); console.log(`🧹 Movie metadata silindi: ${dir}`); @@ -1660,12 +1853,6 @@ async function fetchTvdbEpisodeExtended(episodeId) { 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); @@ -1692,14 +1879,23 @@ function ensureSeasonContainer(seriesData, seasonNumber) { return container; } -function encodeTvDataPath(rootFolder, relativePath) { - const encoded = sanitizeRelative(rootFolder) +function encodeTvDataPath(rootOrKey, relativePath) { + if (!rootOrKey) return null; + const base = String(rootOrKey); + const normalizedKey = base.includes("__") + ? sanitizeRelative(base) + : tvSeriesKey(rootOrKey); + if (!normalizedKey) return null; + const encodedBase = normalizedKey .split(path.sep) .map(encodeURIComponent) .join("/"); - return relativePath - ? `/tv-data/${encoded}/${relativePath.split(path.sep).map(encodeURIComponent).join("/")}` - : `/tv-data/${encoded}`; + if (!relativePath) return `/tv-data/${encodedBase}`; + const encodedRel = String(relativePath) + .split(path.sep) + .map(encodeURIComponent) + .join("/"); + return `/tv-data/${encodedBase}/${encodedRel}`; } async function ensureSeriesData( @@ -1722,17 +1918,51 @@ async function ensureSeriesData( seriesInfo }); - const showDir = buildTvShowDir(rootFolder); - const seriesMetaPath = path.join(showDir, "series.json"); - let seriesData = {}; - if (fs.existsSync(seriesMetaPath)) { + const normalizedRoot = sanitizeRelative(rootFolder); + const normalizedFile = normalizeTrashPath(relativeFilePath); + + const candidateKeys = listTvSeriesKeysForRoot(normalizedRoot); + let seriesData = null; + let existingPaths = null; + + for (const key of candidateKeys) { + const candidatePaths = tvSeriesPathsByKey(key); + if (!fs.existsSync(candidatePaths.metadata)) continue; try { - seriesData = JSON.parse(fs.readFileSync(seriesMetaPath, "utf-8")) || {}; + const data = JSON.parse(fs.readFileSync(candidatePaths.metadata, "utf-8")) || {}; + const seasons = data?.seasons || {}; + const matchesEpisode = Object.values(seasons).some((season) => + season?.episodes && + Object.values(season.episodes).some((episode) => episode?.file === normalizedFile) + ); + if (matchesEpisode) { + seriesData = data; + existingPaths = candidatePaths; + break; + } } catch (err) { - console.warn(`⚠️ series.json okunamadı (${seriesMetaPath}): ${err.message}`); + console.warn( + `⚠️ series.json okunamadı (${candidatePaths.metadata}): ${err.message}` + ); } } + const legacyPaths = tvSeriesPaths(normalizedRoot); + if (!seriesData && fs.existsSync(legacyPaths.metadata)) { + try { + seriesData = JSON.parse(fs.readFileSync(legacyPaths.metadata, "utf-8")) || {}; + existingPaths = legacyPaths; + } catch (err) { + console.warn( + `⚠️ series.json okunamadı (${legacyPaths.metadata}): ${err.message}` + ); + } + } + + if (!seriesData) { + seriesData = {}; + } + let seriesId = seriesData.id ?? seriesData.tvdbId ?? null; if (!seriesId) { const searchResult = await searchTvdbSeries(seriesInfo.searchTitle); @@ -1752,6 +1982,25 @@ async function ensureSeriesData( seriesData.tvdbId = seriesId; } + let targetPaths = + existingPaths && existingPaths.key?.includes("__") ? existingPaths : null; + + if (!targetPaths) { + targetPaths = buildTvSeriesPaths( + normalizedRoot, + seriesId, + seriesInfo.title + ); + if (!targetPaths && existingPaths) { + targetPaths = existingPaths; + } + } + + if (!targetPaths) return null; + + const showDir = targetPaths.dir; + const seriesMetaPath = targetPaths.metadata; + const extended = await fetchTvdbSeriesExtended(seriesId); const container = extended || {}; console.log("📺 TVDB extended yükleme:", { @@ -1846,7 +2095,9 @@ async function ensureSeriesData( } seriesData._dupe = { ...(seriesData._dupe || {}), - folder: rootFolder + folder: normalizedRoot, + seriesId, + key: targetPaths.key }; seriesData.updatedAt = Date.now(); @@ -1922,7 +2173,7 @@ async function ensureSeriesData( } } - const seasonPaths = seasonAssetPaths(rootFolder, seriesInfo.season); + const seasonPaths = seasonAssetPaths(targetPaths, seriesInfo.season); const seasonsRaw = Array.isArray(container.seasons) ? container.seasons : Array.isArray(container.series?.seasons) @@ -1967,7 +2218,7 @@ async function ensureSeriesData( if (fs.existsSync(seasonPaths.poster)) { const relPoster = path.relative(showDir, seasonPaths.poster); if (!relPoster.startsWith("..")) { - seasonContainer.poster = encodeTvDataPath(rootFolder, relPoster); + seasonContainer.poster = encodeTvDataPath(targetPaths.key, relPoster); } } if (!seasonContainer.overview) seasonContainer.overview = ""; @@ -2083,9 +2334,9 @@ async function ensureSeriesData( runtime: episodeRuntime, aired: episodeAirDate, still: fs.existsSync(stillPath) - ? encodeTvDataPath(rootFolder, path.relative(showDir, stillPath)) + ? encodeTvDataPath(targetPaths.key, path.relative(showDir, stillPath)) : null, - file: relativeFilePath, + file: normalizedFile, mediaInfo: mediaInfo || null, tvdbEpisodeId: episodeTvdbId, slug: episodeSlug, @@ -2100,6 +2351,23 @@ async function ensureSeriesData( ensureDirForFile(seriesMetaPath); fs.writeFileSync(seriesMetaPath, JSON.stringify(seriesData, null, 2), "utf-8"); + if ( + existingPaths && + existingPaths.key !== targetPaths.key && + fs.existsSync(existingPaths.dir) + ) { + try { + fs.rmSync(existingPaths.dir, { recursive: true, force: true }); + console.log( + `🧹 Legacy TV metadata kaldırıldı: ${existingPaths.dir}` + ); + } catch (err) { + console.warn( + `⚠️ Legacy TV metadata kaldırılamadı (${existingPaths.dir}): ${err.message}` + ); + } + } + return { show: { id: seriesData.id || null, @@ -2107,10 +2375,10 @@ async function ensureSeriesData( year: seriesData.year || null, overview: seriesData.overview || "", poster: fs.existsSync(path.join(showDir, "poster.jpg")) - ? encodeTvDataPath(rootFolder, "poster.jpg") + ? encodeTvDataPath(targetPaths.key, "poster.jpg") : null, backdrop: fs.existsSync(path.join(showDir, "backdrop.jpg")) - ? encodeTvDataPath(rootFolder, "backdrop.jpg") + ? encodeTvDataPath(targetPaths.key, "backdrop.jpg") : null }, season: { @@ -2121,44 +2389,133 @@ async function ensureSeriesData( tvdbSeasonId: seasonContainer.tvdbId || null, slug: seasonContainer.slug || null }, - episode: seasonContainer.episodes[episodeKey] + episode: seasonContainer.episodes[episodeKey], + cacheKey: targetPaths.key }; } -function tvSeriesDir(rootFolder) { - return path.join(TV_DATA_ROOT, sanitizeRelative(rootFolder)); +function tvSeriesKey(rootFolder, seriesId = null, fallbackTitle = null) { + const normalizedRoot = rootFolder ? sanitizeRelative(rootFolder) : null; + if (!normalizedRoot) return null; + let suffix = null; + if (seriesId) { + suffix = String(seriesId).toLowerCase(); + } else if (fallbackTitle) { + const slug = String(fallbackTitle) + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 60); + if (slug) { + const hash = crypto.createHash("sha1").update(slug).digest("hex"); + suffix = `${slug}-${hash.slice(0, 8)}`; + } + } + return suffix ? `${normalizedRoot}__${suffix}` : normalizedRoot; } -function tvSeriesPaths(rootFolder) { - const dir = tvSeriesDir(rootFolder); +function parseTvSeriesKey(key) { + const normalized = sanitizeRelative(String(key || "")); + if (!normalized.includes("__")) { + return { rootFolder: normalized, seriesId: null, key: normalized }; + } + const [rootFolder, suffix] = normalized.split("__", 2); + return { rootFolder, seriesId: suffix || null, key: normalized }; +} + +function tvSeriesPathsByKey(key) { + const normalizedKey = sanitizeRelative(key); + const dir = path.join(TV_DATA_ROOT, normalizedKey); return { + key: normalizedKey, 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") + seasonsDir: path.join(dir, "seasons"), + rootFolder: parseTvSeriesKey(normalizedKey).rootFolder }; } -function seasonAssetPaths(rootFolder, seasonNumber) { - const baseDir = tvSeriesDir(rootFolder); +function tvSeriesPaths(rootFolderOrKey, seriesId = null, fallbackTitle = null) { + if ( + seriesId === null && + fallbackTitle === null && + String(rootFolderOrKey || "").includes("__") + ) { + return tvSeriesPathsByKey(rootFolderOrKey); + } + const key = tvSeriesKey(rootFolderOrKey, seriesId, fallbackTitle); + if (!key) return null; + return tvSeriesPathsByKey(key); +} + +function tvSeriesDir(rootFolderOrKey, seriesId = null, fallbackTitle = null) { + const paths = tvSeriesPaths(rootFolderOrKey, seriesId, fallbackTitle); + return paths?.dir || null; +} + +function buildTvSeriesPaths(rootFolder, seriesId = null, fallbackTitle = null) { + const paths = tvSeriesPaths(rootFolder, seriesId, fallbackTitle); + if (!paths) return null; + if (!fs.existsSync(paths.dir)) fs.mkdirSync(paths.dir, { recursive: true }); + if (!fs.existsSync(paths.episodesDir)) + fs.mkdirSync(paths.episodesDir, { recursive: true }); + if (!fs.existsSync(paths.seasonsDir)) + fs.mkdirSync(paths.seasonsDir, { recursive: true }); + return paths; +} + +function seasonAssetPaths(paths, seasonNumber) { const padded = String(seasonNumber).padStart(2, "0"); - const dir = path.join(baseDir, "seasons", `season-${padded}`); + const dir = path.join(paths.dir, "seasons", `season-${padded}`); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); 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 listTvSeriesKeysForRoot(rootFolder) { + const normalizedRoot = rootFolder ? sanitizeRelative(rootFolder) : null; + if (!normalizedRoot) return []; + if (!fs.existsSync(TV_DATA_ROOT)) return []; + const keys = []; + try { + const entries = fs.readdirSync(TV_DATA_ROOT, { withFileTypes: true }); + for (const dirent of entries) { + if (!dirent.isDirectory()) continue; + const name = dirent.name; + if ( + name === normalizedRoot || + name.startsWith(`${normalizedRoot}__`) + ) { + keys.push(name); + } + } + } catch (err) { + console.warn( + `⚠️ TV metadata dizini listelenemedi (${TV_DATA_ROOT}): ${err.message}` + ); + } + return keys; +} + +function removeSeriesData(rootFolder, seriesId = null) { + const keys = seriesId + ? [tvSeriesKey(rootFolder, seriesId)].filter(Boolean) + : listTvSeriesKeysForRoot(rootFolder); + for (const key of keys) { + const dir = tvSeriesDir(key); + if (dir && 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}`); + } } } } @@ -2166,62 +2523,76 @@ function removeSeriesData(rootFolder) { function removeSeriesEpisode(rootFolder, relativeFilePath) { if (!rootFolder || !relativeFilePath) return; - const safeRoot = sanitizeRelative(rootFolder); - if (!safeRoot) return; + const keys = listTvSeriesKeysForRoot(rootFolder); + if (!keys.length) return; - const seriesMetaPath = tvSeriesPaths(safeRoot).metadata; - if (!fs.existsSync(seriesMetaPath)) return; + for (const key of keys) { + const paths = tvSeriesPathsByKey(key); + if (!fs.existsSync(paths.metadata)) continue; - let seriesData; - try { - seriesData = JSON.parse(fs.readFileSync(seriesMetaPath, "utf-8")); - } catch (err) { - console.warn( - `⚠️ series.json okunamadı (${seriesMetaPath}): ${err.message}` - ); - return; - } + 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 seasons = seriesData?.seasons || {}; - let removed = false; + const seasons = seriesData?.seasons || {}; + let removed = false; - for (const [seasonKey, season] of Object.entries(seasons)) { - if (!season?.episodes) continue; + 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; + 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 ( - seasonChanged && - (!season.episodes || Object.keys(season.episodes).length === 0) - ) { - delete seasons[seasonKey]; + if (!removed) continue; + + if (!Object.keys(seasons).length) { + removeSeriesData(seriesData._dupe?.folder || rootFolder, seriesData.id); + continue; } - } - if (!removed) return; + seriesData.seasons = seasons; + seriesData.updatedAt = Date.now(); - if (!Object.keys(seasons).length) { - removeSeriesData(safeRoot); - return; - } + try { + fs.writeFileSync(paths.metadata, JSON.stringify(seriesData, null, 2), "utf-8"); + } catch (err) { + console.warn( + `⚠️ series.json güncellenemedi (${paths.metadata}): ${err.message}` + ); + } - 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}` - ); + try { + const seasonDirs = [paths.episodesDir, paths.seasonsDir]; + for (const dir of seasonDirs) { + if (!dir || !fs.existsSync(dir)) continue; + cleanupEmptyDirs(dir); + } + } catch (err) { + console.warn( + `⚠️ TV metadata klasörü temizlenemedi (${paths.dir}): ${err.message}` + ); + } } } @@ -2565,23 +2936,25 @@ function renameSeriesDataPaths(rootFolder, oldRel, newRel) { const newPrefix = normalizeTrashPath(newRel); if (!oldPrefix || oldPrefix === newPrefix) return; - const metadataPath = tvSeriesPaths(rootFolder).metadata; - if (!fs.existsSync(metadataPath)) return; + const keys = listTvSeriesKeysForRoot(rootFolder); + for (const key of keys) { + const metadataPath = tvSeriesPathsByKey(key).metadata; + if (!fs.existsSync(metadataPath)) continue; - let seriesData; - try { - seriesData = JSON.parse(fs.readFileSync(metadataPath, "utf-8")); - } catch (err) { - console.warn(`⚠️ series.json okunamadı (${metadataPath}): ${err.message}`); - return; - } + let seriesData; + try { + seriesData = JSON.parse(fs.readFileSync(metadataPath, "utf-8")); + } catch (err) { + console.warn(`⚠️ series.json okunamadı (${metadataPath}): ${err.message}`); + continue; + } - const transform = (value) => { - const normalized = normalizeTrashPath(value); - if ( - normalized === oldPrefix || - normalized.startsWith(`${oldPrefix}/`) - ) { + const transform = (value) => { + const normalized = normalizeTrashPath(value); + if ( + normalized === oldPrefix || + normalized.startsWith(`${oldPrefix}/`) + ) { const suffix = normalized.slice(oldPrefix.length).replace(/^\/+/, ""); return newPrefix ? `${newPrefix}${suffix ? `/${suffix}` : ""}` @@ -2590,37 +2963,38 @@ function renameSeriesDataPaths(rootFolder, oldRel, newRel) { return value; }; - let changed = false; + let changed = false; - const seasons = seriesData?.seasons || {}; - for (const season of Object.values(seasons)) { - if (!season?.episodes) continue; - for (const episode of Object.values(season.episodes)) { - if (!episode || typeof episode !== "object") continue; - if (episode.file) { - const nextFile = transform(episode.file); - if (nextFile !== episode.file) { - episode.file = nextFile; - changed = true; + const seasons = seriesData?.seasons || {}; + for (const season of Object.values(seasons)) { + if (!season?.episodes) continue; + for (const episode of Object.values(season.episodes)) { + if (!episode || typeof episode !== "object") continue; + if (episode.file) { + const nextFile = transform(episode.file); + if (nextFile !== episode.file) { + episode.file = nextFile; + changed = true; + } } - } - if (episode.videoPath) { - const nextVideo = transform(episode.videoPath); - if (nextVideo !== episode.videoPath) { - episode.videoPath = nextVideo; - changed = true; + if (episode.videoPath) { + const nextVideo = transform(episode.videoPath); + if (nextVideo !== episode.videoPath) { + episode.videoPath = nextVideo; + changed = true; + } } } } - } - if (changed) { - try { - fs.writeFileSync(metadataPath, JSON.stringify(seriesData, null, 2), "utf-8"); - } catch (err) { - console.warn( - `⚠️ series.json güncellenemedi (${metadataPath}): ${err.message}` - ); + if (changed) { + try { + fs.writeFileSync(metadataPath, JSON.stringify(seriesData, null, 2), "utf-8"); + } catch (err) { + console.warn( + `⚠️ series.json güncellenemedi (${metadataPath}): ${err.message}` + ); + } } } } @@ -2983,6 +3357,24 @@ app.post("/api/transfer", requireAuth, upload.single("torrent"), (req, res) => { episodeId: ensured.episode.tvdbEpisodeId || null, slug: ensured.episode.slug || null }; + const fileEntry = perFileMetadata[normalizedRelPath] || {}; + perFileMetadata[normalizedRelPath] = { + ...fileEntry, + seriesMatch: { + id: ensured.show.id || null, + title: ensured.show.title || seriesInfo.title, + season: ensured.season?.seasonNumber ?? seriesInfo.season, + episode: ensured.episode.episodeNumber ?? seriesInfo.episode, + code: ensured.episode.code || seriesInfo.key, + poster: ensured.show.poster || null, + backdrop: ensured.show.backdrop || null, + seasonPoster: ensured.season?.poster || null, + aired: ensured.episode.aired || null, + runtime: ensured.episode.runtime || null, + tvdbEpisodeId: ensured.episode.tvdbEpisodeId || null, + matchedAt: Date.now() + } + }; } } catch (err) { console.warn( @@ -3022,7 +3414,34 @@ app.post("/api/transfer", requireAuth, upload.single("torrent"), (req, res) => { bestVideoPath, primaryMediaInfo ); - if (ensuredMedia) infoUpdate.primaryMediaInfo = ensuredMedia; + if (ensuredMedia?.mediaInfo) { + infoUpdate.primaryMediaInfo = ensuredMedia.mediaInfo; + if (!infoUpdate.files) infoUpdate.files = perFileMetadata; + if (bestVideoPath) { + const entry = infoUpdate.files[bestVideoPath] || {}; + infoUpdate.files[bestVideoPath] = { + ...entry, + movieMatch: ensuredMedia.metadata + ? { + id: ensuredMedia.metadata.id ?? null, + title: + ensuredMedia.metadata.title || + ensuredMedia.metadata.matched_title || + displayName, + year: ensuredMedia.metadata.release_date + ? Number( + ensuredMedia.metadata.release_date.slice(0, 4) + ) + : ensuredMedia.metadata.matched_year || null, + poster: ensuredMedia.metadata.poster_path || null, + backdrop: ensuredMedia.metadata.backdrop_path || null, + cacheKey: ensuredMedia.cacheKey || null, + matchedAt: Date.now() + } + : entry.movieMatch + }; + } + } upsertInfoFile(entry.savePath, infoUpdate); broadcastFileUpdate(rootFolder); @@ -4040,67 +4459,78 @@ app.get("/api/movies", requireAuth, (req, res) => { return res.json([]); } - const entries = fs - .readdirSync(MOVIE_DATA_ROOT, { withFileTypes: true }) - .filter((d) => d.isDirectory()); + 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 movies = entries + .map((dirent) => { + const key = dirent.name; + const paths = movieDataPathsByKey(key); + if (!fs.existsSync(paths.metadata)) return null; + try { + const metadata = JSON.parse( + fs.readFileSync(paths.metadata, "utf-8") + ); + if (!isTmdbMetadata(metadata)) { + try { + fs.rmSync(paths.dir, { recursive: true, force: true }); + } catch (err) { + console.warn( + `⚠️ Movie metadata temizlenemedi (${paths.dir}): ${err.message}` + ); } - const encodedFolder = folder - .split(path.sep) - .map(encodeURIComponent) - .join("/"); - const posterExists = fs.existsSync(paths.poster); - const backdropExists = fs.existsSync(paths.backdrop); + return null; + } + const dupe = metadata._dupe || {}; + const rootFolder = dupe.folder || key; + const videoPath = dupe.videoPath || metadata.videoPath || null; + const encodedKey = key + .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}` + : 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 cacheKey = paths.key; + return { + folder: rootFolder, + cacheKey, + id: + metadata.id ?? + `${rootFolder}:${videoPath || "unknown"}:${cacheKey || "cache"}`, + title: metadata.title || metadata.matched_title || rootFolder, + 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/${encodedKey}/poster.jpg` + : null, + backdrop: backdropExists + ? `/movie-data/${encodedKey}/backdrop.jpg` + : null, + videoPath, + mediaInfo: dupe.mediaInfo || null, + metadata + }; + } catch (err) { + console.warn( + `⚠️ metadata.json okunamadı (${paths.metadata}): ${err.message}` ); return null; } @@ -4153,30 +4583,31 @@ async function rebuildMovieMetadata({ clearCache = false } = {}) { try { const info = readInfoForRoot(folder) || {}; - const displayName = info?.name || dirent.name || folder; + const infoFiles = info.files || {}; + const filesUpdate = { ...infoFiles }; - const normalizePath = (value) => - value ? String(value).replace(/\\/g, "/") : value; + const videoEntries = enumerateVideoFiles(folder); + const videoSet = new Set(videoEntries.map((item) => item.relPath)); - let primaryVideo = normalizePath(info?.primaryVideoPath || null); - if (primaryVideo) { - const absPrimary = path.join(rootDir, primaryVideo); - if (!fs.existsSync(absPrimary)) { - primaryVideo = null; - } - } - if (!primaryVideo) { - primaryVideo = normalizePath(guessPrimaryVideo(folder)); - } - - if (!primaryVideo) { + if (!videoEntries.length) { removeMovieData(folder); - if (clearCache) { - upsertInfoFile(rootDir, { - primaryVideoPath: null, - primaryMediaInfo: null - }); + removeSeriesData(folder); + const update = { + primaryVideoPath: null, + primaryMediaInfo: null, + movieMatch: null, + files: { ...filesUpdate } + }; + for (const value of Object.values(update.files)) { + if (value?.seriesMatch) delete value.seriesMatch; } + // Clear movieMatch for legacy entries + for (const key of Object.keys(update.files)) { + if (update.files[key]?.movieMatch) { + delete update.files[key].movieMatch; + } + } + upsertInfoFile(rootDir, update); console.log( `ℹ️ Movie taraması atlandı (video bulunamadı): ${folder}` ); @@ -4184,12 +4615,16 @@ async function rebuildMovieMetadata({ clearCache = false } = {}) { continue; } - let mediaInfo = - info?.files?.[primaryVideo]?.mediaInfo || info?.primaryMediaInfo || null; + removeMovieData(folder); + removeSeriesData(folder); - if (!mediaInfo) { - const absVideo = path.join(rootDir, primaryVideo); - if (fs.existsSync(absVideo)) { + const matches = []; + + for (const { relPath, size } of videoEntries) { + const absVideo = path.join(rootDir, relPath); + let mediaInfo = filesUpdate[relPath]?.mediaInfo || null; + + if (!mediaInfo && fs.existsSync(absVideo)) { try { mediaInfo = await extractMediaInfo(absVideo); } catch (err) { @@ -4198,19 +4633,99 @@ async function rebuildMovieMetadata({ clearCache = false } = {}) { ); } } + + const ensured = await ensureMovieData( + folder, + path.basename(relPath), + relPath, + mediaInfo + ); + + if (ensured?.mediaInfo) { + mediaInfo = ensured.mediaInfo; + } + + const fileEntry = { + ...(filesUpdate[relPath] || {}), + size: size ?? filesUpdate[relPath]?.size ?? null, + mediaInfo: mediaInfo || null + }; + + if (ensured?.metadata) { + const meta = ensured.metadata; + const releaseYear = meta.release_date + ? Number(meta.release_date.slice(0, 4)) + : meta.matched_year || null; + fileEntry.movieMatch = { + id: meta.id ?? null, + title: meta.title || meta.matched_title || path.basename(relPath), + year: releaseYear, + poster: meta.poster_path || null, + backdrop: meta.backdrop_path || null, + cacheKey: ensured.cacheKey, + matchedAt: Date.now() + }; + matches.push({ relPath, match: fileEntry.movieMatch }); + } else if (fileEntry.movieMatch) { + delete fileEntry.movieMatch; + } + + if (ensured?.show && ensured?.episode) { + const episode = ensured.episode; + const show = ensured.show; + const season = ensured.season; + fileEntry.seriesMatch = { + id: show?.id ?? null, + title: show?.title || seriesInfo.title, + season: season?.seasonNumber ?? seriesInfo.season, + episode: episode?.episodeNumber ?? seriesInfo.episode, + code: episode?.code || seriesInfo.key, + poster: show?.poster || null, + backdrop: show?.backdrop || null, + seasonPoster: season?.poster || null, + aired: episode?.aired || null, + runtime: episode?.runtime || null, + tvdbEpisodeId: episode?.tvdbEpisodeId || null, + cacheKey: ensured.cacheKey || null, + matchedAt: Date.now() + }; + } else if (fileEntry.seriesMatch) { + delete fileEntry.seriesMatch; + } + + filesUpdate[relPath] = fileEntry; } - const ensured = await ensureMovieData( - folder, - displayName, - primaryVideo, - mediaInfo - ); + // Temizlenmiş video listesinde yer almayanların film eşleşmesini temizle + for (const key of Object.keys(filesUpdate)) { + if (videoSet.has(key)) continue; + if (filesUpdate[key]?.movieMatch) { + delete filesUpdate[key].movieMatch; + } + if (filesUpdate[key]?.seriesMatch) { + delete filesUpdate[key].seriesMatch; + } + } const update = { - primaryVideoPath: primaryVideo, - primaryMediaInfo: ensured || mediaInfo || null + files: filesUpdate }; + + if (videoEntries.length) { + const primaryRel = videoEntries[0].relPath; + update.primaryVideoPath = primaryRel; + update.primaryMediaInfo = filesUpdate[primaryRel]?.mediaInfo || null; + const primaryMatch = + matches.find((item) => item.relPath === primaryRel)?.match || + matches[0]?.match || + null; + update.movieMatch = primaryMatch || null; + } else { + update.primaryVideoPath = null; + update.primaryMediaInfo = null; + update.movieMatch = null; + } + upsertInfoFile(rootDir, update); processed.push(folder); @@ -4284,12 +4799,14 @@ app.get("/api/tvshows", requireAuth, (req, res) => { }; for (const dirent of dirEntries) { - const folder = sanitizeRelative(dirent.name); - if (!folder) continue; - const paths = tvSeriesPaths(folder); - if (!fs.existsSync(paths.metadata)) continue; + const key = sanitizeRelative(dirent.name); + if (!key) continue; + const paths = tvSeriesPathsByKey(key); + if (!paths || !fs.existsSync(paths.metadata)) continue; + const { rootFolder } = parseTvSeriesKey(key); + if (!rootFolder) continue; - const infoForFolder = readInfoForRoot(folder) || {}; + const infoForFolder = readInfoForRoot(rootFolder) || {}; const infoFiles = infoForFolder.files || {}; const infoEpisodes = infoForFolder.seriesEpisodes || {}; const infoEpisodeIndex = new Map(); @@ -4309,7 +4826,7 @@ app.get("/api/tvshows", requireAuth, (req, res) => { if (!VIDEO_EXTS.includes(ext)) continue; const absVideo = normalizedRel - ? path.join(DOWNLOAD_DIR, folder, normalizedRel) + ? path.join(DOWNLOAD_DIR, rootFolder, normalizedRel) : null; if (!absVideo || !fs.existsSync(absVideo)) continue; @@ -4331,34 +4848,29 @@ app.get("/api/tvshows", requireAuth, (req, res) => { const seasonsObj = seriesData?.seasons || {}; if (!Object.keys(seasonsObj).length) { - removeSeriesData(folder); + removeSeriesData(rootFolder, seriesData.id ?? seriesData.tvdbId ?? null); continue; } let dataChanged = false; - const encodedFolder = folder - .split(path.sep) - .map(encodeURIComponent) - .join("/"); - const showId = - seriesData.id ?? seriesData.tvdbId ?? seriesData.slug ?? seriesData.name ?? folder; + seriesData.id ?? seriesData.tvdbId ?? seriesData.slug ?? seriesData.name ?? rootFolder; const showKey = String(showId).toLowerCase(); const record = aggregated.get(showKey) || (() => { const base = { - id: seriesData.id ?? seriesData.tvdbId ?? folder, - title: seriesData.name || folder, + id: seriesData.id ?? seriesData.tvdbId ?? rootFolder, + title: seriesData.name || rootFolder, overview: seriesData.overview || "", year: seriesData.year || null, status: seriesData.status || null, poster: fs.existsSync(paths.poster) - ? `/tv-data/${encodedFolder}/poster.jpg` + ? encodeTvDataPath(paths.key, "poster.jpg") : null, backdrop: fs.existsSync(paths.backdrop) - ? `/tv-data/${encodedFolder}/backdrop.jpg` + ? encodeTvDataPath(paths.key, "backdrop.jpg") : null, genres: new Set( Array.isArray(seriesData.genres) @@ -4370,14 +4882,14 @@ app.get("/api/tvshows", requireAuth, (req, res) => { : [] ), seasons: new Map(), - primaryFolder: folder, - folders: new Set([folder]) + primaryFolder: rootFolder, + folders: new Set([rootFolder]) }; aggregated.set(showKey, base); return base; })(); - record.folders.add(folder); + record.folders.add(rootFolder); if ( seriesData.overview && seriesData.overview.length > (record.overview?.length || 0) @@ -4392,10 +4904,10 @@ app.get("/api/tvshows", requireAuth, (req, res) => { record.year = seriesData.year || record.year; } if (!record.poster && fs.existsSync(paths.poster)) { - record.poster = `/tv-data/${encodedFolder}/poster.jpg`; + record.poster = encodeTvDataPath(paths.key, "poster.jpg"); } if (!record.backdrop && fs.existsSync(paths.backdrop)) { - record.backdrop = `/tv-data/${encodedFolder}/backdrop.jpg`; + record.backdrop = encodeTvDataPath(paths.key, "backdrop.jpg"); } if (Array.isArray(seriesData.genres)) { seriesData.genres @@ -4411,7 +4923,7 @@ app.get("/api/tvshows", requireAuth, (req, res) => { ); if (!Number.isFinite(seasonNumber)) continue; - const seasonPaths = seasonAssetPaths(folder, seasonNumber); + const seasonPaths = seasonAssetPaths(paths, seasonNumber); const rawEpisodes = rawSeason.episodes || {}; for (const [episodeKey, rawEpisode] of Object.entries(rawEpisodes)) { @@ -4420,7 +4932,7 @@ app.get("/api/tvshows", requireAuth, (req, res) => { if (relativeFile) { const absEpisodePath = path.join( DOWNLOAD_DIR, - folder, + rootFolder, relativeFile ); if (!fs.existsSync(absEpisodePath)) { @@ -4465,11 +4977,8 @@ app.get("/api/tvshows", requireAuth, (req, res) => { } if (!seasonRecord.poster && fs.existsSync(seasonPaths.poster)) { - const relPoster = path.relative( - tvSeriesDir(folder), - seasonPaths.poster - ); - seasonRecord.poster = encodeTvDataPath(folder, relPoster); + const relPoster = path.relative(paths.dir, seasonPaths.poster); + seasonRecord.poster = encodeTvDataPath(paths.key, relPoster); } for (const [episodeKey, rawEpisode] of Object.entries( @@ -4495,7 +5004,7 @@ app.get("/api/tvshows", requireAuth, (req, res) => { const infoEpisode = infoEpisodeIndex.get(`${seasonNumber}-${episodeNumber}`); if (infoEpisode?.relPath) { const normalizedRel = infoEpisode.relPath.replace(/^\/+/, ""); - const withRoot = `${folder}/${normalizedRel}`.replace(/^\/+/, ""); + const withRoot = `${rootFolder}/${normalizedRel}`.replace(/^\/+/, ""); normalizedEpisode.file = normalizedRel; normalizedEpisode.videoPath = withRoot; const fileMeta = infoFiles[normalizedRel]; @@ -4512,20 +5021,20 @@ app.get("/api/tvshows", requireAuth, (req, res) => { let videoPath = rawVideoPath.replace(/\\/g, "/").replace(/^\.\//, ""); if (videoPath) { const isExternal = /^https?:\/\//i.test(videoPath); - const needsFolderPrefix = - !isExternal && - !videoPath.startsWith(`${folder}/`) && - !videoPath.startsWith(`/${folder}/`); - if (needsFolderPrefix) { - videoPath = `${folder}/${videoPath}`.replace(/\\/g, "/"); - } + const needsFolderPrefix = + !isExternal && + !videoPath.startsWith(`${rootFolder}/`) && + !videoPath.startsWith(`/${rootFolder}/`); + if (needsFolderPrefix) { + videoPath = `${rootFolder}/${videoPath}`.replace(/\\/g, "/"); + } const finalPath = videoPath.replace(/^\/+/, ""); if (finalPath !== rawVideoPath) { dataChanged = true; } normalizedEpisode.videoPath = finalPath; } else if (relativeFile) { - normalizedEpisode.videoPath = `${folder}/${relativeFile}` + normalizedEpisode.videoPath = `${rootFolder}/${relativeFile}` .replace(/\\/g, "/") .replace(/^\/+/, ""); if (normalizedEpisode.videoPath !== rawVideoPath) { @@ -4554,7 +5063,7 @@ app.get("/api/tvshows", requireAuth, (req, res) => { } } } - normalizedEpisode.folder = folder; + normalizedEpisode.folder = rootFolder; const existingEpisode = seasonRecord.episodes.get(episodeNumber); seasonRecord.episodes.set( @@ -4622,7 +5131,8 @@ app.get("/api/tvshows", requireAuth, (req, res) => { status: record.status || null, poster: record.poster || null, backdrop: record.backdrop || null, - seasons + seasons, + folders: Array.from(record.folders) }; }) .filter((show) => show.seasons.length > 0); @@ -4674,6 +5184,7 @@ async function rebuildTvMetadata({ clearCache = false } = {}) { try { const info = readInfoForRoot(folder) || {}; const infoFiles = info.files || {}; + const filesUpdate = { ...infoFiles }; const detected = {}; const walkDir = async (currentDir, relativeBase = "") => { @@ -4708,7 +5219,7 @@ async function rebuildTvMetadata({ clearCache = false } = {}) { const normalizedRel = relPath.replace(/\\/g, "/"); let mediaInfo = - infoFiles?.[normalizedRel]?.mediaInfo || null; + filesUpdate?.[normalizedRel]?.mediaInfo || null; if (!mediaInfo) { try { mediaInfo = await extractMediaInfo(absPath); @@ -4745,6 +5256,40 @@ async function rebuildTvMetadata({ clearCache = false } = {}) { episodeId: ensured.episode.tvdbEpisodeId || null, slug: ensured.episode.slug || null }; + const statSize = (() => { + try { + return fs.statSync(absPath).size; + } catch (err) { + return filesUpdate?.[normalizedRel]?.size ?? null; + } + })(); + const fileEntry = { + ...(filesUpdate[normalizedRel] || {}), + size: statSize, + mediaInfo: mediaInfo || null, + seriesMatch: { + id: ensured.show.id || null, + title: ensured.show.title || seriesInfo.title, + season: ensured.season?.seasonNumber ?? seriesInfo.season, + episode: ensured.episode.episodeNumber ?? seriesInfo.episode, + code: ensured.episode.code || seriesInfo.key, + poster: ensured.show.poster || null, + backdrop: ensured.show.backdrop || null, + seasonPoster: ensured.season?.poster || null, + aired: ensured.episode.aired || null, + runtime: ensured.episode.runtime || null, + tvdbEpisodeId: ensured.episode.tvdbEpisodeId || null, + matchedAt: Date.now() + } + }; + filesUpdate[normalizedRel] = fileEntry; + } else { + const entry = { + ...(filesUpdate[normalizedRel] || {}), + mediaInfo: mediaInfo || null + }; + if (entry.seriesMatch) delete entry.seriesMatch; + filesUpdate[normalizedRel] = entry; } } catch (err) { console.warn( @@ -4758,11 +5303,30 @@ async function rebuildTvMetadata({ clearCache = false } = {}) { await walkDir(rootDir); + for (const key of Object.keys(filesUpdate)) { + if (detected[key]) continue; + if (filesUpdate[key]?.seriesMatch) { + delete filesUpdate[key].seriesMatch; + } + } + const episodeCount = Object.keys(detected).length; if (episodeCount > 0) { - upsertInfoFile(rootDir, { seriesEpisodes: detected }); + const update = { + seriesEpisodes: detected, + files: filesUpdate + }; + upsertInfoFile(rootDir, update); } else if (clearCache) { - upsertInfoFile(rootDir, { seriesEpisodes: {} }); + for (const key of Object.keys(filesUpdate)) { + if (filesUpdate[key]?.seriesMatch) { + delete filesUpdate[key].seriesMatch; + } + } + upsertInfoFile(rootDir, { + seriesEpisodes: {}, + files: filesUpdate + }); } processed.push({ @@ -5112,6 +5676,10 @@ app.post("/api/match/manual", requireAuth, async (req, res) => { } const rootDir = path.join(DOWNLOAD_DIR, rootFolder); + const relativeVideoPath = safePath + .split(/[\\/]/) + .slice(1) + .join("/"); const infoPath = infoFilePath(rootDir); // Mevcut info.json dosyasını oku @@ -5141,7 +5709,7 @@ app.post("/api/match/manual", requireAuth, async (req, res) => { } // Mevcut movie_data ve TV verilerini temizle - removeMovieData(rootFolder); + removeMovieData(rootFolder, relativeVideoPath); removeSeriesData(rootFolder); // TMDB'den detaylı bilgi al @@ -5172,14 +5740,14 @@ app.post("/api/match/manual", requireAuth, async (req, res) => { const movieDataResult = await ensureMovieData( rootFolder, metadata.title, - safePath.split('/').slice(1).join('/'), + relativeVideoPath, mediaInfo ); - - if (movieDataResult) { + + if (movieDataResult?.mediaInfo) { // info.json'u güncelle - eski verileri temizle - infoData.primaryVideoPath = safePath.split('/').slice(1).join('/'); - infoData.primaryMediaInfo = movieDataResult; + infoData.primaryVideoPath = relativeVideoPath; + infoData.primaryMediaInfo = movieDataResult.mediaInfo; infoData.movieMatch = { id: movieDetails.id, title: movieDetails.title, @@ -5188,10 +5756,27 @@ app.post("/api/match/manual", requireAuth, async (req, res) => { backdrop: movieDetails.backdrop_path, matchedAt: Date.now() }; - + + if (!infoData.files || typeof infoData.files !== "object") { + infoData.files = {}; + } + infoData.files[relativeVideoPath] = { + ...(infoData.files[relativeVideoPath] || {}), + mediaInfo: movieDataResult.mediaInfo, + movieMatch: { + id: movieDetails.id, + title: movieDetails.title, + year: movieDetails.release_date ? movieDetails.release_date.slice(0, 4) : null, + poster: movieDetails.poster_path, + backdrop: movieDetails.backdrop_path, + cacheKey: movieDataResult.cacheKey, + matchedAt: Date.now() + } + }; + // Eski dizi verilerini temizle delete infoData.seriesEpisodes; - + upsertInfoFile(rootDir, infoData); } } else if (type === "series") { @@ -5255,6 +5840,28 @@ app.post("/api/match/manual", requireAuth, async (req, res) => { slug: tvDataResult.episode.slug || null, matchedAt: Date.now() }; + if (!infoData.files || typeof infoData.files !== "object") { + infoData.files = {}; + } + const currentFileEntry = infoData.files[relPath] || {}; + infoData.files[relPath] = { + ...currentFileEntry, + mediaInfo: mediaInfo || currentFileEntry.mediaInfo || null, + seriesMatch: { + id: tvDataResult.show.id || null, + title: tvDataResult.show.title || seriesInfo.title, + season, + episode, + code: tvDataResult.episode.code || seriesInfo.key, + poster: tvDataResult.show.poster || null, + backdrop: tvDataResult.show.backdrop || null, + seasonPoster: tvDataResult.season?.poster || null, + aired: tvDataResult.episode.aired || null, + runtime: tvDataResult.episode.runtime || null, + tvdbEpisodeId: tvDataResult.episode.tvdbEpisodeId || null, + matchedAt: Date.now() + } + }; // Eski film verilerini temizle delete infoData.movieMatch;