feat(tv): Kanonik TVDB anahtarları ve çoklu kök klasör desteği eklendi

Birden fazla kök klasör arasında veri birleştirmeyi sağlamak için TVDB kimliklerini kullanan TV dizileri için kanonik anahtar sistemi uygulandı.
Kullanıcı arayüzünde reaktif yükleme eklendi ve
eski yollardan otomatik geçişle meta veri yönetimi geliştirildi.

Önemli Değişiklikler:
- TV dizisi veri yapısı artık dizi başına birden fazla kök klasörü destekliyor
- Eski klasör anahtarları otomatik olarak kanonik TVDB anahtarlarına taşınıyor
- Veritabanı şeması, rootFolders dizisi için yeni indekslerle güncellendi
This commit is contained in:
2025-12-13 13:26:58 +03:00
parent 7ac71606e3
commit 485c3cfd94
3 changed files with 329 additions and 81 deletions

View File

@@ -15,8 +15,10 @@ import { restoreTorrentsFromDisk } from "./modules/state.js";
import { createWebsocketServer, broadcastJson } from "./modules/websocket.js";
import { connectMongo, getDb } from "./modules/db.js";
import {
canonicalTvdbKey,
getTvSeriesByKey as loadTvSeriesByKey,
listAllTvSeries as listAllTvSeriesFromDb,
listTvSeriesKeysForRoot as listTvSeriesKeysForRootDb,
removeTvSeriesByKey,
removeTvSeriesByRoot,
upsertTvSeries
@@ -2815,22 +2817,63 @@ 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;
// Eğer aynı tvdbId için kanonik kayıt varsa onu yükle (bölümler birleşsin)
if (!existingPaths && seriesId) {
const canonicalKey = canonicalTvdbKey(seriesId);
try {
const existingCanonical = await loadTvSeriesByKey(canonicalKey);
if (existingCanonical) {
seriesData = existingCanonical;
existingPaths = tvSeriesPathsByKey(canonicalKey);
}
} catch (err) {
console.warn(
`⚠️ TV metadata kanonik kayıt okunamadı (${canonicalKey}): ${err.message}`
);
}
}
// Her zaman kanonik anahtarı hedefle; legacy path varsa taşı
const canonicalPaths = buildTvSeriesPaths(
normalizedRoot,
seriesId,
seriesInfo.title
);
let targetPaths = canonicalPaths || existingPaths || null;
if (!targetPaths && existingPaths) {
targetPaths = existingPaths;
}
if (!targetPaths) return null;
// Legacy klasör -> kanonik klasöre taşı
if (
existingPaths &&
canonicalPaths &&
existingPaths.key !== canonicalPaths.key &&
fs.existsSync(existingPaths.dir)
) {
try {
if (!fs.existsSync(targetPaths.dir)) {
fs.mkdirSync(targetPaths.dir, { recursive: true });
}
const entries = fs.readdirSync(existingPaths.dir, { withFileTypes: true });
for (const entry of entries) {
const src = path.join(existingPaths.dir, entry.name);
const dest = path.join(targetPaths.dir, entry.name);
if (!fs.existsSync(dest)) {
fs.renameSync(src, dest);
}
}
fs.rmSync(existingPaths.dir, { recursive: true, force: true });
console.log(`🔀 TV metadata klasörü taşındı: ${existingPaths.key} -> ${targetPaths.key}`);
} catch (err) {
console.warn(
`⚠️ TV metadata klasörü taşınamadı (${existingPaths.key} -> ${targetPaths.key}): ${err.message}`
);
}
}
const showDir = targetPaths.dir;
const seriesMetaPath = targetPaths.metadata;
@@ -3169,6 +3212,7 @@ async function ensureSeriesData(
still: fs.existsSync(stillPath)
? encodeTvDataPath(targetPaths.key, path.relative(showDir, stillPath))
: null,
folder: normalizedRoot,
file: normalizedFile,
mediaInfo: mediaInfo || null,
tvdbEpisodeId: episodeTvdbId,
@@ -3181,6 +3225,47 @@ async function ensureSeriesData(
seasonContainer.episodeCount = Object.keys(seasonContainer.episodes).length;
seasonContainer.updatedAt = Date.now();
// Eski kayıt varsa sezon/bölüm verilerini koruyarak birleştir
try {
const existingDoc = await loadTvSeriesByKey(targetPaths.key);
const existingSeasons =
existingDoc && typeof existingDoc.seasons === "object"
? existingDoc.seasons
: {};
const incomingSeasons =
seriesData && typeof seriesData.seasons === "object"
? seriesData.seasons
: {};
const mergedSeasons = { ...existingSeasons };
// Önce mevcut sezonları kopyala, sonra gelen verileri ekle/override et
for (const [seasonKey, incomingSeason] of Object.entries(incomingSeasons)) {
const prevSeason = mergedSeasons[seasonKey] || {};
const prevEpisodes =
(prevSeason && typeof prevSeason.episodes === "object"
? prevSeason.episodes
: {}) || {};
const nextEpisodes = { ...prevEpisodes };
if (incomingSeason.episodes && typeof incomingSeason.episodes === "object") {
for (const [epKey, epVal] of Object.entries(incomingSeason.episodes)) {
nextEpisodes[epKey] = epVal;
}
}
mergedSeasons[seasonKey] = {
...prevSeason,
...incomingSeason,
episodes: nextEpisodes
};
}
seriesData.seasons = mergedSeasons;
} catch (err) {
console.warn(`⚠️ TV metadata merge başarısız (db - ${targetPaths.key}): ${err.message}`);
}
ensureDirForFile(seriesMetaPath);
try {
await upsertTvSeries(targetPaths.key, normalizedRoot, seriesData);
@@ -3234,12 +3319,16 @@ async function ensureSeriesData(
}
function tvSeriesKey(rootFolder, seriesId = null, fallbackTitle = null) {
const tvdbId = normalizeTvdbId(seriesId);
if (tvdbId !== null) {
return canonicalTvdbKey(tvdbId);
}
const normalizedRoot = rootFolder ? sanitizeRelative(rootFolder) : null;
if (!normalizedRoot) return null;
let suffix = null;
if (seriesId) {
suffix = String(seriesId).toLowerCase();
} else if (fallbackTitle) {
if (fallbackTitle) {
const slug = String(fallbackTitle)
.trim()
.toLowerCase()
@@ -3256,11 +3345,14 @@ function tvSeriesKey(rootFolder, seriesId = null, fallbackTitle = null) {
function parseTvSeriesKey(key) {
const normalized = sanitizeRelative(String(key || ""));
if (normalized.startsWith("tvdb-")) {
return { rootFolder: null, seriesId: normalized.slice(5), key: normalized, canonical: true };
}
if (!normalized.includes("__")) {
return { rootFolder: normalized, seriesId: null, key: normalized };
return { rootFolder: normalized, seriesId: null, key: normalized, canonical: false };
}
const [rootFolder, suffix] = normalized.split("__", 2);
return { rootFolder, seriesId: suffix || null, key: normalized };
return { rootFolder, seriesId: suffix || null, key: normalized, canonical: false };
}
function tvSeriesPathsByKey(key) {
@@ -3279,10 +3371,11 @@ function tvSeriesPathsByKey(key) {
}
function tvSeriesPaths(rootFolderOrKey, seriesId = null, fallbackTitle = null) {
const rawKey = String(rootFolderOrKey || "");
if (
seriesId === null &&
fallbackTitle === null &&
String(rootFolderOrKey || "").includes("__")
(rawKey.includes("__") || rawKey.startsWith("tvdb-"))
) {
return tvSeriesPathsByKey(rootFolderOrKey);
}
@@ -3317,9 +3410,19 @@ function seasonAssetPaths(paths, seasonNumber) {
};
}
function listTvSeriesKeysForRoot(rootFolder) {
async function listTvSeriesKeysForRoot(rootFolder) {
const normalizedRoot = rootFolder ? sanitizeRelative(rootFolder) : null;
if (!normalizedRoot) return [];
// Önce DB'den oku (rootFolders desteğiyle)
try {
const dbKeys = await listTvSeriesKeysForRootDb(normalizedRoot);
if (dbKeys?.length) return dbKeys;
} catch (err) {
console.warn(`⚠️ TV metadata anahtarları DB'den alınamadı: ${err.message}`);
}
// Legacy cache dizinlerini tara (geri uyumluluk)
if (!fs.existsSync(TV_DATA_ROOT)) return [];
const keys = [];
try {
@@ -3342,16 +3445,34 @@ function listTvSeriesKeysForRoot(rootFolder) {
return keys;
}
function removeSeriesData(rootFolder, seriesId = null) {
async function removeSeriesData(rootFolder, seriesId = null) {
const keys = seriesId
? [tvSeriesKey(rootFolder, seriesId)].filter(Boolean)
: listTvSeriesKeysForRoot(rootFolder);
: await listTvSeriesKeysForRoot(rootFolder);
if (!keys.length) return;
if (seriesId === null) {
await removeTvSeriesByRoot(rootFolder);
}
for (const key of keys) {
removeTvSeriesByKey(key).catch((err) =>
console.warn(`⚠️ TV metadata silinemedi (db - ${key}): ${err.message}`)
);
if (seriesId !== null) {
await removeTvSeriesByKey(key).catch((err) =>
console.warn(`⚠️ TV metadata silinemedi (db - ${key}): ${err.message}`)
);
}
const dir = tvSeriesDir(key);
if (dir && fs.existsSync(dir)) {
// Dizini ancak DB kaydı kalmadıysa sil
let keepDir = false;
try {
const stillExists = await loadTvSeriesByKey(key);
keepDir = Boolean(stillExists);
} catch {
/* no-op */
}
if (!keepDir && dir && fs.existsSync(dir)) {
try {
fs.rmSync(dir, { recursive: true, force: true });
console.log(`🧹 TV metadata silindi: ${dir}`);
@@ -3365,10 +3486,10 @@ function removeSeriesData(rootFolder, seriesId = null) {
function removeSeriesEpisode(rootFolder, relativeFilePath) {
if (!rootFolder || !relativeFilePath) return;
const keys = listTvSeriesKeysForRoot(rootFolder);
if (!keys.length) return;
(async () => {
const keys = await listTvSeriesKeysForRoot(rootFolder);
if (!keys.length) return;
for (const key of keys) {
const paths = tvSeriesPathsByKey(key);
@@ -3408,7 +3529,7 @@ function removeSeriesEpisode(rootFolder, relativeFilePath) {
if (!removed) continue;
if (!Object.keys(seasons).length) {
removeSeriesData(seriesData._dupe?.folder || rootFolder, seriesData.id);
await removeSeriesData(seriesData._dupe?.folder || rootFolder, seriesData.id);
continue;
}
@@ -3416,7 +3537,7 @@ function removeSeriesEpisode(rootFolder, relativeFilePath) {
seriesData.updatedAt = Date.now();
try {
await upsertTvSeries(key, paths.rootFolder, seriesData);
await upsertTvSeries(key, rootFolder, seriesData);
} catch (err) {
console.warn(
`⚠️ TV metadata güncellenemedi (db - ${key}): ${err.message}`
@@ -3457,7 +3578,9 @@ async function importLegacySeriesMetadata() {
try {
const data = JSON.parse(fs.readFileSync(paths.metadata, "utf-8")) || {};
await upsertTvSeries(key, paths.rootFolder, data);
const parsed = parseTvSeriesKey(key);
const rootForDoc = data?._dupe?.folder || parsed.rootFolder || null;
await upsertTvSeries(key, rootForDoc, data);
imported += 1;
try {
fs.rmSync(paths.metadata, { force: true });
@@ -3493,7 +3616,9 @@ function purgeRootFolder(rootFolder) {
removeAllThumbnailsForRoot(safe);
removeMovieData(safe);
removeSeriesData(safe);
removeSeriesData(safe).catch((err) =>
console.warn(`⚠️ TV metadata silinemedi (purge - ${safe}): ${err?.message || err}`)
);
const infoPath = path.join(DOWNLOAD_DIR, safe, INFO_FILENAME);
if (fs.existsSync(infoPath)) {
@@ -3814,10 +3939,10 @@ function renameSeriesDataPaths(rootFolder, oldRel, newRel) {
const newPrefix = normalizeTrashPath(newRel);
if (!oldPrefix || oldPrefix === newPrefix) return;
const keys = listTvSeriesKeysForRoot(rootFolder);
if (!keys.length) return;
(async () => {
const keys = await listTvSeriesKeysForRoot(rootFolder);
if (!keys.length) return;
for (const key of keys) {
const paths = tvSeriesPathsByKey(key);
let seriesData;
@@ -3869,7 +3994,7 @@ function renameSeriesDataPaths(rootFolder, oldRel, newRel) {
if (changed) {
try {
await upsertTvSeries(key, paths.rootFolder, seriesData);
await upsertTvSeries(key, rootFolder, seriesData);
} catch (err) {
console.warn(
`⚠️ TV metadata güncellenemedi (db - ${key}): ${err.message}`
@@ -5686,7 +5811,7 @@ async function rebuildMovieMetadata({ clearCache = false, resetSeriesData = fals
if (!videoEntries.length) {
removeMovieData(folder);
if (resetSeriesData) {
removeSeriesData(folder);
await removeSeriesData(folder);
}
const update = {
primaryVideoPath: null,
@@ -5713,7 +5838,7 @@ async function rebuildMovieMetadata({ clearCache = false, resetSeriesData = fals
removeMovieData(folder);
if (resetSeriesData) {
removeSeriesData(folder);
await removeSeriesData(folder);
}
const matches = [];
@@ -5922,37 +6047,55 @@ app.get("/api/tvshows", requireAuth, async (req, res) => {
if (!key) continue;
const paths = tvSeriesPathsByKey(key);
const parsed = parseTvSeriesKey(key);
const rootFolder = parsed.rootFolder || doc.rootFolder;
if (!rootFolder) continue;
const infoForFolder = readInfoForRoot(rootFolder) || {};
const infoFiles = infoForFolder.files || {};
const infoEpisodes = infoForFolder.seriesEpisodes || {};
const docFolders = Array.isArray(doc.rootFolders)
? doc.rootFolders.filter(Boolean)
: [];
const primaryFolder =
doc.rootFolder ||
parsed.rootFolder ||
docFolders[0] ||
null;
if (!primaryFolder) continue;
const rootFolder = primaryFolder;
const folderSet = new Set([rootFolder, ...docFolders]);
const infoEpisodeIndex = new Map();
const infoFilesByFolder = new Map();
for (const [relPath, meta] of Object.entries(infoEpisodes)) {
if (!meta) continue;
const seasonNumber = toFiniteNumber(
meta.season ?? meta.seasonNumber ?? meta.seasonNum
);
const episodeNumber = toFiniteNumber(
meta.episode ?? meta.episodeNumber ?? meta.episodeNum
);
if (!Number.isFinite(seasonNumber) || !Number.isFinite(episodeNumber)) continue;
for (const folderName of folderSet) {
const infoForFolder = readInfoForRoot(folderName) || {};
const infoFiles = infoForFolder.files || {};
const infoEpisodes = infoForFolder.seriesEpisodes || {};
infoFilesByFolder.set(folderName, infoFiles);
const normalizedRel = normalizeTrashPath(relPath);
const ext = path.extname(normalizedRel).toLowerCase();
if (!VIDEO_EXTS.includes(ext)) continue;
for (const [relPath, meta] of Object.entries(infoEpisodes)) {
if (!meta) continue;
const seasonNumber = toFiniteNumber(
meta.season ?? meta.seasonNumber ?? meta.seasonNum
);
const episodeNumber = toFiniteNumber(
meta.episode ?? meta.episodeNumber ?? meta.episodeNum
);
if (!Number.isFinite(seasonNumber) || !Number.isFinite(episodeNumber))
continue;
const absVideo = normalizedRel
? path.join(DOWNLOAD_DIR, rootFolder, normalizedRel)
: null;
if (!absVideo || !fs.existsSync(absVideo)) continue;
const normalizedRel = normalizeTrashPath(relPath);
const ext = path.extname(normalizedRel).toLowerCase();
if (!VIDEO_EXTS.includes(ext)) continue;
infoEpisodeIndex.set(`${seasonNumber}-${episodeNumber}`, {
relPath: normalizedRel,
meta
});
const absVideo = normalizedRel
? path.join(DOWNLOAD_DIR, folderName, normalizedRel)
: null;
if (!absVideo || !fs.existsSync(absVideo)) continue;
infoEpisodeIndex.set(`${folderName}:${seasonNumber}-${episodeNumber}`, {
relPath: normalizedRel,
meta,
folder: folderName
});
}
}
let seriesData = doc.data;
@@ -5963,7 +6106,7 @@ app.get("/api/tvshows", requireAuth, async (req, res) => {
const seasonsObj = seriesData?.seasons || {};
if (!Object.keys(seasonsObj).length) {
removeSeriesData(rootFolder, seriesData.id ?? seriesData.tvdbId ?? null);
await removeSeriesData(rootFolder, seriesData.id ?? seriesData.tvdbId ?? null);
continue;
}
@@ -5998,13 +6141,14 @@ app.get("/api/tvshows", requireAuth, async (req, res) => {
),
seasons: new Map(),
primaryFolder: rootFolder,
folders: new Set([rootFolder])
folders: new Set([rootFolder, ...docFolders])
};
aggregated.set(showKey, base);
return base;
})();
record.folders.add(rootFolder);
for (const extra of docFolders) record.folders.add(extra);
if (
seriesData.overview &&
seriesData.overview.length > (record.overview?.length || 0)
@@ -6043,11 +6187,12 @@ app.get("/api/tvshows", requireAuth, async (req, res) => {
const rawEpisodes = rawSeason.episodes || {};
for (const [episodeKey, rawEpisode] of Object.entries(rawEpisodes)) {
if (!rawEpisode || typeof rawEpisode !== "object") continue;
const episodeRootForCleanup = rawEpisode.folder || rootFolder;
const relativeFile = (rawEpisode.file || "").replace(/\\/g, "/");
if (relativeFile) {
const absEpisodePath = path.join(
DOWNLOAD_DIR,
rootFolder,
episodeRootForCleanup,
relativeFile
);
if (!fs.existsSync(absEpisodePath)) {
@@ -6116,13 +6261,16 @@ app.get("/api/tvshows", requireAuth, async (req, res) => {
"0"
)}E${String(episodeNumber).padStart(2, "0")}`;
}
const infoEpisode = infoEpisodeIndex.get(`${seasonNumber}-${episodeNumber}`);
const episodeRoot = rawEpisode.folder || rootFolder;
const infoEpisode = infoEpisodeIndex.get(`${episodeRoot}:${seasonNumber}-${episodeNumber}`);
if (infoEpisode?.relPath) {
const normalizedRel = infoEpisode.relPath.replace(/^\/+/, "");
const withRoot = `${rootFolder}/${normalizedRel}`.replace(/^\/+/, "");
const withRoot = `${episodeRoot}/${normalizedRel}`.replace(/^\/+/, "");
normalizedEpisode.file = normalizedRel;
normalizedEpisode.videoPath = withRoot;
const fileMeta = infoFiles[normalizedRel];
const fileMeta = (infoFilesByFolder.get(episodeRoot) || {})[
normalizedRel
];
if (fileMeta?.mediaInfo && !normalizedEpisode.mediaInfo) {
normalizedEpisode.mediaInfo = fileMeta.mediaInfo;
}
@@ -6138,10 +6286,10 @@ app.get("/api/tvshows", requireAuth, async (req, res) => {
const isExternal = /^https?:\/\//i.test(videoPath);
const needsFolderPrefix =
!isExternal &&
!videoPath.startsWith(`${rootFolder}/`) &&
!videoPath.startsWith(`/${rootFolder}/`);
!videoPath.startsWith(`${episodeRoot}/`) &&
!videoPath.startsWith(`/${episodeRoot}/`);
if (needsFolderPrefix) {
videoPath = `${rootFolder}/${videoPath}`.replace(/\\/g, "/");
videoPath = `${episodeRoot}/${videoPath}`.replace(/\\/g, "/");
}
const finalPath = videoPath.replace(/^\/+/, "");
if (finalPath !== rawVideoPath) {
@@ -6149,7 +6297,7 @@ app.get("/api/tvshows", requireAuth, async (req, res) => {
}
normalizedEpisode.videoPath = finalPath;
} else if (relativeFile) {
normalizedEpisode.videoPath = `${rootFolder}/${relativeFile}`
normalizedEpisode.videoPath = `${episodeRoot}/${relativeFile}`
.replace(/\\/g, "/")
.replace(/^\/+/, "");
if (normalizedEpisode.videoPath !== rawVideoPath) {
@@ -6178,7 +6326,7 @@ app.get("/api/tvshows", requireAuth, async (req, res) => {
}
}
}
normalizedEpisode.folder = rootFolder;
normalizedEpisode.folder = episodeRoot;
const existingEpisode = seasonRecord.episodes.get(episodeNumber);
seasonRecord.episodes.set(
@@ -6981,7 +7129,7 @@ app.post("/api/match/manual", requireAuth, async (req, res) => {
// Mevcut movie_data ve TV verilerini temizle
removeMovieData(rootFolder, relativeVideoPath);
removeSeriesData(rootFolder);
await removeSeriesData(rootFolder);
// TMDB'den detaylı bilgi al
const movieDetails = await tmdbFetch(`/movie/${movieId}`, {
@@ -7063,7 +7211,7 @@ app.post("/api/match/manual", requireAuth, async (req, res) => {
// Mevcut movie_data ve TV verilerini temizle
removeMovieData(rootFolder);
removeSeriesData(rootFolder);
await removeSeriesData(rootFolder);
// TVDB'den dizi bilgilerini al
const extended = await fetchTvdbSeriesExtended(seriesId);