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
+30
View File
@@ -0,0 +1,30 @@
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
import { applyCachedEmbyNames } from '../lib/server/emby-user-cache.js';
/** @type {import('./$types').PageServerLoad} */
export async function load() {
const dataPath = resolve('static/db_export.json');
const raw = readFileSync(dataPath, 'utf-8');
const data = JSON.parse(raw);
const enrichedUsers = applyCachedEmbyNames(data.users);
const configPath = resolve('config.json');
let config = { embyUrl: '', apiKey: '', tmdbApiKey: '', dbPath: '' };
if (existsSync(configPath)) {
try {
config = {
...config,
...JSON.parse(readFileSync(configPath, 'utf-8'))
};
} catch { /* use defaults */ }
}
return {
users: enrichedUsers.users,
genres: data.genres,
enums: data.enums,
config,
embyCache: enrichedUsers.cache
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { resolve } from 'path';
import { json } from '@sveltejs/kit';
const CONFIG_PATH = resolve('config.json');
const DEFAULT_CONFIG = { embyUrl: '', apiKey: '', tmdbApiKey: '', dbPath: '' };
function loadConfig() {
if (!existsSync(CONFIG_PATH)) return DEFAULT_CONFIG;
try {
return {
...DEFAULT_CONFIG,
...JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'))
};
} catch {
return DEFAULT_CONFIG;
}
}
export async function GET() {
return json(loadConfig());
}
export async function POST({ request }) {
const body = await request.json();
const config = {
embyUrl: String(body.embyUrl || '').trim(),
apiKey: String(body.apiKey || '').trim(),
tmdbApiKey: String(body.tmdbApiKey || '').trim(),
dbPath: String(body.dbPath || '').trim()
};
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
return json({ ok: true });
}
@@ -0,0 +1,32 @@
import { json, error } from '@sveltejs/kit';
import { existsSync } from 'fs';
import { DatabaseSync } from 'node:sqlite';
import { loadHomeScreenUsers } from '../../../lib/server/emby-user-db.js';
import { applyCachedEmbyNames } from '../../../lib/server/emby-user-cache.js';
export async function POST({ request }) {
const { dbPath } = await request.json();
if (!dbPath) throw error(400, 'No dbPath provided');
if (!existsSync(dbPath)) throw error(404, `Database file not found: ${dbPath}`);
let db;
try {
db = new DatabaseSync(dbPath, { readOnly: true });
const result = loadHomeScreenUsers(db);
const enriched = applyCachedEmbyNames(result.users);
db.close();
return json({
...result,
users: enriched.users,
validation: {
...result.validation,
embyCacheMatchedUsers: enriched.cache.matchedCount,
embyCacheUserCount: enriched.cache.totalCachedUsers,
embyCacheLastSyncedAt: enriched.cache.lastSyncedAt
}
});
} catch (err) {
if (db) try { db.close(); } catch { /* ignore */ }
throw error(500, err.message);
}
}
@@ -0,0 +1,72 @@
import { json, error } from '@sveltejs/kit';
import { existsSync } from 'fs';
import { DatabaseSync } from 'node:sqlite';
import { loadUserLookup, normalizeSectionsForUser } from '../../../lib/server/emby-user-db.js';
export async function POST({ request }) {
const { dbPath, changes } = await request.json();
if (!dbPath) throw error(400, 'No dbPath provided');
if (!existsSync(dbPath)) throw error(404, `Database file not found: ${dbPath}`);
if (!changes?.length) return json({ ok: true, count: 0 });
let db;
try {
db = new DatabaseSync(dbPath);
const userLookup = loadUserLookup(db);
const keyRow = db
.prepare("SELECT UserSettingsKeyId FROM UserSettingsKeys WHERE Name = 'homescreensettings'")
.get();
if (!keyRow) throw new Error("'homescreensettings' key not found in UserSettingsKeys table");
const keyId = keyRow.UserSettingsKeyId;
const checkStmt = db.prepare(
'SELECT 1 FROM UserSettings WHERE UserId = ? AND UserSettingsKeyId = ?'
);
const updateStmt = db.prepare(
'UPDATE UserSettings SET Value = ? WHERE UserId = ? AND UserSettingsKeyId = ?'
);
const insertStmt = db.prepare(
'INSERT INTO UserSettings (UserId, UserSettingsKeyId, Value) VALUES (?, ?, ?)'
);
let count = 0;
let normalizedSections = 0;
// node:sqlite transactions: use db.exec('BEGIN') / db.exec('COMMIT') manually
// or wrap in a function with db.transaction() if supported
db.exec('BEGIN');
try {
for (const { userId, sections } of changes) {
const user = userLookup.get(String(userId));
if (!user) {
throw new Error(`UserId ${userId} does not exist in ${dbPath}`);
}
const nextSections = normalizeSectionsForUser(sections, user.embyGuid);
if (JSON.stringify(nextSections) !== JSON.stringify(sections)) {
normalizedSections += nextSections.length;
}
const value = JSON.stringify({ Sections: nextSections });
const exists = checkStmt.get(userId, keyId);
if (exists) {
updateStmt.run(value, userId, keyId);
} else {
insertStmt.run(userId, keyId, value);
}
count++;
}
db.exec('COMMIT');
} catch (err) {
db.exec('ROLLBACK');
throw err;
}
db.close();
return json({ ok: true, count, normalizedSections });
} catch (err) {
if (db) try { db.close(); } catch { /* ignore */ }
throw error(500, err.message);
}
}
@@ -0,0 +1,695 @@
import { error, json } from '@sveltejs/kit';
import {
RECOMMENDATION_PROFILES,
rankRecommendationResults,
normalizeLookupItem
} from '../../../lib/collection-tools.js';
import { fetchEmby, fetchEmbyJson, normalizeEmbyGuid } from '../../../lib/server/emby-api.js';
import {
fetchTmdbCredits,
fetchTmdbDetails,
fetchTmdbDiscover,
fetchTmdbGenres,
fetchTmdbKeywords,
fetchTmdbRecommendations,
fetchTmdbSimilar,
searchTmdbByTitle
} from '../../../lib/server/tmdb-api.js';
const EMBY_PAGE_SIZE = 200;
function normalizeTitle(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '');
}
function normalizeYear(value) {
const year = Number(value || 0);
return Number.isFinite(year) && year > 0 ? year : null;
}
function titleYearKey(name, year) {
return `${normalizeTitle(name)}::${normalizeYear(year) || ''}`;
}
function normalizeCollectionName(name) {
return String(name || '')
.toLowerCase()
.replace(/[']/g, '')
.replace(/[^a-z0-9]+/g, '');
}
function getSeedTmdbId(item) {
const providerIds = item?.providerIds || {};
return String(providerIds.Tmdb || providerIds.TMDB || providerIds.tmdb || '').trim();
}
function getTmdbMediaType(item) {
return item?.type === 'Series' ? 'tv' : 'movie';
}
function summarizeItems(items, limit = 5) {
return (items || []).slice(0, limit).map((item) => ({
name: item?.name || item?.title || item?.Name || '',
year: item?.year || item?.ProductionYear || null,
id: item?.id || item?.Id || '',
type: item?.type || item?.Type || ''
}));
}
function filterEnglishCandidates(items) {
return (items || []).filter((item) => !item?.originalLanguage || item.originalLanguage === 'en');
}
async function fetchAllUserItems(userId, params) {
const allItems = [];
let startIndex = 0;
while (true) {
const payload = await fetchEmbyJson(`/Users/${encodeURIComponent(userId)}/Items`, {
params: {
Recursive: true,
GroupItemsIntoCollections: false,
Limit: EMBY_PAGE_SIZE,
StartIndex: startIndex,
...params
}
});
const pageItems = (payload.Items || payload || []).map(normalizeLookupItem);
allItems.push(...pageItems);
const total = Number(payload.TotalRecordCount || 0);
if (!pageItems.length) break;
if (total > 0 && allItems.length >= total) break;
if (pageItems.length < EMBY_PAGE_SIZE) break;
startIndex += pageItems.length;
}
return allItems;
}
async function fetchAllLibraryItems(userId) {
return fetchAllUserItems(userId, {
IncludeItemTypes: 'Movie,Series',
Fields: 'Overview,Genres,CommunityRating,ProductionYear,ProviderIds'
});
}
async function fetchAllPlayedItems(userId) {
return fetchAllUserItems(userId, {
Filters: 'IsPlayed',
IncludeItemTypes: 'Movie,Series',
SortBy: 'DatePlayed',
SortOrder: 'Descending',
Fields: 'Overview,Genres,CommunityRating,ProductionYear,ProviderIds,UserData'
});
}
function uniqueById(items) {
const seen = new Set();
const unique = [];
for (const item of items || []) {
if (!item?.id || seen.has(item.id)) continue;
seen.add(item.id);
unique.push(item);
}
return unique;
}
function buildLibraryIndex(items) {
const byId = new Map();
const byTmdbId = new Map();
const byTitleYear = new Map();
const byTitle = new Map();
for (const item of items || []) {
if (!item?.id) continue;
byId.set(item.id, item);
const tmdbId = getSeedTmdbId(item);
if (tmdbId && !byTmdbId.has(tmdbId)) {
byTmdbId.set(tmdbId, item);
}
const titleKey = titleYearKey(item.name, item.year);
if (normalizeTitle(item.name) && !byTitleYear.has(titleKey)) {
byTitleYear.set(titleKey, item);
}
const normalizedName = normalizeTitle(item.name);
if (normalizedName && !byTitle.has(normalizedName)) {
byTitle.set(normalizedName, item);
}
}
return { byId, byTmdbId, byTitleYear, byTitle };
}
async function fetchSeedItems(userId, ids, libraryIndex) {
const fromLibrary = ids
.map((id) => libraryIndex.byId.get(String(id)))
.filter(Boolean);
if (fromLibrary.length === ids.length) {
return fromLibrary;
}
const payload = await fetchEmbyJson(`/Users/${encodeURIComponent(userId)}/Items`, {
params: {
Ids: ids.join(','),
Limit: ids.length,
Fields: 'Overview,Genres,CommunityRating,ProductionYear,ProviderIds',
GroupItemsIntoCollections: false
}
});
return uniqueById((payload.Items || payload || []).map(normalizeLookupItem));
}
async function findExistingCollection(userId, name) {
const trimmedName = String(name || '').trim();
if (!userId || !trimmedName) return null;
const payload = await fetchEmbyJson(`/Users/${encodeURIComponent(userId)}/Items`, {
params: {
Recursive: true,
SearchTerm: trimmedName,
Limit: 20,
SortBy: 'SortName',
SortOrder: 'Ascending',
Fields: 'Overview',
IncludeItemTypes: 'BoxSet',
GroupItemsIntoCollections: false
}
});
const normalizedTarget = normalizeCollectionName(trimmedName);
const exact = (payload.Items || payload || [])
.map(normalizeLookupItem)
.find((item) => item.type === 'BoxSet' && normalizeCollectionName(item.name) === normalizedTarget);
return exact || null;
}
async function resolveTmdbMatch(item) {
const directTmdbId = getSeedTmdbId(item);
if (directTmdbId) {
return {
tmdbId: Number(directTmdbId),
mediaType: getTmdbMediaType(item)
};
}
const matches = await searchTmdbByTitle({
mediaType: getTmdbMediaType(item),
name: item.name,
year: item.year
});
const best = matches[0];
if (!best?.tmdbId) return null;
return {
tmdbId: best.tmdbId,
mediaType: best.mediaType
};
}
function resolveLocalCandidate(index, candidate) {
const tmdbId = String(candidate?.tmdbId || '').trim();
if (tmdbId && index.byTmdbId.has(tmdbId)) {
return index.byTmdbId.get(tmdbId);
}
const exactTitleYear = index.byTitleYear.get(titleYearKey(candidate?.name, candidate?.year));
if (exactTitleYear) {
return exactTitleYear;
}
return index.byTitle.get(normalizeTitle(candidate?.name)) || null;
}
function addWeight(map, key, amount = 1) {
if (key === null || key === undefined || key === '') return;
map.set(key, (map.get(key) || 0) + amount);
}
function sortWeightedEntries(map, limit) {
return [...map.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, limit);
}
function getGenreLookup(genres) {
return new Map((genres || []).map((genre) => [normalizeTitle(genre.name), Number(genre.id)]));
}
function resolveGenreIdsFromLocalItems(items, genreLookup, weight, targetMap) {
for (const item of items || []) {
for (const genreName of item.genres || []) {
const id = genreLookup.get(normalizeTitle(genreName));
if (id) addWeight(targetMap, id, weight);
}
}
}
function normalizeKeywordIds(keywords) {
return (keywords || []).map((keyword) => Number(keyword?.id || 0)).filter(Boolean);
}
function extractPeopleIds(credits, mediaType) {
const ids = new Set();
for (const person of (credits?.cast || []).slice(0, 5)) {
if (person?.id) ids.add(Number(person.id));
}
if (mediaType === 'movie') {
for (const crew of credits?.crew || []) {
if (!crew?.id) continue;
if (crew.job === 'Director' || crew.job === 'Writer' || crew.job === 'Screenplay') {
ids.add(Number(crew.id));
}
if (ids.size >= 8) break;
}
}
return [...ids];
}
function buildDiscoverQueries({ mediaType, genreIds, keywordIds, peopleIds }) {
const base = {
page: 1,
sort_by: 'popularity.desc',
'vote_count.gte': mediaType === 'tv' ? 10 : 25,
with_original_language: 'en'
};
const queries = [];
if (genreIds.length) {
queries.push({
label: `${mediaType}-genres`,
params: {
...base,
with_genres: genreIds.slice(0, 4).join('|')
}
});
}
if (keywordIds.length) {
queries.push({
label: `${mediaType}-keywords`,
params: {
...base,
with_keywords: keywordIds.slice(0, 6).join('|')
}
});
}
if (genreIds.length && keywordIds.length) {
queries.push({
label: `${mediaType}-genres-keywords`,
params: {
...base,
with_genres: genreIds.slice(0, 3).join('|'),
with_keywords: keywordIds.slice(0, 4).join('|')
}
});
}
if (mediaType === 'movie' && peopleIds.length) {
queries.push({
label: 'movie-people',
params: {
...base,
with_people: peopleIds.slice(0, 5).join('|')
}
});
}
return queries;
}
function inspectCandidateSets(seedIds, resultSets) {
const excluded = new Set((seedIds || []).map((id) => String(id)));
const uniqueIncluded = new Map();
const uniqueExcluded = new Map();
for (const resultSet of resultSets || []) {
const items = Array.isArray(resultSet) ? resultSet : (Array.isArray(resultSet?.items) ? resultSet.items : []);
for (const item of items) {
const normalized = normalizeLookupItem(item);
if (!normalized.id) continue;
if (excluded.has(normalized.id)) {
if (!uniqueExcluded.has(normalized.id)) uniqueExcluded.set(normalized.id, normalized);
continue;
}
if (!uniqueIncluded.has(normalized.id)) uniqueIncluded.set(normalized.id, normalized);
}
}
return {
uniqueCandidateCount: uniqueIncluded.size,
excludedSeedCandidateCount: uniqueExcluded.size,
sampleExcludedSeedCandidates: summarizeItems([...uniqueExcluded.values()])
};
}
async function buildSeedContext(seed, libraryIndex) {
const diagnostic = {
seedId: seed.id,
seedName: seed.name,
seedType: seed.type,
seedYear: seed.year,
providerTmdbId: getSeedTmdbId(seed) || null,
tmdbMatch: null,
similarCount: 0,
recommendationCount: 0,
localSimilarCount: 0,
localRecommendationCount: 0,
error: null
};
try {
const tmdbMatch = await resolveTmdbMatch(seed);
if (!tmdbMatch?.tmdbId) {
diagnostic.error = 'No TMDB match resolved for seed';
return { seed, diagnostic, resultSets: [], details: null, keywords: [], peopleIds: [] };
}
diagnostic.tmdbMatch = tmdbMatch;
const [details, keywords, credits, similar, recommendations] = await Promise.all([
fetchTmdbDetails(tmdbMatch),
fetchTmdbKeywords(tmdbMatch),
fetchTmdbCredits(tmdbMatch),
fetchTmdbSimilar({ ...tmdbMatch, page: 1 }),
fetchTmdbRecommendations({ ...tmdbMatch, page: 1 })
]);
const englishSimilar = filterEnglishCandidates(similar);
const englishRecommendations = filterEnglishCandidates(recommendations);
const localSimilar = uniqueById(englishSimilar.map((candidate) => resolveLocalCandidate(libraryIndex, candidate)).filter(Boolean));
const localRecommendations = uniqueById(
englishRecommendations.map((candidate) => resolveLocalCandidate(libraryIndex, candidate)).filter(Boolean)
);
diagnostic.similarCount = englishSimilar.length;
diagnostic.recommendationCount = englishRecommendations.length;
diagnostic.localSimilarCount = localSimilar.length;
diagnostic.localRecommendationCount = localRecommendations.length;
return {
seed,
details,
keywords,
peopleIds: extractPeopleIds(credits, tmdbMatch.mediaType),
diagnostic,
resultSets: [
{ items: localSimilar, sourceWeight: 5, label: 'tmdb-similar' },
{ items: localRecommendations, sourceWeight: 6, label: 'tmdb-recommendations' }
]
};
} catch (err) {
diagnostic.error = err.message;
return { seed, diagnostic, resultSets: [], details: null, keywords: [], peopleIds: [] };
}
}
async function buildPreview(userId, seedIds, limit, profile) {
const normalizedSeedIds = [...new Set(seedIds.map((id) => String(id).trim()).filter(Boolean))];
const libraryItems = uniqueById(await fetchAllLibraryItems(userId));
const playedItems = uniqueById(await fetchAllPlayedItems(userId));
const excludedIds = [...new Set([...normalizedSeedIds, ...playedItems.map((item) => item.id).filter(Boolean)])];
const libraryIndex = buildLibraryIndex(libraryItems);
const seeds = await fetchSeedItems(userId, normalizedSeedIds, libraryIndex);
const allowedTypes = [...new Set(seeds.map((item) => item.type).filter(Boolean))];
const includeMovies = allowedTypes.includes('Movie');
const includeSeries = allowedTypes.includes('Series');
if (!seeds.length) {
return {
seeds: [],
recommendations: [],
diagnostics: {
libraryItemCount: libraryItems.length,
watchedItemCount: playedItems.length,
seedCount: 0,
embyCandidates: 0,
tmdbEnabled: true,
tmdbCandidates: 0,
tmdbResolved: 0,
uniqueCandidateCount: 0,
excludedSeedCandidateCount: 0,
sampleExcludedSeedCandidates: [],
perSeed: [],
discoverQueries: []
}
};
}
const seedContexts = await Promise.all(seeds.map((seed) => buildSeedContext(seed, libraryIndex)));
const movieGenres = await fetchTmdbGenres('movie');
const tvGenres = await fetchTmdbGenres('tv');
const movieGenreLookup = getGenreLookup(movieGenres);
const tvGenreLookup = getGenreLookup(tvGenres);
const movieGenreWeights = new Map();
const tvGenreWeights = new Map();
const movieKeywordWeights = new Map();
const tvKeywordWeights = new Map();
const moviePeopleWeights = new Map();
for (const context of seedContexts) {
const mediaType = getTmdbMediaType(context.seed);
const genreTarget = mediaType === 'tv' ? tvGenreWeights : movieGenreWeights;
const keywordTarget = mediaType === 'tv' ? tvKeywordWeights : movieKeywordWeights;
for (const genre of context.details?.genres || []) {
addWeight(genreTarget, Number(genre.id), 5);
}
for (const keywordId of normalizeKeywordIds(context.keywords)) {
addWeight(keywordTarget, keywordId, 4);
}
if (mediaType === 'movie') {
for (const personId of context.peopleIds || []) {
addWeight(moviePeopleWeights, personId, 3);
}
}
}
resolveGenreIdsFromLocalItems(seeds.filter((item) => getTmdbMediaType(item) === 'movie'), movieGenreLookup, 3, movieGenreWeights);
resolveGenreIdsFromLocalItems(seeds.filter((item) => getTmdbMediaType(item) === 'tv'), tvGenreLookup, 3, tvGenreWeights);
resolveGenreIdsFromLocalItems(playedItems.filter((item) => getTmdbMediaType(item) === 'movie'), movieGenreLookup, 1, movieGenreWeights);
resolveGenreIdsFromLocalItems(playedItems.filter((item) => getTmdbMediaType(item) === 'tv'), tvGenreLookup, 1, tvGenreWeights);
const discoverQueries = [
...(includeMovies
? buildDiscoverQueries({
mediaType: 'movie',
genreIds: sortWeightedEntries(movieGenreWeights, 6).map(([id]) => id),
keywordIds: sortWeightedEntries(movieKeywordWeights, 8).map(([id]) => id),
peopleIds: sortWeightedEntries(moviePeopleWeights, 6).map(([id]) => id)
})
: []),
...(includeSeries
? buildDiscoverQueries({
mediaType: 'tv',
genreIds: sortWeightedEntries(tvGenreWeights, 6).map(([id]) => id),
keywordIds: sortWeightedEntries(tvKeywordWeights, 8).map(([id]) => id),
peopleIds: []
})
: [])
];
const queryResults = await Promise.all(
discoverQueries.map(async (query) => {
try {
const tmdbResults = filterEnglishCandidates(await fetchTmdbDiscover({
mediaType: query.label.startsWith('tv') ? 'tv' : 'movie',
params: query.params
}));
const localMatches = uniqueById(
tmdbResults.map((candidate) => resolveLocalCandidate(libraryIndex, candidate)).filter(Boolean)
);
return {
label: query.label,
params: query.params,
tmdbCount: tmdbResults.length,
localCount: localMatches.length,
localMatches,
sourceWeight: 1
};
} catch (err) {
return {
label: query.label,
params: query.params,
tmdbCount: 0,
localCount: 0,
localMatches: [],
error: err.message
};
}
})
);
const resultSets = [
...seedContexts.flatMap((context) => context.resultSets),
...queryResults.map((query) => ({
items: query.localMatches,
sourceWeight: query.sourceWeight || 1,
label: query.label
}))
].filter((resultSet) => (Array.isArray(resultSet) ? resultSet.length : resultSet.items.length) > 0);
const candidateInspection = inspectCandidateSets(excludedIds, resultSets);
const recommendations = rankRecommendationResults(normalizedSeedIds, resultSets, {
limit,
profile,
seeds,
excludeIds: excludedIds,
allowedTypes
});
return {
seeds,
recommendations,
diagnostics: {
libraryItemCount: libraryItems.length,
watchedItemCount: playedItems.length,
seedCount: seeds.length,
embyCandidates: seedContexts.reduce(
(total, context) => total + context.diagnostic.localSimilarCount + context.diagnostic.localRecommendationCount,
0
),
tmdbEnabled: true,
tmdbCandidates:
seedContexts.reduce(
(total, context) => total + context.diagnostic.similarCount + context.diagnostic.recommendationCount,
0
) + queryResults.reduce((total, query) => total + query.tmdbCount, 0),
tmdbResolved:
seedContexts.reduce(
(total, context) => total + context.diagnostic.localSimilarCount + context.diagnostic.localRecommendationCount,
0
) + queryResults.reduce((total, query) => total + query.localCount, 0),
uniqueCandidateCount: candidateInspection.uniqueCandidateCount,
excludedSeedCandidateCount: candidateInspection.excludedSeedCandidateCount,
sampleExcludedSeedCandidates: candidateInspection.sampleExcludedSeedCandidates,
perSeed: seedContexts.map((context) => context.diagnostic),
discoverQueries: queryResults.map((query) => ({
label: query.label,
tmdbCount: query.tmdbCount,
localCount: query.localCount,
error: query.error || null,
params: query.params
}))
}
};
}
async function createOrUpdateCollection(userId, name, itemIds) {
const existingCollection = await findExistingCollection(userId, name);
if (existingCollection?.id) {
await fetchEmby(`/Collections/${encodeURIComponent(existingCollection.id)}/Items`, {
method: 'POST',
params: {
Ids: itemIds.join(',')
}
});
return {
id: existingCollection.id,
name: existingCollection.name || name,
updated: true
};
}
const response = await fetchEmby('/Collections', {
method: 'POST',
params: {
Name: name,
Ids: itemIds.join(',')
}
});
const created = await response.json();
return {
id: String(created?.Id || ''),
name: created?.Name || name,
updated: false
};
}
export async function POST({ request }) {
const body = await request.json();
const mode = body?.mode === 'create' ? 'create' : 'preview';
const userId = normalizeEmbyGuid(body?.userId);
const seedIds = Array.isArray(body?.seedIds) ? body.seedIds : [];
const limit = Math.min(Math.max(Number(body?.limit || 18), 1), 48);
const includeSeeds = body?.includeSeeds !== false;
const name = String(body?.name || '').trim();
const profile = RECOMMENDATION_PROFILES[body?.profile] ? body.profile : 'balanced';
if (!userId) {
throw error(400, 'Missing userId');
}
if (seedIds.length === 0) {
throw error(400, 'Select at least one seed item');
}
try {
const preview = await buildPreview(userId, seedIds, limit, profile);
const existingCollection = name ? await findExistingCollection(userId, name) : null;
if (mode !== 'create') {
return json({
...preview,
profile,
collection: existingCollection
? {
id: existingCollection.id,
name: existingCollection.name,
updated: true
}
: null
});
}
if (!name) {
throw error(400, 'Collection name is required');
}
const collectionIds = [
...(includeSeeds ? preview.seeds.map((item) => item.id) : []),
...preview.recommendations.map((item) => item.id)
];
const uniqueIds = [...new Set(collectionIds.filter(Boolean))];
if (uniqueIds.length === 0) {
throw error(400, 'No items were available to add to the collection');
}
const collection = await createOrUpdateCollection(userId, name, uniqueIds);
return json({
seeds: preview.seeds,
recommendations: preview.recommendations,
profile,
collection
});
} catch (err) {
if (err?.status) throw err;
if (String(err.message || '').includes('not configured')) {
throw error(400, err.message);
}
throw error(502, `Could not build Emby collection: ${err.message}`);
}
}
@@ -0,0 +1,173 @@
import { error, json } from '@sveltejs/kit';
import { normalizeLookupItem } from '../../../lib/collection-tools.js';
import {
buildSingleGenreUpdate,
normalizeGenreNames,
pickSuggestedGenre
} from '../../../lib/genre-cleanup.js';
import { fetchEmby, fetchEmbyJson, normalizeEmbyGuid } from '../../../lib/server/emby-api.js';
import { fetchTmdbDetails, searchTmdbByTitle } from '../../../lib/server/tmdb-api.js';
function getItemTmdbId(item) {
const providerIds = item?.ProviderIds || item?.providerIds || {};
return String(providerIds.Tmdb || providerIds.TMDB || providerIds.tmdb || '').trim();
}
function getTmdbMediaType(item) {
return item?.Type === 'Series' || item?.type === 'Series' ? 'tv' : 'movie';
}
function normalizeTitle(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '');
}
async function fetchFullItem(userId, itemId) {
const candidates = [
() => fetchEmbyJson(`/Users/${encodeURIComponent(userId)}/Items/${encodeURIComponent(itemId)}`),
() =>
fetchEmbyJson(`/Items/${encodeURIComponent(itemId)}`, {
params: { UserId: userId }
})
];
let lastError = null;
for (const candidate of candidates) {
try {
return await candidate();
} catch (err) {
lastError = err;
}
}
throw lastError || new Error('Could not fetch item details from Emby');
}
async function resolveTmdbMatch(item) {
const directTmdbId = getItemTmdbId(item);
if (directTmdbId) {
return {
tmdbId: Number(directTmdbId),
mediaType: getTmdbMediaType(item),
source: 'providerId'
};
}
const matches = await searchTmdbByTitle({
mediaType: getTmdbMediaType(item),
name: item?.Name || item?.name,
year: item?.ProductionYear || item?.year
});
const exactTitle = normalizeTitle(item?.Name || item?.name);
const bestMatch =
matches.find(
(candidate) =>
normalizeTitle(candidate?.name) === exactTitle &&
(!item?.ProductionYear || !candidate?.year || candidate.year === item.ProductionYear)
) || matches[0];
if (!bestMatch?.tmdbId) return null;
return {
tmdbId: Number(bestMatch.tmdbId),
mediaType: bestMatch.mediaType,
source: 'search'
};
}
async function inspectItemGenres(userId, itemId) {
const fullItem = await fetchFullItem(userId, itemId);
const normalizedItem = normalizeLookupItem(fullItem);
const currentGenres = normalizeGenreNames([
...(fullItem?.Genres || []),
...((fullItem?.GenreItems || []).map((entry) => entry?.Name))
]);
const tmdbMatch = await resolveTmdbMatch(fullItem);
if (!tmdbMatch?.tmdbId) {
return {
item: normalizedItem,
currentGenres,
tmdb: null,
suggestedGenre: ''
};
}
const details = await fetchTmdbDetails(tmdbMatch);
const tmdbGenres = normalizeGenreNames((details?.genres || []).map((genre) => genre?.name));
return {
item: normalizedItem,
currentGenres,
tmdb: {
tmdbId: tmdbMatch.tmdbId,
mediaType: tmdbMatch.mediaType,
source: tmdbMatch.source,
genres: tmdbGenres
},
suggestedGenre: pickSuggestedGenre(tmdbGenres, currentGenres)
};
}
export async function GET({ url }) {
const userId = normalizeEmbyGuid(url.searchParams.get('userId'));
const itemId = String(url.searchParams.get('itemId') || '').trim();
if (!userId) {
throw error(400, 'Missing userId');
}
if (!itemId) {
throw error(400, 'Missing itemId');
}
try {
return json(await inspectItemGenres(userId, itemId));
} catch (err) {
if (String(err.message || '').includes('not configured')) {
throw error(400, err.message);
}
throw error(502, `Could not inspect item genres: ${err.message}`);
}
}
export async function POST({ request }) {
const body = await request.json();
const userId = normalizeEmbyGuid(body?.userId);
const itemId = String(body?.itemId || '').trim();
const genreName = String(body?.genreName || '').trim();
if (!userId) {
throw error(400, 'Missing userId');
}
if (!itemId) {
throw error(400, 'Missing itemId');
}
if (!genreName) {
throw error(400, 'Missing genreName');
}
try {
const fullItem = await fetchFullItem(userId, itemId);
const updatedItem = buildSingleGenreUpdate(fullItem, genreName);
await fetchEmby(`/Items/${encodeURIComponent(itemId)}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(updatedItem)
});
return json(await inspectItemGenres(userId, itemId));
} catch (err) {
if (err?.status) throw err;
if (String(err.message || '').includes('not configured')) {
throw error(400, err.message);
}
throw error(502, `Could not update item genres: ${err.message}`);
}
}
@@ -0,0 +1,45 @@
import { error, json } from '@sveltejs/kit';
import { fetchEmbyJson, normalizeEmbyGuid } from '../../../lib/server/emby-api.js';
import { normalizeLookupItem } from '../../../lib/collection-tools.js';
export async function GET({ url }) {
const userId = normalizeEmbyGuid(url.searchParams.get('userId'));
const term = String(url.searchParams.get('term') || '').trim();
const limit = Math.min(Math.max(Number(url.searchParams.get('limit') || 12), 1), 50);
const types = (url.searchParams.get('types') || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
if (!userId) {
throw error(400, 'Missing userId');
}
if (!term) {
return json({ items: [] });
}
try {
const payload = await fetchEmbyJson(`/Users/${encodeURIComponent(userId)}/Items`, {
params: {
Recursive: true,
SearchTerm: term,
Limit: limit,
SortBy: 'SortName',
SortOrder: 'Ascending',
Fields: 'Overview,Genres,ProviderIds,ProductionYear',
IncludeItemTypes: types.join(','),
GroupItemsIntoCollections: false
}
});
return json({
items: (payload.Items || payload || []).map(normalizeLookupItem)
});
} catch (err) {
if (String(err.message || '').includes('not configured')) {
throw error(400, err.message);
}
throw error(502, `Could not search Emby items: ${err.message}`);
}
}
@@ -0,0 +1,160 @@
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
import { json, error } from '@sveltejs/kit';
import {
readCachedEmbyUserContext,
writeCachedEmbyUserContext
} from '../../../lib/server/emby-user-context-cache.js';
const CONFIG_PATH = resolve('config.json');
const EMBY_PAGE_SIZE = 200;
function loadConfig() {
if (!existsSync(CONFIG_PATH)) return {};
try {
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
} catch {
return {};
}
}
function normalizeGuid(value) {
return typeof value === 'string' ? value.replace(/-/g, '').trim().toLowerCase() : '';
}
async function fetchJson(url) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`);
}
return response.json();
}
async function fetchAllUserItems(base, embyGuid, apiKey, params = {}) {
const allItems = [];
let startIndex = 0;
while (true) {
const query = new URLSearchParams({
api_key: apiKey,
Recursive: 'true',
GroupItemsIntoCollections: 'false',
Limit: String(EMBY_PAGE_SIZE),
StartIndex: String(startIndex),
...Object.fromEntries(
Object.entries(params).map(([key, value]) => [key, String(value)])
)
});
const payload = await fetchJson(`${base}/Users/${encodeURIComponent(embyGuid)}/Items?${query.toString()}`);
const pageItems = payload.Items || payload || [];
allItems.push(...pageItems);
const total = Number(payload.TotalRecordCount || 0);
if (!pageItems.length) break;
if (total > 0 && allItems.length >= total) break;
if (pageItems.length < EMBY_PAGE_SIZE) break;
startIndex += pageItems.length;
}
return allItems;
}
function normalizeViews(items = []) {
return items.map((item) => ({
id: String(item.Id || ''),
name: item.Name || 'Unnamed view',
type: item.CollectionType || item.Type || 'View'
}));
}
function normalizeRecentlyPlayed(items = []) {
return items.map((item) => ({
id: String(item.Id || ''),
name: item.Name || item.SeriesName || 'Unknown item',
type: item.Type || 'Item',
seriesName: item.SeriesName || null,
datePlayed: item.UserData?.LastPlayedDate || item.DateLastMediaAdded || null,
isPlayed: item.UserData?.Played ?? true
}));
}
function buildExcludedFolderLookup(items = []) {
return Object.fromEntries(
items
.filter((item) => item?.Id)
.map((item) => [
String(item.Id),
{
name: item.Name || item.Path || `Item ${item.Id}`,
type: item.CollectionType || item.Type || 'Item'
}
])
);
}
export async function GET({ url }) {
const embyGuid = normalizeGuid(url.searchParams.get('embyGuid'));
const excludedIds = (url.searchParams.get('excludedIds') || '')
.split(',')
.map((id) => id.trim())
.filter(Boolean);
if (!embyGuid) {
throw error(400, 'Missing embyGuid');
}
const { embyUrl, apiKey } = loadConfig();
const cached = readCachedEmbyUserContext(embyGuid);
if (!embyUrl || !apiKey) {
if (cached) {
return json({ ...cached, source: 'cache' });
}
throw error(400, 'Emby URL and API key not configured');
}
const base = embyUrl.replace(/\/+$/, '');
try {
const [viewsPayload, recentlyPlayedPayload, excludedPayload] = await Promise.all([
fetchJson(`${base}/Users/${encodeURIComponent(embyGuid)}/Views?api_key=${encodeURIComponent(apiKey)}`),
fetchAllUserItems(base, embyGuid, apiKey, {
Filters: 'IsPlayed',
IncludeItemTypes: 'Movie,Series',
SortBy: 'DatePlayed',
SortOrder: 'Descending',
Fields: 'UserData'
}),
excludedIds.length > 0
? fetchJson(
`${base}/Users/${encodeURIComponent(embyGuid)}/Items?api_key=${encodeURIComponent(apiKey)}&Ids=${encodeURIComponent(excludedIds.join(','))}&Fields=Path`
)
: Promise.resolve({ Items: [] })
]);
const context = writeCachedEmbyUserContext(embyGuid, {
views: normalizeViews(viewsPayload.Items || viewsPayload || []),
recentlyPlayed: normalizeRecentlyPlayed(recentlyPlayedPayload),
excludedFolderLookup: buildExcludedFolderLookup(excludedPayload.Items || excludedPayload || []),
lastSyncedAt: new Date().toISOString()
});
return json({ ...context, source: 'live' });
} catch (err) {
if (cached) {
const filteredLookup = excludedIds.length
? Object.fromEntries(
Object.entries(cached.excludedFolderLookup || {}).filter(([id]) => excludedIds.includes(id))
)
: (cached.excludedFolderLookup || {});
return json({
...cached,
excludedFolderLookup: filteredLookup,
source: 'cache',
message: `Using cached Emby user context because live fetch failed: ${err.message}`
});
}
throw error(502, `Could not load Emby user context: ${err.message}`);
}
}
@@ -0,0 +1,68 @@
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
import { json, error } from '@sveltejs/kit';
import { readCachedEmbyUsers, writeCachedEmbyUsers } from '../../../lib/server/emby-user-cache.js';
const CONFIG_PATH = resolve('config.json');
function loadConfig() {
if (!existsSync(CONFIG_PATH)) return {};
try {
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
} catch {
return {};
}
}
export async function GET() {
const { embyUrl, apiKey } = loadConfig();
if (!embyUrl || !apiKey) {
throw error(400, 'Emby URL and API key not configured');
}
const base = embyUrl.replace(/\/+$/, '');
let res;
try {
res = await fetch(`${base}/Users?api_key=${encodeURIComponent(apiKey)}`);
} catch (e) {
const cached = readCachedEmbyUsers();
if (cached.users.length > 0) {
return json({
users: cached.users,
source: 'cache',
lastSyncedAt: cached.lastSyncedAt,
message: `Using cached Emby users because the server could not be reached: ${e.message}`
});
}
throw error(502, `Could not reach Emby server: ${e.message}`);
}
if (!res.ok) {
if (res.status >= 500) {
const cached = readCachedEmbyUsers();
if (cached.users.length > 0) {
return json({
users: cached.users,
source: 'cache',
lastSyncedAt: cached.lastSyncedAt,
message: `Using cached Emby users because the Emby API returned ${res.status} ${res.statusText}`
});
}
}
throw error(res.status, `Emby API error: ${res.status} ${res.statusText}`);
}
const users = await res.json();
const cached = writeCachedEmbyUsers(
users.map((u) => ({
embyGuid: u.Id,
name: u.Name
}))
);
return json({
users: cached.users,
source: 'live',
lastSyncedAt: cached.lastSyncedAt
});
}