Homelabtoolkit v2

This commit is contained in:
2026-06-08 21:58:16 +12:00
parent 040fbacc70
commit 3c77066beb
75 changed files with 16945 additions and 374 deletions
+69
View File
@@ -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]));
}
+123
View File
@@ -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));
}