Homelabtoolkit v2
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const CONFIG_PATH = resolve('config.json');
|
||||
|
||||
export function loadEmbyConfig() {
|
||||
if (!existsSync(CONFIG_PATH)) return {};
|
||||
try {
|
||||
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeEmbyGuid(value) {
|
||||
return typeof value === 'string' ? value.replace(/-/g, '').trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
export function buildEmbyUrl(pathname, params = {}) {
|
||||
const { embyUrl, apiKey } = loadEmbyConfig();
|
||||
if (!embyUrl || !apiKey) {
|
||||
throw new Error('Emby URL and API key not configured');
|
||||
}
|
||||
|
||||
const base = embyUrl.replace(/\/+$/, '');
|
||||
const path = pathname.startsWith('/') ? pathname : `/${pathname}`;
|
||||
const url = new URL(`${base}${path}`);
|
||||
|
||||
for (const [key, value] of Object.entries({
|
||||
...params,
|
||||
api_key: apiKey
|
||||
})) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
|
||||
return { url, apiKey };
|
||||
}
|
||||
|
||||
export async function fetchEmby(pathname, options = {}) {
|
||||
const {
|
||||
params = {},
|
||||
method = 'GET',
|
||||
headers = {},
|
||||
body
|
||||
} = options;
|
||||
const { url, apiKey } = buildEmbyUrl(pathname, params);
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Emby-Token': apiKey,
|
||||
...headers
|
||||
},
|
||||
body
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => response.statusText);
|
||||
throw new Error(text || `${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function fetchEmbyJson(pathname, options = {}) {
|
||||
const response = await fetchEmby(pathname, options);
|
||||
return response.json();
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
function getCacheDbPath() {
|
||||
return process.env.EMBY_USER_CACHE_DB_PATH || resolve('.cache', 'emby-users.db');
|
||||
}
|
||||
|
||||
function ensureCacheDir() {
|
||||
const dir = dirname(getCacheDbPath());
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function openCacheDb() {
|
||||
ensureCacheDir();
|
||||
const db = new DatabaseSync(getCacheDbPath());
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS EmbyUsers (
|
||||
embyGuid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
fetchedAt TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS CacheMeta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
return db;
|
||||
}
|
||||
|
||||
function normalizeGuid(value) {
|
||||
return typeof value === 'string' ? value.replace(/-/g, '').trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
export function normalizeEmbyUsers(users) {
|
||||
if (!Array.isArray(users)) return [];
|
||||
|
||||
return users
|
||||
.map((user) => ({
|
||||
embyGuid: normalizeGuid(user?.embyGuid ?? user?.Id),
|
||||
name: String(user?.name ?? user?.Name ?? '').trim()
|
||||
}))
|
||||
.filter((user) => user.embyGuid && user.name);
|
||||
}
|
||||
|
||||
export function writeCachedEmbyUsers(users) {
|
||||
const normalizedUsers = normalizeEmbyUsers(users);
|
||||
const fetchedAt = new Date().toISOString();
|
||||
const db = openCacheDb();
|
||||
|
||||
try {
|
||||
const clearStmt = db.prepare('DELETE FROM EmbyUsers');
|
||||
const insertStmt = db.prepare(
|
||||
'INSERT INTO EmbyUsers (embyGuid, name, fetchedAt) VALUES (?, ?, ?)'
|
||||
);
|
||||
const metaStmt = db.prepare(
|
||||
'INSERT INTO CacheMeta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
);
|
||||
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
clearStmt.run();
|
||||
for (const user of normalizedUsers) {
|
||||
insertStmt.run(user.embyGuid, user.name, fetchedAt);
|
||||
}
|
||||
metaStmt.run('lastSyncedAt', fetchedAt);
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
return { users: normalizedUsers, lastSyncedAt: fetchedAt };
|
||||
}
|
||||
|
||||
export function readCachedEmbyUsers() {
|
||||
if (!existsSync(getCacheDbPath())) {
|
||||
return { users: [], lastSyncedAt: null };
|
||||
}
|
||||
|
||||
const db = openCacheDb();
|
||||
try {
|
||||
const users = db
|
||||
.prepare('SELECT embyGuid, name, fetchedAt FROM EmbyUsers ORDER BY lower(name), embyGuid')
|
||||
.all()
|
||||
.map((row) => ({
|
||||
embyGuid: normalizeGuid(row.embyGuid),
|
||||
name: row.name
|
||||
}));
|
||||
const meta = db.prepare("SELECT value FROM CacheMeta WHERE key = 'lastSyncedAt'").get();
|
||||
|
||||
return {
|
||||
users,
|
||||
lastSyncedAt: meta?.value || users[0]?.fetchedAt || null
|
||||
};
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedEmbyUserMap() {
|
||||
return new Map(readCachedEmbyUsers().users.map((user) => [user.embyGuid, user]));
|
||||
}
|
||||
|
||||
export function applyCachedEmbyNames(users) {
|
||||
const cached = readCachedEmbyUsers();
|
||||
const lookup = new Map(cached.users.map((user) => [user.embyGuid, user]));
|
||||
let matchedCount = 0;
|
||||
|
||||
const hydratedUsers = (users || []).map((user) => {
|
||||
const embyGuid = normalizeGuid(user?.embyGuid);
|
||||
const cachedUser = lookup.get(embyGuid);
|
||||
if (!cachedUser) {
|
||||
return user;
|
||||
}
|
||||
|
||||
matchedCount++;
|
||||
return {
|
||||
...user,
|
||||
dbName: user.dbName || user.name,
|
||||
embyName: cachedUser.name,
|
||||
name: cachedUser.name
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
users: hydratedUsers,
|
||||
cache: {
|
||||
matchedCount,
|
||||
totalCachedUsers: lookup.size,
|
||||
lastSyncedAt: cached.lastSyncedAt
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { dirname, resolve } from 'path';
|
||||
|
||||
const CACHE_PATH = resolve('.cache', 'emby-user-context.json');
|
||||
|
||||
function ensureCacheDir() {
|
||||
const dir = dirname(CACHE_PATH);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGuid(value) {
|
||||
return typeof value === 'string' ? value.replace(/-/g, '').trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
function loadCacheFile() {
|
||||
if (!existsSync(CACHE_PATH)) {
|
||||
return { users: {} };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(CACHE_PATH, 'utf8'));
|
||||
return parsed && typeof parsed === 'object' ? parsed : { users: {} };
|
||||
} catch {
|
||||
return { users: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function saveCacheFile(cache) {
|
||||
ensureCacheDir();
|
||||
writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 2));
|
||||
}
|
||||
|
||||
export function readCachedEmbyUserContext(embyGuid) {
|
||||
const normalizedGuid = normalizeGuid(embyGuid);
|
||||
if (!normalizedGuid) return null;
|
||||
|
||||
const cache = loadCacheFile();
|
||||
return cache.users?.[normalizedGuid] || null;
|
||||
}
|
||||
|
||||
export function writeCachedEmbyUserContext(embyGuid, context) {
|
||||
const normalizedGuid = normalizeGuid(embyGuid);
|
||||
if (!normalizedGuid) return null;
|
||||
|
||||
const cache = loadCacheFile();
|
||||
const nextContext = {
|
||||
views: Array.isArray(context?.views) ? context.views : [],
|
||||
recentlyPlayed: Array.isArray(context?.recentlyPlayed) ? context.recentlyPlayed : [],
|
||||
excludedFolderLookup: context?.excludedFolderLookup || {},
|
||||
lastSyncedAt: context?.lastSyncedAt || new Date().toISOString()
|
||||
};
|
||||
|
||||
cache.users ||= {};
|
||||
cache.users[normalizedGuid] = nextContext;
|
||||
saveCacheFile(cache);
|
||||
return nextContext;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
function parseJsonBlob(blob) {
|
||||
if (!blob) return null;
|
||||
try {
|
||||
const text = typeof blob === 'string' ? blob : Buffer.from(blob).toString('utf8');
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emby stores GUIDs in SQLite as 16-byte blobs using Microsoft mixed-endian ordering.
|
||||
* Components 1-3 are little-endian; components 4-5 are big-endian.
|
||||
*/
|
||||
export function blobToEmbyGuid(blob) {
|
||||
if (!blob) return '';
|
||||
const b = blob instanceof Uint8Array ? blob : new Uint8Array(blob);
|
||||
if (b.length !== 16) return Buffer.from(b).toString('hex').toLowerCase();
|
||||
const out = new Uint8Array([
|
||||
b[3], b[2], b[1], b[0],
|
||||
b[5], b[4],
|
||||
b[7], b[6],
|
||||
b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]
|
||||
]);
|
||||
return Buffer.from(out).toString('hex').toLowerCase();
|
||||
}
|
||||
|
||||
function hasTable(db, tableName) {
|
||||
const row = db
|
||||
.prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(tableName);
|
||||
return !!row?.found;
|
||||
}
|
||||
|
||||
function normalizeSectionUserId(sectionUserId) {
|
||||
return typeof sectionUserId === 'string' ? sectionUserId.trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
export function normalizeSectionsForUser(sections, expectedEmbyGuid) {
|
||||
if (!Array.isArray(sections)) return [];
|
||||
if (!expectedEmbyGuid) return sections;
|
||||
|
||||
return sections.map((section) => ({
|
||||
...section,
|
||||
UserId: expectedEmbyGuid
|
||||
}));
|
||||
}
|
||||
|
||||
function loadUsersTableUsers(db) {
|
||||
const cols = db.prepare('PRAGMA table_info(Users)').all().map((c) => c.name);
|
||||
const nameCol = cols.find((c) => /^username$/i.test(c)) || cols.find((c) => /^name$/i.test(c));
|
||||
const guidCol = cols.find((c) => /^guid$/i.test(c));
|
||||
const idCol = cols.find((c) => /^id$/i.test(c)) || 'Id';
|
||||
|
||||
if (!nameCol) {
|
||||
throw new Error(`Cannot find a name column in Users table. Columns found: ${cols.join(', ')}`);
|
||||
}
|
||||
|
||||
const selectCols = [idCol, nameCol, guidCol].filter(Boolean).join(', ');
|
||||
const rows = db.prepare(`SELECT ${selectCols} FROM Users`).all();
|
||||
|
||||
return rows.map((row) => {
|
||||
const rawId = row[idCol] ?? row.Id ?? row.id;
|
||||
const rawGuid = guidCol ? row[guidCol] : null;
|
||||
let embyGuid = '';
|
||||
let guid = '';
|
||||
|
||||
if (rawGuid) {
|
||||
if (rawGuid instanceof Uint8Array || rawGuid instanceof Buffer) {
|
||||
const buf = Buffer.from(rawGuid);
|
||||
guid = buf.toString('hex').toUpperCase();
|
||||
embyGuid = blobToEmbyGuid(rawGuid);
|
||||
} else if (typeof rawGuid === 'string') {
|
||||
const clean = rawGuid.replace(/-/g, '').toLowerCase();
|
||||
embyGuid = clean;
|
||||
guid = clean.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: rawId,
|
||||
name: row[nameCol] || `User ${rawId}`,
|
||||
guid,
|
||||
embyGuid,
|
||||
sourceTable: 'Users'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function loadLocalUsers(db) {
|
||||
const rows = db.prepare('SELECT Id, guid, data FROM LocalUsersv2').all();
|
||||
|
||||
return rows.map((row) => {
|
||||
const parsed = parseJsonBlob(row.data);
|
||||
const guid = row.guid ? Buffer.from(row.guid).toString('hex').toUpperCase() : '';
|
||||
const embyGuidFromBlob = blobToEmbyGuid(row.guid);
|
||||
const embyGuidFromJson = normalizeSectionUserId(parsed?.IdString);
|
||||
const embyGuid = embyGuidFromJson || embyGuidFromBlob;
|
||||
|
||||
return {
|
||||
id: row.Id,
|
||||
name: parsed?.Name || `User ${row.Id}`,
|
||||
guid,
|
||||
embyGuid,
|
||||
sourceTable: 'LocalUsersv2',
|
||||
profile: parsed || null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function loadCanonicalUsers(db) {
|
||||
if (hasTable(db, 'LocalUsersv2')) {
|
||||
return loadLocalUsers(db);
|
||||
}
|
||||
if (hasTable(db, 'Users')) {
|
||||
return loadUsersTableUsers(db);
|
||||
}
|
||||
throw new Error('No supported user table found. Expected LocalUsersv2 or Users.');
|
||||
}
|
||||
|
||||
export function loadHomeScreenUsers(db) {
|
||||
const users = loadCanonicalUsers(db);
|
||||
const settingsRows = db
|
||||
.prepare(
|
||||
`SELECT us.UserId, us.Value
|
||||
FROM UserSettings us
|
||||
JOIN UserSettingsKeys usk ON us.UserSettingsKeyId = usk.UserSettingsKeyId
|
||||
WHERE usk.Name = 'homescreensettings'`
|
||||
)
|
||||
.all();
|
||||
|
||||
const settingsMap = new Map(settingsRows.map((row) => [String(row.UserId), row.Value]));
|
||||
const userIds = new Set(users.map((user) => String(user.id)));
|
||||
|
||||
let matchedUsers = 0;
|
||||
let mismatchedUsers = 0;
|
||||
let normalizedUsers = 0;
|
||||
let missingSectionUserIds = 0;
|
||||
|
||||
const hydratedUsers = users.map((user) => {
|
||||
const rawValue = settingsMap.get(String(user.id));
|
||||
let sections = [];
|
||||
|
||||
try {
|
||||
if (rawValue) sections = JSON.parse(rawValue).Sections || [];
|
||||
} catch {
|
||||
sections = [];
|
||||
}
|
||||
|
||||
const actualUserIds = [...new Set(sections.map((section) => normalizeSectionUserId(section?.UserId)).filter(Boolean))];
|
||||
const mismatchedSectionUserIds = user.embyGuid
|
||||
? actualUserIds.filter((id) => id !== user.embyGuid)
|
||||
: actualUserIds;
|
||||
const missingIdsForUser = sections.filter((section) => !normalizeSectionUserId(section?.UserId)).length;
|
||||
const normalizedSections = normalizeSectionsForUser(sections, user.embyGuid);
|
||||
const sectionsWereNormalized =
|
||||
user.embyGuid &&
|
||||
JSON.stringify(sections) !== JSON.stringify(normalizedSections);
|
||||
|
||||
if (mismatchedSectionUserIds.length === 0) matchedUsers++;
|
||||
else mismatchedUsers++;
|
||||
if (sectionsWereNormalized) normalizedUsers++;
|
||||
missingSectionUserIds += missingIdsForUser;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
guid: user.guid,
|
||||
embyGuid: user.embyGuid,
|
||||
sections: normalizedSections,
|
||||
details: {
|
||||
sourceTable: user.sourceTable,
|
||||
lastLoginDate: user.profile?.LastLoginDate || null,
|
||||
lastActivityDate: user.profile?.LastActivityDate || null,
|
||||
usesIdForConfigurationPath: user.profile?.UsesIdForConfigurationPath ?? null,
|
||||
importedCollectionsCount: Array.isArray(user.profile?.ImportedCollections) ? user.profile.ImportedCollections.length : 0
|
||||
},
|
||||
match: {
|
||||
sourceTable: user.sourceTable,
|
||||
settingsUserId: user.id,
|
||||
expectedSectionUserId: user.embyGuid,
|
||||
actualSectionUserIds: actualUserIds,
|
||||
mismatchedSectionUserIds,
|
||||
missingSectionUserIds: missingIdsForUser,
|
||||
ok: mismatchedSectionUserIds.length === 0
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const orphanedSettingsUserIds = settingsRows
|
||||
.map((row) => String(row.UserId))
|
||||
.filter((userId, index, all) => all.indexOf(userId) === index && !userIds.has(userId));
|
||||
|
||||
return {
|
||||
users: hydratedUsers,
|
||||
validation: {
|
||||
userSource: users[0]?.sourceTable || null,
|
||||
userCount: hydratedUsers.length,
|
||||
settingsCount: settingsRows.length,
|
||||
matchedUsers,
|
||||
mismatchedUsers,
|
||||
normalizedUsers,
|
||||
missingSectionUserIds,
|
||||
orphanedSettingsUserIds
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function loadUserLookup(db) {
|
||||
return new Map(loadCanonicalUsers(db).map((user) => [String(user.id), user]));
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const CONFIG_PATH = resolve('config.json');
|
||||
const TMDB_BASE = 'https://api.themoviedb.org/3';
|
||||
|
||||
function loadConfig() {
|
||||
if (!existsSync(CONFIG_PATH)) return {};
|
||||
try {
|
||||
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function loadTmdbConfig() {
|
||||
const config = loadConfig();
|
||||
return {
|
||||
tmdbApiKey: String(config?.tmdbApiKey || '').trim()
|
||||
};
|
||||
}
|
||||
|
||||
export function hasTmdbConfig() {
|
||||
return !!loadTmdbConfig().tmdbApiKey;
|
||||
}
|
||||
|
||||
async function fetchTmdb(pathname, params = {}) {
|
||||
const { tmdbApiKey } = loadTmdbConfig();
|
||||
if (!tmdbApiKey) {
|
||||
throw new Error('TMDB API key not configured');
|
||||
}
|
||||
|
||||
const url = new URL(`${TMDB_BASE}${pathname.startsWith('/') ? pathname : `/${pathname}`}`);
|
||||
for (const [key, value] of Object.entries({
|
||||
api_key: tmdbApiKey,
|
||||
language: 'en-US',
|
||||
include_adult: 'false',
|
||||
...params
|
||||
})) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => response.statusText);
|
||||
throw new Error(text || `${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function normalizeTmdbSearchItem(item, mediaType) {
|
||||
return {
|
||||
tmdbId: Number(item?.id || 0) || null,
|
||||
name: item?.title || item?.name || '',
|
||||
year: Number(String(item?.release_date || item?.first_air_date || '').slice(0, 4)) || null,
|
||||
mediaType,
|
||||
overview: item?.overview || '',
|
||||
genreIds: Array.isArray(item?.genre_ids) ? item.genre_ids : [],
|
||||
originalLanguage: item?.original_language || '',
|
||||
popularity: Number(item?.popularity || 0),
|
||||
voteAverage: Number(item?.vote_average || 0),
|
||||
voteCount: Number(item?.vote_count || 0)
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchTmdbByTitle({ mediaType, name, year }) {
|
||||
const searchType = mediaType === 'tv' ? 'tv' : 'movie';
|
||||
const payload = await fetchTmdb(`/search/${searchType}`, {
|
||||
query: name,
|
||||
page: 1,
|
||||
...(year ? (searchType === 'movie' ? { year } : { first_air_date_year: year }) : {})
|
||||
});
|
||||
|
||||
return (payload?.results || []).map((item) => normalizeTmdbSearchItem(item, searchType));
|
||||
}
|
||||
|
||||
export async function fetchTmdbSimilar({ mediaType, tmdbId, page = 1 }) {
|
||||
const pathType = mediaType === 'tv' ? 'tv' : 'movie';
|
||||
const payload = await fetchTmdb(`/${pathType}/${encodeURIComponent(tmdbId)}/similar`, { page });
|
||||
return (payload?.results || []).map((item) => normalizeTmdbSearchItem(item, pathType));
|
||||
}
|
||||
|
||||
export async function fetchTmdbRecommendations({ mediaType, tmdbId, page = 1 }) {
|
||||
const pathType = mediaType === 'tv' ? 'tv' : 'movie';
|
||||
const payload = await fetchTmdb(`/${pathType}/${encodeURIComponent(tmdbId)}/recommendations`, { page });
|
||||
return (payload?.results || []).map((item) => normalizeTmdbSearchItem(item, pathType));
|
||||
}
|
||||
|
||||
export async function fetchTmdbDetails({ mediaType, tmdbId }) {
|
||||
const pathType = mediaType === 'tv' ? 'tv' : 'movie';
|
||||
return fetchTmdb(`/${pathType}/${encodeURIComponent(tmdbId)}`);
|
||||
}
|
||||
|
||||
export async function fetchTmdbKeywords({ mediaType, tmdbId }) {
|
||||
const pathType = mediaType === 'tv' ? 'tv' : 'movie';
|
||||
const payload = await fetchTmdb(`/${pathType}/${encodeURIComponent(tmdbId)}/keywords`);
|
||||
return payload?.keywords || payload?.results || [];
|
||||
}
|
||||
|
||||
export async function fetchTmdbCredits({ mediaType, tmdbId }) {
|
||||
if (mediaType === 'tv') {
|
||||
return fetchTmdb(`/tv/${encodeURIComponent(tmdbId)}/aggregate_credits`);
|
||||
}
|
||||
|
||||
return fetchTmdb(`/movie/${encodeURIComponent(tmdbId)}/credits`);
|
||||
}
|
||||
|
||||
export async function fetchTmdbGenres(mediaType) {
|
||||
const pathType = mediaType === 'tv' ? 'tv' : 'movie';
|
||||
const payload = await fetchTmdb(`/genre/${pathType}/list`, { language: 'en' });
|
||||
return payload?.genres || [];
|
||||
}
|
||||
|
||||
export async function fetchTmdbDiscover({ mediaType, params = {} }) {
|
||||
const pathType = mediaType === 'tv' ? 'tv' : 'movie';
|
||||
const payload = await fetchTmdb(`/discover/${pathType}`, params);
|
||||
return (payload?.results || []).map((item) => normalizeTmdbSearchItem(item, pathType));
|
||||
}
|
||||
Reference in New Issue
Block a user