v1.2 - collections, etc

This commit is contained in:
2026-04-25 22:57:08 +12:00
parent e68a8b1622
commit cd563d67b6
28 changed files with 20276 additions and 270 deletions
File diff suppressed because it is too large Load Diff
Binary file not shown.
+4
View File
@@ -4,6 +4,10 @@
"private": true,
"type": "module",
"scripts": {
"test": "node tests/run-tests.js",
"predev": "npm run test",
"prebuild": "npm run test",
"prepreview": "npm run test",
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
+7 -1
View File
@@ -3,7 +3,13 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Emby Home Screen Editor</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<title>HomeScreenPal</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
+635
View File
@@ -0,0 +1,635 @@
<script>
import { createEventDispatcher } from 'svelte';
export let open = false;
export let users = [];
export let selectedUserId = null;
const dispatch = createEventDispatcher();
let sourceUserId = null;
let targetUserIds = [];
let seedType = 'Movie';
let collectionName = '';
let seedSearchTerm = '';
let seedResults = [];
let selectedSeeds = [];
let recommendations = [];
let includeSeeds = true;
let recommendationLimit = 18;
let searchBusy = false;
let previewBusy = false;
let createBusy = false;
let searchError = '';
let actionError = '';
let actionMessage = '';
let existingCollection = null;
let wasOpen = false;
$: sourceUsers = users.filter((user) => user.embyGuid);
$: if (open && !wasOpen) {
sourceUserId = sourceUsers.some((user) => user.id === selectedUserId)
? selectedUserId
: (sourceUsers[0]?.id || null);
targetUserIds = users.some((user) => user.id === selectedUserId) ? [selectedUserId] : [];
wasOpen = true;
} else if (!open && wasOpen) {
resetState();
wasOpen = false;
}
$: if (open && !sourceUsers.some((user) => user.id === sourceUserId)) {
sourceUserId = sourceUsers.some((user) => user.id === selectedUserId)
? selectedUserId
: (sourceUsers[0]?.id || null);
}
$: if (open && targetUserIds.length === 0 && selectedUserId != null) {
targetUserIds = users.some((user) => user.id === selectedUserId) ? [selectedUserId] : [];
}
$: sourceUser = users.find((user) => user.id === sourceUserId);
$: lookupType = seedType === 'Movie' ? 'Movie' : 'Series';
$: canPreview = !!sourceUser?.embyGuid && selectedSeeds.length > 0;
$: canCreate = canPreview && !!collectionName.trim() && !createBusy && recommendations.length > 0;
function close() {
dispatch('close');
}
function resetState() {
sourceUserId = null;
targetUserIds = [];
seedType = 'Movie';
collectionName = '';
seedSearchTerm = '';
seedResults = [];
selectedSeeds = [];
recommendations = [];
existingCollection = null;
includeSeeds = true;
recommendationLimit = 18;
searchBusy = false;
previewBusy = false;
createBusy = false;
searchError = '';
actionError = '';
actionMessage = '';
}
function toggleTargetUser(userId) {
if (targetUserIds.includes(userId)) {
targetUserIds = targetUserIds.filter((id) => id !== userId);
} else {
targetUserIds = [...targetUserIds, userId];
}
}
function addSeed(item) {
if (selectedSeeds.some((seed) => seed.id === item.id)) return;
selectedSeeds = [...selectedSeeds, item];
recommendations = [];
existingCollection = null;
actionError = '';
actionMessage = '';
}
function removeSeed(seedId) {
selectedSeeds = selectedSeeds.filter((seed) => seed.id !== seedId);
recommendations = [];
existingCollection = null;
}
async function searchSeeds() {
if (!sourceUser?.embyGuid) {
searchError = 'Select a source user with a linked Emby account.';
return;
}
if (!seedSearchTerm.trim()) {
seedResults = [];
searchError = '';
return;
}
searchBusy = true;
searchError = '';
try {
const params = new URLSearchParams({
userId: sourceUser.embyGuid,
term: seedSearchTerm.trim(),
types: lookupType,
limit: '10'
});
const response = await fetch(`/api/emby-item-search?${params.toString()}`);
const body = await response.json().catch(() => ({ items: [] }));
if (!response.ok) throw new Error(body.message || body.error || response.statusText);
seedResults = body.items || [];
} catch (err) {
searchError = err.message;
seedResults = [];
} finally {
searchBusy = false;
}
}
async function previewRecommendations() {
if (!canPreview) return;
previewBusy = true;
actionError = '';
actionMessage = '';
try {
const response = await fetch('/api/emby-collections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mode: 'preview',
userId: sourceUser.embyGuid,
seedIds: selectedSeeds.map((seed) => seed.id),
name: collectionName.trim(),
limit: recommendationLimit
})
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.message || body.error || response.statusText);
recommendations = body.recommendations || [];
existingCollection = body.collection || null;
actionMessage = recommendations.length
? existingCollection?.updated
? `Generated ${recommendations.length} recommendations. Re-running create will update "${existingCollection.name}".`
: `Generated ${recommendations.length} recommendations.`
: 'No recommendations were returned for these seeds.';
} catch (err) {
actionError = err.message;
recommendations = [];
existingCollection = null;
} finally {
previewBusy = false;
}
}
async function createCollection() {
if (!canCreate) return;
createBusy = true;
actionError = '';
actionMessage = '';
try {
const response = await fetch('/api/emby-collections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mode: 'create',
userId: sourceUser.embyGuid,
name: collectionName.trim(),
seedIds: selectedSeeds.map((seed) => seed.id),
limit: recommendationLimit,
includeSeeds
})
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.message || body.error || response.statusText);
dispatch('created', {
collection: body.collection,
targetUserIds,
seeds: selectedSeeds,
recommendations: body.recommendations || []
});
existingCollection = body.collection || null;
actionMessage = body.collection?.updated
? `Updated collection "${body.collection.name}".`
: `Created collection "${body.collection?.name || collectionName.trim()}".`;
} catch (err) {
actionError = err.message;
} finally {
createBusy = false;
}
}
</script>
{#if open}
<div
class="overlay"
role="button"
tabindex="0"
aria-label="Close collection builder"
on:click|self={close}
on:keydown={(event) => (event.key === 'Escape' || event.key === 'Enter') && close()}
>
<div class="modal" role="dialog" aria-modal="true" aria-label="Recommendation collection builder">
<div class="modal-header">
<div>
<h3>Recommendation Collection Builder</h3>
<p>Pick seed movies or shows, generate similar items, then create a reusable Emby collection.</p>
</div>
<button class="close-btn" on:click={close}>×</button>
</div>
<div class="modal-body">
<div class="field-grid">
<label class="field">
<span class="field-label">Source user</span>
<select bind:value={sourceUserId}>
{#each sourceUsers as user}
<option value={user.id}>{user.name}</option>
{/each}
</select>
</label>
<label class="field">
<span class="field-label">Seed type</span>
<select bind:value={seedType}>
<option value="Movie">Movies</option>
<option value="Series">Shows</option>
</select>
</label>
<label class="field span2">
<span class="field-label">Collection name</span>
<input
type="text"
bind:value={collectionName}
placeholder="Recommended for Family Movie Night"
/>
</label>
</div>
<div class="field-group">
<div class="field-label-row">
<span class="field-label">Seed lookup</span>
<label class="field-inline compact">
<input type="checkbox" bind:checked={includeSeeds} />
<span>Include seeds in final collection</span>
</label>
</div>
<div class="lookup-row">
<input
type="text"
bind:value={seedSearchTerm}
on:keydown={(event) => event.key === 'Enter' && searchSeeds()}
placeholder={`Search ${seedType === 'Movie' ? 'movies' : 'shows'}...`}
/>
<button class="btn ghost" on:click={searchSeeds} disabled={searchBusy || !sourceUser?.embyGuid}>
{searchBusy ? 'Searching…' : 'Search'}
</button>
</div>
{#if searchError}
<div class="status error">{searchError}</div>
{/if}
{#if seedResults.length}
<div class="result-list">
{#each seedResults as item}
<button class="result-card" on:click={() => addSeed(item)}>
<div class="result-name">{item.name}</div>
<div class="result-meta">{item.type}{item.year ? ` · ${item.year}` : ''}</div>
{#if item.overview}
<div class="result-overview">{item.overview}</div>
{/if}
</button>
{/each}
</div>
{/if}
</div>
<div class="field-group">
<div class="field-label-row">
<span class="field-label">Selected seeds</span>
<label class="field-inline compact">
<span>Recommendation count</span>
<select bind:value={recommendationLimit}>
<option value={12}>12</option>
<option value={18}>18</option>
<option value={24}>24</option>
<option value={30}>30</option>
</select>
</label>
</div>
{#if selectedSeeds.length}
<div class="seed-list">
{#each selectedSeeds as item}
<div class="seed-chip">
<div>
<div class="seed-name">{item.name}</div>
<div class="seed-meta">{item.type}{item.year ? ` · ${item.year}` : ''}</div>
</div>
<button class="remove-btn" on:click={() => removeSeed(item.id)}>×</button>
</div>
{/each}
</div>
{:else}
<div class="empty-note">Add at least one seed item to generate recommendations.</div>
{/if}
</div>
<div class="field-group">
<span class="field-label">Apply linked section to users</span>
<div class="target-list">
{#each users.filter((user) => user.embyGuid) as user}
<label class="target-option" class:selected={targetUserIds.includes(user.id)}>
<input
type="checkbox"
checked={targetUserIds.includes(user.id)}
on:change={() => toggleTargetUser(user.id)}
/>
<span>{user.name}</span>
<span class="target-count">{user.sections?.length || 0} sections</span>
</label>
{/each}
</div>
</div>
{#if recommendations.length}
<div class="field-group">
<span class="field-label">Recommendation preview</span>
<div class="preview-list">
{#each recommendations as item}
<div class="preview-item">
<div>
<div class="result-name">{item.name}</div>
<div class="result-meta">
{item.type}{item.year ? ` · ${item.year}` : ''} · matched {item.matchCount} seed{item.matchCount === 1 ? '' : 's'}
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
{#if existingCollection?.id}
<div class="status">Collection target: {existingCollection.name} · existing box set will be updated</div>
{/if}
{#if actionError}
<div class="status error">{actionError}</div>
{:else if actionMessage}
<div class="status ok">{actionMessage}</div>
{/if}
</div>
<div class="modal-actions">
<button class="btn ghost" on:click={previewRecommendations} disabled={!canPreview || previewBusy || createBusy}>
{previewBusy ? 'Generating…' : 'Preview Recommendations'}
</button>
<button class="btn accent" on:click={createCollection} disabled={!canCreate || previewBusy || createBusy}>
{createBusy ? (existingCollection?.updated ? 'Updating…' : 'Creating…') : (existingCollection?.updated ? 'Update Collection' : 'Create Collection')}
</button>
</div>
</div>
</div>
{/if}
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.78);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
z-index: 120;
}
.modal {
width: min(980px, 100%);
max-height: 90vh;
display: flex;
flex-direction: column;
border-radius: 20px;
background: var(--surface-strong);
border: 1px solid var(--border);
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35);
}
.modal-header,
.modal-actions {
padding: 18px 20px;
border-bottom: 1px solid var(--border);
}
.modal-actions {
border-bottom: none;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 10px;
}
.modal-header {
display: flex;
justify-content: space-between;
gap: 16px;
}
.modal-header h3 {
margin: 0 0 6px;
font-size: 18px;
color: var(--text);
}
.modal-header p {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--text-muted);
}
.close-btn,
.remove-btn {
all: unset;
cursor: pointer;
color: var(--text-muted);
font-size: 22px;
line-height: 1;
}
.modal-body {
padding: 20px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 18px;
}
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.field,
.field-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.field.span2 {
grid-column: span 2;
}
.field-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
}
.field-label-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.field-inline {
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
font-size: 13px;
}
.field-inline.compact {
font-size: 12px;
}
input,
select {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: #0b0f14;
color: var(--text);
font-size: 13px;
font-family: inherit;
}
input:focus,
select:focus {
outline: none;
border-color: var(--border-strong);
}
.lookup-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
}
.result-list,
.preview-list,
.target-list,
.seed-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.result-card,
.preview-item,
.seed-chip,
.target-option {
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface);
padding: 12px 14px;
}
.result-card {
all: unset;
cursor: pointer;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--surface);
padding: 12px 14px;
transition: border-color 0.12s ease, background 0.12s ease;
}
.result-card:hover,
.target-option:hover {
border-color: var(--border-strong);
background: var(--surface-hover);
}
.result-name,
.seed-name {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.result-meta,
.seed-meta,
.target-count {
font-size: 12px;
color: var(--text-muted);
}
.result-overview {
margin-top: 6px;
font-size: 12px;
color: var(--text-muted);
line-height: 1.45;
}
.seed-chip,
.target-option {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.target-option {
cursor: pointer;
}
.target-option.selected {
background: var(--surface-active);
border-color: var(--border-strong);
}
.empty-note {
padding: 14px;
border-radius: 14px;
background: var(--surface);
border: 1px dashed var(--border);
color: var(--text-muted);
font-size: 13px;
}
.status {
padding: 10px 12px;
border-radius: 14px;
font-size: 12px;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text-muted);
}
.status.ok {
border-color: rgba(34, 197, 94, 0.3);
color: #86efac;
}
.status.error {
border-color: rgba(239, 68, 68, 0.3);
color: #fca5a5;
}
.btn {
all: unset;
cursor: pointer;
padding: 9px 16px;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
border: 1px solid transparent;
}
.btn.ghost {
color: #d9e7f8;
border-color: var(--border);
background: var(--surface);
}
.btn.accent {
background: var(--accent);
color: #fff;
}
.btn:disabled {
opacity: 0.45;
cursor: default;
}
@media (max-width: 720px) {
.field-grid,
.lookup-row {
grid-template-columns: 1fr;
}
.modal-header,
.modal-body,
.modal-actions {
padding: 16px;
}
.modal-actions {
flex-direction: column;
}
.field.span2 {
grid-column: span 1;
}
}
</style>
+857
View File
@@ -0,0 +1,857 @@
<script>
import { createEventDispatcher } from 'svelte';
import Icon from '$lib/Icon.svelte';
import { RECOMMENDATION_PROFILES } from '$lib/collection-tools.js';
export let users = [];
export let selectedUserId = null;
const dispatch = createEventDispatcher();
let sourceUserId = null;
let targetUserIds = [];
let collectionName = '';
let selectedSeeds = [];
let recommendations = [];
let includeSeeds = true;
let recommendationLimit = 18;
let recommendationProfile = 'balanced';
let recentItems = [];
let recentBusy = false;
let recentError = '';
let movieSearchTerm = '';
let showSearchTerm = '';
let movieResults = [];
let showResults = [];
let movieSearchBusy = false;
let showSearchBusy = false;
let movieSearchError = '';
let showSearchError = '';
let previewBusy = false;
let createBusy = false;
let actionError = '';
let actionMessage = '';
let existingCollection = null;
$: sourceUsers = users.filter((user) => user.embyGuid);
$: profileOptions = Object.values(RECOMMENDATION_PROFILES);
$: selectedProfile = RECOMMENDATION_PROFILES[recommendationProfile] || RECOMMENDATION_PROFILES.balanced;
$: if (!sourceUsers.some((user) => user.id === sourceUserId)) {
sourceUserId = sourceUsers.some((user) => user.id === selectedUserId)
? selectedUserId
: (sourceUsers[0]?.id || null);
}
$: sourceUser = users.find((user) => user.id === sourceUserId);
$: canPreview = !!sourceUser?.embyGuid && selectedSeeds.length > 0;
$: canCreate = canPreview && !!collectionName.trim() && recommendations.length > 0 && !createBusy;
$: if (sourceUser?.embyGuid) {
loadRecentActivity(sourceUser.embyGuid);
}
let lastRecentUserId = '';
async function loadRecentActivity(embyGuid) {
if (!embyGuid || lastRecentUserId === embyGuid) return;
lastRecentUserId = embyGuid;
recentBusy = true;
recentError = '';
recentItems = [];
try {
const params = new URLSearchParams({ embyGuid });
const response = await fetch(`/api/emby-user-context?${params.toString()}`);
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.message || body.error || response.statusText);
const activity = Array.isArray(body.recentlyPlayed) ? body.recentlyPlayed : [];
recentItems = activity.filter((item) => item.type === 'Movie' || item.type === 'Series');
} catch (error) {
recentError = error.message;
} finally {
recentBusy = false;
}
}
function toggleTargetUser(userId) {
if (targetUserIds.includes(userId)) {
targetUserIds = targetUserIds.filter((id) => id !== userId);
} else {
targetUserIds = [...targetUserIds, userId];
}
}
function addSeed(item) {
if (!item?.id || selectedSeeds.some((seed) => seed.id === item.id)) return;
selectedSeeds = [...selectedSeeds, item];
recommendations = [];
existingCollection = null;
actionError = '';
actionMessage = '';
}
function removeSeed(seedId) {
selectedSeeds = selectedSeeds.filter((seed) => seed.id !== seedId);
recommendations = [];
existingCollection = null;
}
function handleProfileChange() {
recommendations = [];
existingCollection = null;
actionError = '';
actionMessage = '';
}
function logPreviewDiagnostics(requestPayload, responseBody) {
if (typeof window === 'undefined') return;
const diagnostics = responseBody?.diagnostics || {};
const perSeed = Array.isArray(diagnostics.perSeed) ? diagnostics.perSeed : [];
console.groupCollapsed('[Collections] Recommendation preview');
console.info('Request', requestPayload);
console.info('Response summary', {
profile: responseBody?.profile,
recommendationCount: (responseBody?.recommendations || []).length,
libraryItemCount: diagnostics.libraryItemCount ?? 0,
watchedItemCount: diagnostics.watchedItemCount ?? 0,
seedCount: diagnostics.seedCount ?? 0,
embyCandidates: diagnostics.embyCandidates ?? 0,
tmdbEnabled: diagnostics.tmdbEnabled ?? false,
tmdbCandidates: diagnostics.tmdbCandidates ?? 0,
tmdbResolved: diagnostics.tmdbResolved ?? 0,
uniqueCandidateCount: diagnostics.uniqueCandidateCount ?? 0,
excludedSeedCandidateCount: diagnostics.excludedSeedCandidateCount ?? 0
});
console.info('Seeds returned by API', responseBody?.seeds || []);
if ((diagnostics.sampleExcludedSeedCandidates || []).length) {
console.warn('Candidates dropped because they resolved back onto the seed items', diagnostics.sampleExcludedSeedCandidates);
}
if (perSeed.length) {
console.table(
perSeed.map((seed) => ({
seed: seed.seedName,
type: seed.seedType,
year: seed.seedYear,
tmdbProviderId: seed.providerTmdbId || '',
tmdbMatchId: seed.tmdbMatch?.tmdbId || '',
tmdbMediaType: seed.tmdbMatch?.mediaType || '',
similar: seed.similarCount ?? 0,
recommendations: seed.recommendationCount ?? 0,
localSimilar: seed.localSimilarCount ?? 0,
localRecommendations: seed.localRecommendationCount ?? 0,
error: seed.error || ''
}))
);
console.info('Per-seed detail', perSeed);
}
if ((diagnostics.discoverQueries || []).length) {
console.table(
diagnostics.discoverQueries.map((query) => ({
query: query.label,
tmdbCount: query.tmdbCount,
localCount: query.localCount,
error: query.error || ''
}))
);
console.info('Discover query detail', diagnostics.discoverQueries);
}
if ((responseBody?.recommendations || []).length) {
console.info('Recommendations', responseBody.recommendations);
}
console.groupEnd();
}
async function searchItems(type) {
const searchTerm = type === 'Movie' ? movieSearchTerm.trim() : showSearchTerm.trim();
if (!sourceUser?.embyGuid) {
if (type === 'Movie') movieSearchError = 'Select a source user first.';
else showSearchError = 'Select a source user first.';
return;
}
if (!searchTerm) {
if (type === 'Movie') movieResults = [];
else showResults = [];
return;
}
if (type === 'Movie') {
movieSearchBusy = true;
movieSearchError = '';
} else {
showSearchBusy = true;
showSearchError = '';
}
try {
const params = new URLSearchParams({
userId: sourceUser.embyGuid,
term: searchTerm,
types: type,
limit: '10'
});
const response = await fetch(`/api/emby-item-search?${params.toString()}`);
const body = await response.json().catch(() => ({ items: [] }));
if (!response.ok) throw new Error(body.message || body.error || response.statusText);
if (type === 'Movie') movieResults = body.items || [];
else showResults = body.items || [];
} catch (error) {
if (type === 'Movie') {
movieSearchError = error.message;
movieResults = [];
} else {
showSearchError = error.message;
showResults = [];
}
} finally {
if (type === 'Movie') movieSearchBusy = false;
else showSearchBusy = false;
}
}
async function previewRecommendations() {
if (!canPreview) return;
previewBusy = true;
actionError = '';
actionMessage = '';
const requestPayload = {
mode: 'preview',
userId: sourceUser.embyGuid,
seedIds: selectedSeeds.map((seed) => seed.id),
seeds: selectedSeeds.map((seed) => ({
id: seed.id,
name: seed.name,
type: seed.type,
year: seed.year
})),
name: collectionName.trim(),
limit: recommendationLimit,
profile: recommendationProfile
};
try {
const response = await fetch('/api/emby-collections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestPayload)
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.message || body.error || response.statusText);
logPreviewDiagnostics(requestPayload, body);
recommendations = body.recommendations || [];
existingCollection = body.collection || null;
if (recommendations.length) {
actionMessage = existingCollection?.updated
? `Generated ${recommendations.length} recommendations. Re-running create will update "${existingCollection.name}".`
: `Generated ${recommendations.length} recommendations.`;
} else if (body?.diagnostics?.tmdbEnabled && body?.diagnostics?.tmdbCandidates > 0 && body?.diagnostics?.tmdbResolved === 0) {
actionMessage = 'TMDB found similar titles, but none of them matched items currently in your Emby library.';
} else {
actionMessage = 'No recommendations were returned for these seeds.';
}
} catch (error) {
console.error('[Collections] Recommendation preview failed', {
request: requestPayload,
error
});
actionError = error.message;
recommendations = [];
existingCollection = null;
} finally {
previewBusy = false;
}
}
async function createCollection() {
if (!canCreate) return;
createBusy = true;
actionError = '';
actionMessage = '';
try {
const response = await fetch('/api/emby-collections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mode: 'create',
userId: sourceUser.embyGuid,
name: collectionName.trim(),
seedIds: selectedSeeds.map((seed) => seed.id),
limit: recommendationLimit,
includeSeeds,
profile: recommendationProfile
})
});
const body = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(body.message || body.error || response.statusText);
dispatch('created', {
collection: body.collection,
targetUserIds,
seeds: selectedSeeds,
recommendations: body.recommendations || []
});
existingCollection = body.collection || null;
actionMessage = body.collection?.updated
? `Updated collection "${body.collection.name}".`
: `Created collection "${body.collection?.name || collectionName.trim()}".`;
} catch (error) {
actionError = error.message;
} finally {
createBusy = false;
}
}
</script>
<div class="collection-page">
<div class="page-header">
<div>
<div class="page-title-row">
<span class="page-icon"><Icon name="collections" size={18} /></span>
<h2>Collections</h2>
</div>
<p class="page-copy">Build recommendation collections from recent activity, seed movies, and seed shows.</p>
</div>
<div class="page-actions">
<label class="field-inline compact field-select">
<span>Profile</span>
<select bind:value={recommendationProfile} on:change={handleProfileChange}>
{#each profileOptions as profile}
<option value={profile.id}>{profile.label}</option>
{/each}
</select>
</label>
<label class="field-inline compact">
<input type="checkbox" bind:checked={includeSeeds} />
<span>Include seeds in collection</span>
</label>
<label class="field-inline compact">
<span>Recommendation count</span>
<select bind:value={recommendationLimit}>
<option value={12}>12</option>
<option value={18}>18</option>
<option value={24}>24</option>
<option value={30}>30</option>
</select>
</label>
</div>
</div>
<div class="builder-grid">
<div class="builder-main">
<div class="builder-card hero-card">
<div class="field-grid">
<label class="field">
<span class="field-label">Source user</span>
<select bind:value={sourceUserId}>
{#each sourceUsers as user}
<option value={user.id}>{user.name}</option>
{/each}
</select>
</label>
<label class="field">
<span class="field-label">Collection name</span>
<input type="text" bind:value={collectionName} placeholder="Recommended for Family Night" />
</label>
</div>
<p class="profile-note">{selectedProfile.description}</p>
</div>
<div class="seed-grid">
<section class="builder-card">
<div class="card-header">
<div class="card-title-row">
<Icon name="activity" size={16} />
<h3>Watched Activity</h3>
</div>
<span class="card-copy">Quick-add from recent movies and shows.</span>
</div>
{#if recentError}
<div class="status error">{recentError}</div>
{:else if recentBusy}
<div class="status">Loading recent activity…</div>
{:else if recentItems.length}
<div class="result-list">
{#each recentItems as item}
<button class="result-card" on:click={() => addSeed(item)}>
<div class="result-name">{item.name}</div>
<div class="result-meta">{item.type}{item.datePlayed ? ` · ${new Date(item.datePlayed).toLocaleDateString()}` : ''}</div>
</button>
{/each}
</div>
{:else}
<div class="empty-note">No recent movie or show activity was available for this user.</div>
{/if}
</section>
<section class="builder-card">
<div class="card-header">
<div class="card-title-row">
<Icon name="search" size={16} />
<h3>Seed Movies</h3>
</div>
<span class="card-copy">Search Emby and add movie seeds.</span>
</div>
<div class="lookup-row">
<input
type="text"
bind:value={movieSearchTerm}
on:keydown={(event) => event.key === 'Enter' && searchItems('Movie')}
placeholder="Search movies..."
/>
<button class="btn ghost" on:click={() => searchItems('Movie')} disabled={movieSearchBusy}>
{movieSearchBusy ? 'Searching…' : 'Search'}
</button>
</div>
{#if movieSearchError}
<div class="status error">{movieSearchError}</div>
{/if}
{#if movieResults.length}
<div class="result-list">
{#each movieResults as item}
<button class="result-card" on:click={() => addSeed(item)}>
<div class="result-name">{item.name}</div>
<div class="result-meta">{item.year ? `${item.year} · ` : ''}{item.type}</div>
</button>
{/each}
</div>
{/if}
</section>
<section class="builder-card">
<div class="card-header">
<div class="card-title-row">
<Icon name="search" size={16} />
<h3>Seed Shows</h3>
</div>
<span class="card-copy">Search Emby and add TV seeds.</span>
</div>
<div class="lookup-row">
<input
type="text"
bind:value={showSearchTerm}
on:keydown={(event) => event.key === 'Enter' && searchItems('Series')}
placeholder="Search shows..."
/>
<button class="btn ghost" on:click={() => searchItems('Series')} disabled={showSearchBusy}>
{showSearchBusy ? 'Searching…' : 'Search'}
</button>
</div>
{#if showSearchError}
<div class="status error">{showSearchError}</div>
{/if}
{#if showResults.length}
<div class="result-list">
{#each showResults as item}
<button class="result-card" on:click={() => addSeed(item)}>
<div class="result-name">{item.name}</div>
<div class="result-meta">{item.year ? `${item.year} · ` : ''}{item.type}</div>
</button>
{/each}
</div>
{/if}
</section>
</div>
</div>
<div class="builder-side">
<section class="builder-card sticky-card">
<div class="card-header">
<div class="card-title-row">
<Icon name="spark" size={16} />
<h3>Selected Seeds</h3>
</div>
<span class="card-copy">Mix watched activity with manual seeds.</span>
</div>
{#if selectedSeeds.length}
<div class="seed-list">
{#each selectedSeeds as item}
<div class="seed-chip">
<div>
<div class="result-name">{item.name}</div>
<div class="result-meta">{item.type}{item.year ? ` · ${item.year}` : ''}</div>
</div>
<button class="remove-btn" on:click={() => removeSeed(item.id)}>×</button>
</div>
{/each}
</div>
{:else}
<div class="empty-note">Add at least one seed to generate recommendations.</div>
{/if}
<div class="divider"></div>
<div class="card-header compact">
<div class="card-title-row">
<Icon name="boxset" size={16} />
<h3>Apply Section To</h3>
</div>
</div>
<div class="target-list">
{#each users.filter((user) => user.embyGuid) as user}
<label class="target-option" class:selected={targetUserIds.includes(user.id)}>
<input
type="checkbox"
checked={targetUserIds.includes(user.id)}
on:change={() => toggleTargetUser(user.id)}
/>
<span>{user.name}</span>
<span class="target-count">{user.sections?.length || 0} sections</span>
</label>
{/each}
</div>
{#if recommendations.length}
<div class="divider"></div>
<div class="card-header compact">
<div class="card-title-row">
<Icon name="collections" size={16} />
<h3>Recommendation Preview</h3>
</div>
</div>
<div class="preview-list">
{#each recommendations as item}
<div class="preview-item">
<div class="result-name">{item.name}</div>
<div class="result-meta">
{item.type}{item.year ? ` · ${item.year}` : ''} · matched {item.matchCount}
{#if item.styleScore !== null && item.styleScore !== undefined}
· style {item.styleScore}
{/if}
{#if item.qualityScore !== null && item.qualityScore !== undefined}
· quality {item.qualityScore}
{/if}
</div>
</div>
{/each}
</div>
{/if}
{#if existingCollection?.id}
<div class="divider"></div>
<div class="status">
Collection target: {existingCollection.name} · existing box set will be updated
</div>
{/if}
{#if actionError}
<div class="status error">{actionError}</div>
{:else if actionMessage}
<div class="status ok">{actionMessage}</div>
{/if}
<div class="action-row">
<button class="btn ghost" on:click={previewRecommendations} disabled={!canPreview || previewBusy || createBusy}>
{previewBusy ? 'Generating…' : 'Preview Recommendations'}
</button>
<button class="btn accent" on:click={createCollection} disabled={!canCreate || previewBusy || createBusy}>
{createBusy ? (existingCollection?.updated ? 'Updating…' : 'Creating…') : (existingCollection?.updated ? 'Update Collection' : 'Create Collection')}
</button>
</div>
</section>
</div>
</div>
</div>
<style>
.collection-page {
display: flex;
flex-direction: column;
gap: 18px;
}
.page-header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
}
.page-title-row,
.card-title-row {
display: flex;
align-items: center;
gap: 10px;
}
h2,
h3 {
margin: 0;
color: var(--text);
}
h2 {
font-size: 26px;
font-weight: 800;
}
h3 {
font-size: 15px;
font-weight: 700;
}
.page-copy,
.card-copy {
margin: 6px 0 0;
font-size: 13px;
line-height: 1.5;
color: var(--text-muted);
}
.page-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.field-select {
min-width: 220px;
}
.builder-grid {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(320px, 0.9fr);
gap: 18px;
align-items: start;
}
.builder-main,
.builder-side,
.seed-grid {
display: flex;
flex-direction: column;
gap: 18px;
}
.seed-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.builder-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 18px;
padding: 18px;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.16);
}
.hero-card {
padding-bottom: 14px;
}
.profile-note {
margin: 12px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--text-muted);
}
.sticky-card {
position: sticky;
top: 24px;
}
.card-header.compact {
margin-bottom: 8px;
}
.field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
}
.field-inline {
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
font-size: 13px;
}
.field-inline.compact {
font-size: 12px;
}
input,
select {
width: 100%;
box-sizing: border-box;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: #0b0f14;
color: var(--text);
font-size: 13px;
font-family: inherit;
}
input:focus,
select:focus {
outline: none;
border-color: var(--border-strong);
}
.lookup-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
margin-top: 12px;
}
.result-list,
.seed-list,
.target-list,
.preview-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.result-card,
.seed-chip,
.target-option,
.preview-item {
border: 1px solid var(--border);
border-radius: 14px;
background: var(--bg-secondary);
padding: 12px 14px;
}
.result-card {
all: unset;
cursor: pointer;
border: 1px solid var(--border);
border-radius: 14px;
background: var(--bg-secondary);
padding: 12px 14px;
transition: background 0.12s ease, border-color 0.12s ease;
}
.result-card:hover,
.target-option:hover {
background: var(--surface-hover);
border-color: var(--border-strong);
}
.result-name {
font-size: 14px;
font-weight: 600;
color: var(--text);
}
.result-meta,
.target-count {
font-size: 12px;
color: var(--text-muted);
}
.seed-chip,
.target-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.target-option {
cursor: pointer;
}
.target-option.selected {
background: var(--surface-active);
border-color: var(--border-strong);
}
.preview-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.remove-btn {
all: unset;
cursor: pointer;
color: var(--text-muted);
font-size: 20px;
line-height: 1;
}
.remove-btn:hover {
color: var(--danger);
}
.empty-note {
padding: 14px;
border-radius: 14px;
background: var(--bg-secondary);
border: 1px dashed var(--border);
color: var(--text-muted);
font-size: 13px;
margin-top: 12px;
}
.divider {
height: 1px;
background: rgba(148, 163, 184, 0.1);
margin: 16px 0;
}
.action-row {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 16px;
}
.status {
padding: 10px 12px;
border-radius: 14px;
font-size: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
color: var(--text-muted);
margin-top: 12px;
}
.status.ok {
border-color: rgba(34, 197, 94, 0.3);
color: #86efac;
}
.status.error {
border-color: rgba(239, 68, 68, 0.3);
color: #fca5a5;
}
.btn {
all: unset;
cursor: pointer;
padding: 9px 16px;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
border: 1px solid transparent;
text-align: center;
}
.btn.ghost {
color: #d9e7f8;
border-color: var(--border);
background: var(--surface);
}
.btn.accent {
background: var(--accent);
color: #fff;
}
.btn:disabled {
opacity: 0.45;
cursor: default;
}
.page-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 10px;
background: var(--surface-active);
color: #a9d6ff;
}
@media (max-width: 1100px) {
.builder-grid {
grid-template-columns: 1fr;
}
.sticky-card {
position: static;
}
}
@media (max-width: 860px) {
.seed-grid,
.field-grid,
.lookup-row {
grid-template-columns: 1fr;
}
.page-header {
flex-direction: column;
}
.page-actions {
width: 100%;
}
}
</style>
+107
View File
@@ -0,0 +1,107 @@
<script>
export let name = 'spark';
export let size = 16;
export let stroke = 1.8;
const paths = {
edit: [
'M3 21h3.8L19.4 8.4a2.2 2.2 0 0 0 0-3.1l-.7-.7a2.2 2.2 0 0 0-3.1 0L3 17.2V21Z',
'M13.5 6.5l4 4'
],
sync: [
'M17 3l4 4-4 4',
'M3 7h18',
'M7 21l-4-4 4-4',
'M21 17H3'
],
settings: [
'M12 8.5a3.5 3.5 0 1 1 0 7a3.5 3.5 0 0 1 0-7Z',
'M19.4 15a1 1 0 0 0 .2 1.1l.1.1a1.2 1.2 0 0 1 0 1.7l-1.4 1.4a1.2 1.2 0 0 1-1.7 0l-.1-.1a1 1 0 0 0-1.1-.2a1 1 0 0 0-.6.9V21a1.2 1.2 0 0 1-1.2 1.2h-2a1.2 1.2 0 0 1-1.2-1.2v-.2a1 1 0 0 0-.6-.9a1 1 0 0 0-1.1.2l-.1.1a1.2 1.2 0 0 1-1.7 0L4.3 18a1.2 1.2 0 0 1 0-1.7l.1-.1a1 1 0 0 0 .2-1.1a1 1 0 0 0-.9-.6H3.5A1.2 1.2 0 0 1 2.3 13v-2a1.2 1.2 0 0 1 1.2-1.2h.2a1 1 0 0 0 .9-.6a1 1 0 0 0-.2-1.1l-.1-.1a1.2 1.2 0 0 1 0-1.7l1.4-1.4a1.2 1.2 0 0 1 1.7 0l.1.1a1 1 0 0 0 1.1.2a1 1 0 0 0 .6-.9V3A1.2 1.2 0 0 1 10.5 1.8h2A1.2 1.2 0 0 1 13.7 3v.2a1 1 0 0 0 .6.9a1 1 0 0 0 1.1-.2l.1-.1a1.2 1.2 0 0 1 1.7 0L18.6 5a1.2 1.2 0 0 1 0 1.7l-.1.1a1 1 0 0 0-.2 1.1a1 1 0 0 0 .9.6h.2A1.2 1.2 0 0 1 20.6 11v2a1.2 1.2 0 0 1-1.2 1.2h-.2a1 1 0 0 0-.9.8Z'
],
collections: [
'M4 7.5h16',
'M4 12h16',
'M4 16.5h10',
'M17.5 14.5v6',
'M14.5 17.5h6'
],
user: [
'M12 12a4 4 0 1 0 0-8a4 4 0 0 0 0 8Z',
'M4 20a8 8 0 0 1 16 0'
],
resume: [
'M7 5v14l11-7Z'
],
items: [
'M4 6.5h16',
'M4 12h16',
'M4 17.5h16'
],
userviews: [
'M4 5h7v6H4Z',
'M13 5h7v6h-7Z',
'M4 13h7v6H4Z',
'M13 13h7v6h-7Z'
],
boxset: [
'M4 8l8-4l8 4-8 4-8-4Z',
'M4 8v8l8 4l8-4V8'
],
latestepisodereleases: [
'M4 18h16',
'M7 18V9l5-4l5 4v9'
],
latestmoviereleases: [
'M4 7h16v10H4Z',
'M8 7V5',
'M16 7V5',
'M8 17v2',
'M16 17v2'
],
latestmediablock: [
'M4 6h16',
'M4 12h10',
'M4 18h16'
],
spark: [
'M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3Z'
],
search: [
'M11 18a7 7 0 1 0 0-14a7 7 0 0 0 0 14Z',
'M20 20l-3.5-3.5'
],
activity: [
'M5 12h3l2-5l4 10l2-5h3'
],
plus: [
'M12 5v14',
'M5 12h14'
]
};
$: selectedPaths = paths[name] || paths.spark;
</script>
<svg
class="icon"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width={stroke}
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
{#each selectedPaths as path}
<path d={path}></path>
{/each}
</svg>
<style>
.icon {
display: block;
flex: none;
}
</style>
+239 -39
View File
@@ -6,20 +6,25 @@
SORT_OPTIONS,
IMAGE_TYPES,
GENRES,
getSectionIconName,
getSectionTypeLabel,
getGenreNames
} from '$lib/constants.js';
import Icon from '$lib/Icon.svelte';
export let section;
export let index;
export let total;
export let expanded = false;
export let excludedFolderLookup = {};
export let lookupUserId = '';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
$: genreNames = getGenreNames(section.Query?.GenreIds);
$: typeLabel = getSectionTypeLabel(section.SectionType);
$: sectionIcon = getSectionIconName(section.SectionType);
$: showFilters =
section.SectionType === 'items' ||
section.SectionType === 'collections' ||
@@ -28,10 +33,20 @@
// ExcludedFolders as a comma-separated string for editing
$: excludedFoldersStr = (section.ExcludedFolders || []).join(', ');
$: excludedFolderDetails = (section.ExcludedFolders || []).map((id) => ({
id,
label: excludedFolderLookup?.[id]?.name || null,
type: excludedFolderLookup?.[id]?.type || null
}));
// TagIds as a comma-separated string for editing
$: tagIdsStr = (section.Query?.TagIds || []).join(', ');
let collectionSearchTerm = '';
let collectionResults = [];
let collectionLookupBusy = false;
let collectionLookupError = '';
function toggleGenre(id) {
if (!section.Query) section.Query = { StudioIds: [], TagIds: [], GenreIds: [], CollectionTypes: [] };
const idx = section.Query.GenreIds.indexOf(id);
@@ -75,6 +90,59 @@
.filter((s) => s !== '');
dispatch('change');
}
async function searchCollections() {
if (!lookupUserId) {
collectionLookupError = 'Select a user with a linked Emby account to search collections.';
return;
}
if (!collectionSearchTerm.trim()) {
collectionResults = [];
collectionLookupError = '';
return;
}
collectionLookupBusy = true;
collectionLookupError = '';
try {
const params = new URLSearchParams({
userId: lookupUserId,
term: collectionSearchTerm.trim(),
types: 'BoxSet',
limit: '8'
});
const response = await fetch(`/api/emby-item-search?${params.toString()}`);
const body = await response.json().catch(() => ({ items: [] }));
if (!response.ok) throw new Error(body.message || body.error || response.statusText);
collectionResults = body.items || [];
} catch (err) {
collectionLookupError = err.message;
collectionResults = [];
} finally {
collectionLookupBusy = false;
}
}
function applyCollection(item) {
const previousName = section.ParentItem?.Name || section.Name || section.CustomName || '';
if (!section.ParentItem) section.ParentItem = { Name: '', Id: '' };
section.ParentItem.Name = item.name;
section.ParentItem.Id = item.id;
section.ParentId = item.id;
if (!section.CustomName || section.CustomName === previousName) {
section.CustomName = item.name;
}
if (!section.Name || section.Name === previousName) {
section.Name = item.name;
}
collectionSearchTerm = item.name;
collectionResults = [];
dispatch('change');
}
</script>
<div class="section-card" class:expanded>
@@ -97,6 +165,10 @@
<span class="section-index">{index + 1}</span>
<span class="section-icon">
<Icon name={sectionIcon} size={16} />
</span>
<div class="section-info">
<div class="section-name">{section.CustomName || section.Name || '(unnamed)'}</div>
<div class="section-meta">
@@ -312,12 +384,31 @@
{#if section.SectionType === 'boxset'}
<div class="field-group">
<span class="field-label">Linked box set</span>
{#if section.ParentItem}
<div class="boxset-info">
<span>{section.ParentItem.Name}</span>
<span class="text-muted">ID: {section.ParentItem.Id}</span>
<div class="lookup-row">
<input
type="text"
class="ids-input"
bind:value={collectionSearchTerm}
on:keydown={(event) => event.key === 'Enter' && searchCollections()}
placeholder="Search existing collections..."
/>
<button class="chip lookup-btn" on:click={searchCollections} disabled={collectionLookupBusy}>
{collectionLookupBusy ? 'Searching…' : 'Lookup'}
</button>
</div>
{:else}
{#if collectionLookupError}
<div class="inline-status error">{collectionLookupError}</div>
{/if}
{#if collectionResults.length}
<div class="lookup-results">
{#each collectionResults as item}
<button class="resolved-item lookup-result" on:click={() => applyCollection(item)}>
<span class="resolved-name">{item.name}</span>
<span class="resolved-meta">{item.id}</span>
</button>
{/each}
</div>
{/if}
<div class="field-grid">
<label class="field">
<span class="field-label">Box set name</span>
@@ -348,6 +439,11 @@
/>
</label>
</div>
{#if section.ParentItem?.Id}
<div class="boxset-info">
<span>{section.ParentItem.Name}</span>
<span class="text-muted">ID: {section.ParentItem.Id}</span>
</div>
{/if}
</div>
{/if}
@@ -381,6 +477,16 @@
{/each}
</div>
{/if}
{#if excludedFolderDetails.some((folder) => folder.label)}
<div class="resolved-list">
{#each excludedFolderDetails.filter((folder) => folder.label) as folder}
<div class="resolved-item">
<span class="resolved-name">{folder.label}</span>
<span class="resolved-meta">{folder.id}{folder.type ? ` · ${folder.type}` : ''}</span>
</div>
{/each}
</div>
{/if}
</div>
</div>
@@ -390,20 +496,26 @@
<style>
.section-card {
background: var(--surface);
border-radius: 10px;
border-radius: 20px;
border: 1px solid var(--border);
margin-bottom: 6px;
margin-bottom: 12px;
overflow: hidden;
transition: border-color 0.15s;
box-shadow: 0 18px 36px rgba(0, 0, 0, 0.18);
transition: border-color 0.15s, transform 0.15s ease, box-shadow 0.15s ease;
}
.section-card:hover {
transform: translateY(-1px);
box-shadow: 0 22px 42px rgba(0, 0, 0, 0.22);
}
.section-card.expanded {
border-color: var(--accent);
border-color: var(--border-strong);
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.26);
}
.section-header {
all: unset;
display: flex;
align-items: center;
padding: 10px 14px;
padding: 14px 16px;
gap: 10px;
cursor: pointer;
width: 100%;
@@ -422,8 +534,8 @@
cursor: pointer;
font-size: 10px;
line-height: 1;
opacity: 0.5;
padding: 1px 2px;
opacity: 0.6;
padding: 3px 4px;
color: var(--text);
}
.move-btn:disabled {
@@ -436,17 +548,29 @@
.section-index {
font-size: 11px;
color: var(--text-muted);
min-width: 18px;
min-width: 24px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.section-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
color: #a9d6ff;
flex-shrink: 0;
}
.section-info {
flex: 1;
min-width: 0;
}
.section-name {
font-weight: 600;
font-size: 14px;
font-size: 15px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
@@ -455,7 +579,7 @@
.section-meta {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
margin-top: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -464,7 +588,7 @@
all: unset;
cursor: pointer;
color: var(--danger);
font-size: 20px;
font-size: 22px;
line-height: 1;
padding: 0 4px;
opacity: 0.5;
@@ -480,14 +604,14 @@
/* Editor panel */
.section-editor {
padding: 4px 16px 16px;
padding: 8px 16px 18px;
border-top: 1px solid var(--border);
}
.field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 12px;
gap: 12px;
margin-bottom: 14px;
}
.field-grid.checkboxes {
grid-template-columns: auto auto auto;
@@ -498,7 +622,7 @@
.field {
display: flex;
flex-direction: column;
gap: 4px;
gap: 6px;
}
.field.span2 {
grid-column: span 2;
@@ -519,10 +643,10 @@
}
.field input[type='text'],
.field select {
padding: 7px 10px;
border-radius: 6px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--bg);
background: #0b0f14;
color: var(--text);
font-size: 13px;
font-family: inherit;
@@ -530,12 +654,52 @@
.field input:focus,
.field select:focus {
outline: none;
border-color: var(--accent);
border-color: var(--border-strong);
}
.field-group {
margin-bottom: 12px;
}
.lookup-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: start;
}
.lookup-btn {
padding: 10px 12px;
border-radius: 12px;
text-align: center;
color: var(--text);
background: var(--surface);
}
.lookup-btn:disabled {
opacity: 0.45;
cursor: default;
}
.lookup-results {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.lookup-result {
all: unset;
cursor: pointer;
}
.inline-status {
margin-top: 8px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--surface);
font-size: 12px;
color: var(--text-muted);
}
.inline-status.error {
color: #fca5a5;
border-color: rgba(239, 68, 68, 0.24);
}
.chip-group {
display: flex;
flex-wrap: wrap;
@@ -545,8 +709,8 @@
.chip {
all: unset;
cursor: pointer;
padding: 4px 10px;
border-radius: 14px;
padding: 6px 11px;
border-radius: 999px;
font-size: 12px;
border: 1px solid var(--border);
color: var(--text-muted);
@@ -558,7 +722,7 @@
}
.chip.active {
background: var(--accent);
border-color: var(--accent);
border-color: transparent;
color: #fff;
}
@@ -575,10 +739,10 @@
.ids-input {
width: 100%;
margin-top: 6px;
padding: 7px 10px;
border-radius: 6px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--bg);
background: #0b0f14;
color: var(--text);
font-size: 12px;
font-family: monospace;
@@ -586,7 +750,7 @@
}
.ids-input:focus {
outline: none;
border-color: var(--accent);
border-color: var(--border-strong);
}
.ids-preview {
display: flex;
@@ -598,10 +762,10 @@
display: inline-flex;
align-items: center;
gap: 3px;
background: var(--bg);
background: #0b0f14;
border: 1px solid var(--border);
border-radius: 4px;
padding: 2px 6px 2px 8px;
border-radius: 999px;
padding: 4px 8px 4px 10px;
font-size: 11px;
font-family: monospace;
color: var(--text-muted);
@@ -621,8 +785,8 @@
display: inline-block;
background: var(--accent);
color: #fff;
border-radius: 8px;
padding: 0 5px;
border-radius: 999px;
padding: 2px 6px;
font-size: 10px;
font-weight: 700;
margin-left: 4px;
@@ -638,12 +802,48 @@
font-size: 13px;
color: var(--text);
margin-top: 6px;
padding: 6px 10px;
background: var(--bg);
border-radius: 6px;
padding: 10px 12px;
background: #0b0f14;
border-radius: 12px;
}
.text-muted {
color: var(--text-muted);
font-size: 12px;
}
.resolved-list {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.resolved-item {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 8px 10px;
border-radius: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
}
.lookup-result:hover {
border-color: var(--border-strong);
background: var(--surface-hover);
}
.resolved-name {
color: var(--text);
font-size: 12px;
font-weight: 600;
}
.resolved-meta {
color: var(--text-muted);
font-size: 11px;
font-family: monospace;
text-align: right;
}
@media (max-width: 640px) {
.lookup-row {
grid-template-columns: 1fr;
}
}
</style>
+73 -33
View File
@@ -2,7 +2,7 @@
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
export let config = { embyUrl: '', apiKey: '', dbPath: '' };
export let config = { embyUrl: '', apiKey: '', tmdbApiKey: '', dbPath: '' };
let localConfig = { ...config };
let status = '';
@@ -14,6 +14,19 @@
statusType = type;
}
async function fetchEmbyUsers() {
const res = await fetch('/api/emby-users');
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(err.message || res.statusText);
}
const payload = await res.json();
return Array.isArray(payload)
? { users: payload, source: 'live', lastSyncedAt: null, message: '' }
: payload;
}
async function saveConfig() {
busy = true;
setStatus('Saving…');
@@ -48,13 +61,15 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(localConfig)
});
const res = await fetch('/api/emby-users');
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(err.message || res.statusText);
const payload = await fetchEmbyUsers();
if (payload.source === 'cache') {
setStatus(
`${payload.message} Loaded ${payload.users.length} cached users from ${payload.lastSyncedAt || 'the last successful sync'}.`,
'ok'
);
} else {
setStatus(`Connected — ${payload.users.length} users found and cached locally.`, 'ok');
}
const users = await res.json();
setStatus(`Connected — ${users.length} users found.`, 'ok');
} catch (e) {
setStatus(`Connection failed: ${e.message}`, 'error');
} finally {
@@ -66,14 +81,16 @@
busy = true;
setStatus('Fetching user names from Emby…');
try {
const res = await fetch('/api/emby-users');
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(err.message || res.statusText);
const payload = await fetchEmbyUsers();
dispatch('namesRefreshed', payload);
if (payload.source === 'cache') {
setStatus(
`${payload.message} Refreshed ${payload.users.length} users from the local cache.`,
'ok'
);
} else {
setStatus(`Names refreshed — ${payload.users.length} users from Emby and saved locally.`, 'ok');
}
const embyUsers = await res.json();
dispatch('namesRefreshed', embyUsers);
setStatus(`Names refreshed — ${embyUsers.length} users from Emby.`, 'ok');
} catch (e) {
setStatus(`Failed: ${e.message}`, 'error');
} finally {
@@ -148,6 +165,25 @@
</div>
</section>
<section>
<h4>Recommendations</h4>
<label>
<span>TMDB API key</span>
<input
type="password"
bind:value={localConfig.tmdbApiKey}
placeholder="Paste your TMDB v3 API key"
/>
</label>
<p class="hint">
Stored locally in the app config so recommendation features can reuse it without re-entering
the key each time.
</p>
<div class="row">
<button class="btn ghost" on:click={saveConfig} disabled={busy}>Save TMDB key</button>
</div>
</section>
<section>
<h4>Database file</h4>
<label>
@@ -182,9 +218,9 @@
padding: 0 4px;
}
h3 {
font-size: 15px;
font-size: 18px;
font-weight: 700;
margin: 0 0 16px;
margin: 0 0 18px;
color: var(--text);
}
h4 {
@@ -196,12 +232,15 @@
margin: 0 0 10px;
}
section {
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border);
margin-bottom: 18px;
padding: 16px;
border: 1px solid var(--border);
border-radius: 18px;
background: var(--surface);
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.16);
}
section:last-of-type {
border-bottom: none;
margin-bottom: 12px;
}
label {
display: flex;
@@ -215,10 +254,10 @@
font-weight: 500;
}
input {
background: var(--bg);
background: #0b0f14;
border: 1px solid var(--border);
border-radius: 6px;
padding: 7px 10px;
border-radius: 12px;
padding: 10px 12px;
font-size: 13px;
color: var(--text);
font-family: inherit;
@@ -227,7 +266,7 @@
}
input:focus {
outline: none;
border-color: var(--accent);
border-color: var(--border-strong);
}
.hint {
font-size: 11px;
@@ -237,9 +276,9 @@
}
code {
font-size: 11px;
background: var(--bg);
padding: 1px 4px;
border-radius: 3px;
background: #0b0f14;
padding: 2px 5px;
border-radius: 6px;
}
.row {
display: flex;
@@ -249,16 +288,17 @@
.btn {
all: unset;
cursor: pointer;
padding: 6px 14px;
border-radius: 6px;
padding: 9px 14px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
font-family: inherit;
transition: all 0.12s;
}
.btn.ghost {
color: var(--text-muted);
color: #d9e7f8;
border: 1px solid var(--border);
background: var(--bg-secondary);
}
.btn.ghost:hover:not(:disabled) {
background: var(--surface-hover);
@@ -277,10 +317,10 @@
}
.status {
margin-top: 8px;
padding: 8px 12px;
border-radius: 6px;
padding: 10px 12px;
border-radius: 14px;
font-size: 12px;
background: var(--surface-hover);
background: var(--surface);
color: var(--text-muted);
border-left: 3px solid var(--border);
}
+11 -4
View File
@@ -23,8 +23,15 @@
}
</script>
<div class="overlay" on:click|self={() => dispatch('close')} on:keydown={() => {}}>
<div class="modal">
<div
class="overlay"
role="button"
tabindex="0"
aria-label="Close generated SQL modal"
on:click|self={() => dispatch('close')}
on:keydown={(event) => (event.key === 'Escape' || event.key === 'Enter') && dispatch('close')}
>
<div class="modal" role="dialog" aria-modal="true" aria-label="Generated SQL">
<div class="modal-header">
<h3>Generated SQL</h3>
<button class="close-btn" on:click={() => dispatch('close')}>×</button>
@@ -47,7 +54,7 @@
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
background: rgba(0, 0, 0, 0.72);
display: flex;
align-items: center;
justify-content: center;
@@ -55,7 +62,7 @@
padding: 20px;
}
.modal {
background: var(--bg);
background: var(--surface-strong);
border-radius: 14px;
width: 100%;
max-width: 720px;
+23 -20
View File
@@ -158,9 +158,9 @@
padding: 0;
}
h3 {
font-size: 15px;
font-weight: 600;
margin: 0 0 16px;
font-size: 18px;
font-weight: 700;
margin: 0 0 18px;
color: var(--text);
}
.sync-step {
@@ -183,10 +183,10 @@
}
.select-input {
width: 100%;
padding: 8px 10px;
border-radius: 6px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: var(--bg);
background: #0b0f14;
color: var(--text);
font-size: 13px;
font-family: inherit;
@@ -201,15 +201,16 @@
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 12px;
border-radius: 8px;
padding: 10px 12px;
border-radius: 16px;
border: 1px solid var(--border);
background: var(--surface);
cursor: pointer;
transition: all 0.12s;
}
.mode-option.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
border-color: var(--border-strong);
background: var(--surface-active);
}
.mode-option input {
margin-top: 2px;
@@ -235,8 +236,8 @@
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 6px;
padding: 8px 10px;
border-radius: 12px;
cursor: pointer;
font-size: 13px;
}
@@ -244,7 +245,7 @@
background: var(--surface-hover);
}
.section-pick.selected {
background: color-mix(in srgb, var(--accent) 10%, transparent);
background: var(--surface-active);
}
.pick-name {
flex: 1;
@@ -264,8 +265,8 @@
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 6px;
padding: 8px 10px;
border-radius: 12px;
cursor: pointer;
font-size: 13px;
}
@@ -273,7 +274,7 @@
background: var(--surface-hover);
}
.target-option.selected {
background: color-mix(in srgb, var(--accent) 10%, transparent);
background: var(--surface-active);
}
.target-name {
flex: 1;
@@ -300,18 +301,20 @@
display: block;
width: 100%;
text-align: center;
padding: 10px;
border-radius: 8px;
padding: 12px;
border-radius: 16px;
background: var(--accent);
color: #fff;
font-size: 14px;
font-weight: 600;
font-family: inherit;
box-sizing: border-box;
transition: opacity 0.12s;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.2);
transition: opacity 0.12s, transform 0.12s ease;
}
.sync-btn:hover:not(:disabled) {
opacity: 0.9;
opacity: 0.95;
transform: translateY(-1px);
}
.sync-btn:disabled {
opacity: 0.4;
+386
View File
@@ -0,0 +1,386 @@
export function normalizeLookupItem(item) {
const derivedYear = Number(
String(
item?.year ??
item?.ProductionYear ??
item?.release_date ??
item?.first_air_date ??
''
).slice(0, 4)
) || null;
const providerIds =
item?.providerIds ||
item?.ProviderIds ||
(item?.tmdbId ? { Tmdb: String(item.tmdbId) } : {});
return {
id: String(item?.id ?? item?.Id ?? ''),
name: item?.name || item?.Name || item?.SeriesName || item?.title || 'Unnamed item',
type:
item?.type ||
item?.Type ||
item?.CollectionType ||
(item?.mediaType === 'tv' ? 'Series' : item?.mediaType === 'movie' ? 'Movie' : 'Item'),
overview: item?.overview || item?.Overview || '',
year: derivedYear,
communityRating: item?.communityRating ?? item?.CommunityRating ?? item?.voteAverage ?? item?.vote_average ?? null,
providerIds,
genres: Array.isArray(item?.genres)
? item.genres.filter(Boolean)
: Array.isArray(item?.Genres)
? item.Genres.filter(Boolean)
: Array.isArray(item?.GenreItems)
? item.GenreItems.map((genre) => genre?.Name).filter(Boolean)
: [],
parentId: item?.parentId ?? item?.ParentId ?? null
};
}
const POSITIVE_GENRES = {
Romance: 18,
Comedy: 16,
Drama: 10,
Music: 4
};
const NEGATIVE_GENRES = {
Animation: -30,
Horror: -25,
'Science Fiction': -20,
Action: -16,
Thriller: -14,
Crime: -10,
War: -18,
Western: -14,
Documentary: -18,
Fantasy: -8,
Family: -6
};
const POSITIVE_TERMS = [
'wedding',
'love',
'romance',
'relationship',
'bride',
'best friend',
'friendship',
'family',
'holiday',
'food',
'small town',
'bookstore',
'restaurant',
'writer',
'divorce',
'second chance',
'starting over',
'feel-good',
'mother',
'daughter',
'sisters',
'chosen family',
'comedy of manners'
];
const NEGATIVE_TERMS = [
'war',
'serial killer',
'murder spree',
'mercenary',
'zombie',
'post-apocalyptic',
'gang',
'assassin',
'combat',
'superhero',
'multiverse',
'alien invasion',
'dystopian',
'dragon',
'animated adventure'
];
const SEED_BONUS_TERMS = [
'ensemble',
'relationship',
'family',
'comedy',
'romantic',
'wedding',
'friendship',
'identity',
'midlife',
'second chance'
];
export const RECOMMENDATION_PROFILES = {
balanced: {
id: 'balanced',
label: 'Balanced',
description: 'Default Emby-style recommendation overlap ranking.'
},
classicComfort: {
id: 'classicComfort',
label: 'Classic Comfort',
description: 'Bias toward older, warm, highly-rated movies and shows inspired by test.py.'
}
};
function normalizeLimit(limit) {
return Math.min(Math.max(Number(limit || 24), 1), 48);
}
function resolveRankingOptions(limitOrOptions) {
if (typeof limitOrOptions === 'number' || limitOrOptions === undefined) {
return {
limit: normalizeLimit(limitOrOptions),
profile: 'balanced',
seeds: [],
excludeIds: [],
allowedTypes: []
};
}
return {
limit: normalizeLimit(limitOrOptions?.limit),
profile: RECOMMENDATION_PROFILES[limitOrOptions?.profile] ? limitOrOptions.profile : 'balanced',
seeds: Array.isArray(limitOrOptions?.seeds) ? limitOrOptions.seeds.map(normalizeLookupItem) : [],
excludeIds: Array.isArray(limitOrOptions?.excludeIds)
? limitOrOptions.excludeIds.map((id) => String(id).trim()).filter(Boolean)
: [],
allowedTypes: Array.isArray(limitOrOptions?.allowedTypes)
? limitOrOptions.allowedTypes.map((type) => String(type).trim()).filter(Boolean)
: []
};
}
function genreBiasScore(genreNames, overview) {
let score = 0;
for (const genre of genreNames || []) {
score += POSITIVE_GENRES[genre] || 0;
score += NEGATIVE_GENRES[genre] || 0;
}
const text = String(overview || '').toLowerCase();
for (const term of POSITIVE_TERMS) {
if (text.includes(term)) score += 2;
}
for (const term of NEGATIVE_TERMS) {
if (text.includes(term)) score -= 2;
}
return score;
}
function classicBonus(year, seedHits, overview, seedGenres) {
let bonus = 0;
if (year !== null && year !== undefined) {
if (year >= 1985 && year <= 2008) bonus += 10;
else if (year >= 2009 && year <= 2012) bonus += 4;
else if (year < 1985) bonus += 2;
else bonus -= 8;
}
bonus += Math.min(seedHits * 5, 20);
const text = String(overview || '').toLowerCase();
for (const term of SEED_BONUS_TERMS) {
if (text.includes(term)) bonus += 1;
}
for (const genre of seedGenres) {
if ((overview || '').toLowerCase().includes(genre.toLowerCase())) {
bonus += 1;
}
}
return bonus;
}
function buildSeedContext(seeds) {
const genreHits = new Set();
const mediaTypes = new Set();
for (const seed of seeds || []) {
for (const genre of seed.genres || []) {
genreHits.add(genre);
}
if (seed.type) mediaTypes.add(seed.type);
}
return { genres: genreHits, mediaTypes };
}
function scoreSeedAffinity(item, seedContext) {
const genres = new Set(item.genres || []);
const text = `${item.name || ''} ${item.overview || ''}`.toLowerCase();
let score = 0;
for (const genre of seedContext.genres) {
if (genres.has(genre)) score += 8;
}
const warmSeed =
seedContext.genres.has('Romance') ||
seedContext.genres.has('Drama') ||
seedContext.genres.has('Comedy') ||
seedContext.genres.has('Family');
if (warmSeed) {
if (genres.has('Romance')) score += 12;
if (genres.has('Drama')) score += 10;
if (genres.has('Comedy')) score += 8;
if (genres.has('Family')) score += 5;
if (genres.has('Horror')) score -= 28;
if (genres.has('Science Fiction')) score -= 18;
if (genres.has('Action')) score -= 16;
if (genres.has('Thriller')) score -= 14;
if (genres.has('War')) score -= 10;
if (genres.has('Crime')) score -= 8;
for (const term of POSITIVE_TERMS) {
if (text.includes(term)) score += 1;
}
for (const term of NEGATIVE_TERMS) {
if (text.includes(term)) score -= 2;
}
}
return score;
}
function normalizeResultSet(resultSet) {
if (Array.isArray(resultSet)) {
return {
items: resultSet,
sourceWeight: 1
};
}
return {
items: Array.isArray(resultSet?.items) ? resultSet.items : [],
sourceWeight: Math.max(1, Number(resultSet?.sourceWeight || 1))
};
}
function evaluateClassicComfort(item, matches, seedContext) {
const genres = item.genres || [];
const year = Number.isFinite(Number(item.year)) ? Number(item.year) : null;
const rating = Number.isFinite(Number(item.communityRating)) ? Number(item.communityRating) : null;
const hasAnimation = genres.includes('Animation');
if (hasAnimation) return { keep: false };
if (year !== null && (year < 1980 || year > 2012)) return { keep: false };
if (rating !== null && rating < 6.3) return { keep: false };
const bias = genreBiasScore(genres, item.overview);
const bonus = classicBonus(year, matches, `${item.name} ${item.overview}`, seedContext.genres);
const styleScore = bias + bonus;
if (styleScore < 16) {
return { keep: false };
}
return {
keep: true,
styleScore,
qualityScore: rating !== null ? Math.round(rating * 10) : null
};
}
export function rankRecommendationResults(seedIds, resultSets, limitOrOptions = 24) {
const options = resolveRankingOptions(limitOrOptions);
const excluded = new Set([
...(seedIds || []).map((id) => String(id).trim()),
...options.excludeIds
].filter(Boolean));
const allowedTypes = new Set(options.allowedTypes);
const scored = new Map();
for (const resultSet of resultSets || []) {
const normalizedSet = normalizeResultSet(resultSet);
const seenInSet = new Set();
const items = normalizedSet.items;
for (let index = 0; index < items.length; index += 1) {
const item = normalizeLookupItem(items[index]);
if (!item.id || excluded.has(item.id)) continue;
if (allowedTypes.size > 0 && !allowedTypes.has(item.type)) continue;
if (seenInSet.has(item.id)) continue;
seenInSet.add(item.id);
const weight = Math.max(1, items.length - index) * normalizedSet.sourceWeight;
const current = scored.get(item.id) || {
item,
score: 0,
matches: 0,
sourceStrength: 0,
bestRank: Number.POSITIVE_INFINITY
};
current.item = item;
current.score += weight;
current.matches += 1;
current.sourceStrength += normalizedSet.sourceWeight;
current.bestRank = Math.min(current.bestRank, index);
scored.set(item.id, current);
}
}
const seedContext = buildSeedContext(options.seeds);
return [...scored.values()]
.map((entry) => {
if (options.profile !== 'classicComfort') {
const affinityScore = scoreSeedAffinity(entry.item, seedContext);
return {
...entry,
affinityScore,
totalScore:
(entry.sourceStrength * 20) +
entry.score +
affinityScore,
styleScore: null,
qualityScore: entry.item.communityRating !== null
? Math.round(Number(entry.item.communityRating) * 10)
: null
};
}
const style = evaluateClassicComfort(entry.item, entry.matches, seedContext);
if (!style.keep) return null;
return {
...entry,
styleScore: style.styleScore,
qualityScore: style.qualityScore,
totalScore:
(entry.sourceStrength * 20) +
entry.score +
(style.styleScore * 2) +
(style.qualityScore || 0)
};
})
.filter(Boolean)
.sort((a, b) => {
if (b.totalScore !== a.totalScore) return b.totalScore - a.totalScore;
if ((b.sourceStrength || 0) !== (a.sourceStrength || 0)) return (b.sourceStrength || 0) - (a.sourceStrength || 0);
if (b.matches !== a.matches) return b.matches - a.matches;
if ((b.styleScore || 0) !== (a.styleScore || 0)) return (b.styleScore || 0) - (a.styleScore || 0);
if ((b.qualityScore || 0) !== (a.qualityScore || 0)) return (b.qualityScore || 0) - (a.qualityScore || 0);
if (b.score !== a.score) return b.score - a.score;
if (a.bestRank !== b.bestRank) return a.bestRank - b.bestRank;
return a.item.name.localeCompare(b.item.name);
})
.slice(0, options.limit)
.map((entry) => ({
...entry.item,
matchCount: entry.matches,
score: entry.score,
totalScore: entry.totalScore,
sourceStrength: entry.sourceStrength,
affinityScore: entry.affinityScore ?? null,
styleScore: entry.styleScore,
qualityScore: entry.qualityScore
}));
}
+117 -11
View File
@@ -43,6 +43,7 @@ export const ITEM_TYPES = ['Movie', 'Series', 'Episode', 'BoxSet'];
export const SORT_OPTIONS = [
{ value: '', label: '(none)' },
{ value: 'default', label: 'Default (boxset)' },
{ value: 'DatePlayed', label: 'Date played' },
{ value: 'DateLastContentAdded,SortName', label: 'Date added' },
{ value: 'ProductionYear,PremiereDate,SortName', label: 'Release year' },
{ value: 'CommunityRating', label: 'Community rating' },
@@ -58,6 +59,24 @@ export const IMAGE_TYPES = [
{ value: 'Primary', label: 'Primary / Poster' }
];
export const PAGE_ICONS = {
edit: 'edit',
sync: 'sync',
collections: 'collections',
settings: 'settings'
};
export const SECTION_ICONS = {
resume: 'resume',
items: 'items',
userviews: 'userviews',
boxset: 'boxset',
collections: 'collections',
latestepisodereleases: 'latestepisodereleases',
latestmoviereleases: 'latestmoviereleases',
latestmediablock: 'latestmediablock'
};
export function genId() {
return crypto.randomUUID().replace(/-/g, '').slice(0, 32);
}
@@ -88,11 +107,64 @@ export function createEmptySection(userId) {
};
}
export function createRecentlyWatchedSection(userId, userName = '') {
return {
UserId: userId,
Name: `Recently Watched${userName ? ` - ${userName}` : ''}`,
CustomName: `Recently Watched${userName ? ` - ${userName}` : ''}`,
Id: genId(),
SectionType: 'items',
ImageType: 'Thumb',
CollectionType: '',
SortBy: 'DatePlayed',
SortOrder: 'Descending',
Monitor: [],
ItemTypes: ['Movie', 'Series'],
ExcludedFolders: [],
CardSizeOffset: 0,
IncludeNextUpInResume: true,
Query: {
StudioIds: [],
TagIds: [],
GenreIds: [],
CollectionTypes: [],
IsPlayed: true
}
};
}
export function createBoxSetSection(userId, collectionName, collectionId) {
return {
UserId: userId,
Name: collectionName || 'New Collection',
CustomName: collectionName || 'New Collection',
Id: genId(),
SectionType: 'boxset',
ImageType: 'Thumb',
ItemTypes: [],
SortBy: 'default',
SortOrder: 'Descending',
Monitor: [],
ExcludedFolders: [],
CardSizeOffset: 0,
IncludeNextUpInResume: true,
ParentItem: {
Name: collectionName || 'New Collection',
Id: String(collectionId || '')
},
ParentId: String(collectionId || '')
};
}
export function getSectionTypeLabel(type) {
const found = SECTION_TYPES.find((t) => t.value === type);
return found ? found.label : type;
}
export function getSectionIconName(type) {
return SECTION_ICONS[type] || 'spark';
}
export function getGenreNames(genreIds) {
if (!genreIds || genreIds.length === 0) return '';
return genreIds.map((id) => GENRES[id] || id).join(', ');
@@ -101,15 +173,9 @@ export function getGenreNames(genreIds) {
export function extractUserName(sections) {
for (const s of sections) {
const name = s.CustomName || s.Name || '';
const lower = name.toLowerCase();
if (lower.includes('watchlist')) {
const extracted = name
.replace(/['']s\s*Watchlist/i, '')
.replace(/\s*Watchlist/i, '')
.trim();
const extracted = extractWatchlistOwnerName(name);
if (extracted && extracted !== name) return extracted;
}
}
return null;
}
@@ -122,11 +188,11 @@ export function isWatchlistSection(section) {
function renameWatchlistLabel(label, targetName) {
if (!label || !targetName) return label;
if (/^\s*watch\s*list\s*$/i.test(label)) return 'Watch List';
if (/^\s*watch\s+list\s*$/i.test(label)) return 'Watch List';
if (/^\s*watchlist\s*$/i.test(label)) return 'Watchlist';
if (/watch\s*list/i.test(label)) {
return label.replace(/^.*?watch\s*list/i, `${targetName}'s Watch List`);
if (/watch\s+list/i.test(label)) {
return label.replace(/^.*?watch\s+list/i, `${targetName}'s Watch List`);
}
if (/watchlist/i.test(label)) {
@@ -136,17 +202,57 @@ function renameWatchlistLabel(label, targetName) {
return label;
}
function extractWatchlistOwnerName(label) {
if (!label) return '';
const normalized = String(label).trim();
if (!/watchlist|watch\s+list/i.test(normalized)) return '';
return normalized
.replace(/\bwatchlist\b/i, '')
.replace(/\bwatch\s+list\b/i, '')
.replace(/[']s$/i, '')
.trim();
}
function normalizeNameForCompare(name) {
return String(name || '')
.toLowerCase()
.replace(/[']/g, '')
.replace(/[^a-z0-9]+/g, '');
}
function labelsMatchUser(label, targetName) {
const owner = extractWatchlistOwnerName(label);
if (!owner || !targetName) return false;
return normalizeNameForCompare(owner) === normalizeNameForCompare(targetName);
}
function getPreferredUserName(user) {
return user?.embyName || user?.name || '';
}
export function getWatchlistLabelsForTarget(sourceSection, targetUser) {
const existingTargetWatchlist = targetUser?.sections?.find((section) => isWatchlistSection(section));
const targetName = getPreferredUserName(targetUser);
if (existingTargetWatchlist) {
const existingName = existingTargetWatchlist.CustomName || existingTargetWatchlist.Name || '';
if (!targetName || labelsMatchUser(existingName, targetName)) {
return {
Name: existingTargetWatchlist.Name || sourceSection.Name,
CustomName: existingTargetWatchlist.CustomName || existingTargetWatchlist.Name || sourceSection.CustomName || sourceSection.Name
};
}
const targetName = targetUser?.name || '';
return {
Name: renameWatchlistLabel(existingTargetWatchlist.Name || sourceSection.Name, targetName),
CustomName: renameWatchlistLabel(
existingTargetWatchlist.CustomName || existingTargetWatchlist.Name || sourceSection.CustomName || sourceSection.Name,
targetName
)
};
}
return {
Name: renameWatchlistLabel(sourceSection.Name, targetName),
CustomName: renameWatchlistLabel(sourceSection.CustomName, targetName)
+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();
}
+139
View File
@@ -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
}
};
}
+60
View File
@@ -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;
}
+7
View File
@@ -168,6 +168,13 @@ export function loadHomeScreenUsers(db) {
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,
+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));
}
+10 -4
View File
@@ -1,24 +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: '', dbPath: '' };
let config = { embyUrl: '', apiKey: '', tmdbApiKey: '', dbPath: '' };
if (existsSync(configPath)) {
try {
config = JSON.parse(readFileSync(configPath, 'utf-8'));
config = {
...config,
...JSON.parse(readFileSync(configPath, 'utf-8'))
};
} catch { /* use defaults */ }
}
return {
users: data.users,
users: enrichedUsers.users,
genres: data.genres,
enums: data.enums,
config
config,
embyCache: enrichedUsers.cache
};
}
+685 -115
View File
File diff suppressed because it is too large Load Diff
+8 -3
View File
@@ -3,13 +3,17 @@ 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 { embyUrl: '', apiKey: '', dbPath: '' };
if (!existsSync(CONFIG_PATH)) return DEFAULT_CONFIG;
try {
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
return {
...DEFAULT_CONFIG,
...JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'))
};
} catch {
return { embyUrl: '', apiKey: '', dbPath: '' };
return DEFAULT_CONFIG;
}
}
@@ -22,6 +26,7 @@ export async function POST({ request }) {
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));
+12 -1
View File
@@ -2,6 +2,7 @@ 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();
@@ -12,8 +13,18 @@ export async function POST({ request }) {
try {
db = new DatabaseSync(dbPath, { readOnly: true });
const result = loadHomeScreenUsers(db);
const enriched = applyCachedEmbyNames(result.users);
db.close();
return json(result);
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);
+695
View File
@@ -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,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',
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}`);
}
}
+160
View File
@@ -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}`);
}
}
+29 -3
View File
@@ -1,6 +1,7 @@
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');
@@ -24,19 +25,44 @@ export async function GET() {
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();
// Return embyGuid (no dashes, lowercase) -> name mapping
return json(
const cached = writeCachedEmbyUsers(
users.map((u) => ({
embyGuid: u.Id.replace(/-/g, '').toLowerCase(),
embyGuid: u.Id,
name: u.Name
}))
);
return json({
users: cached.users,
source: 'live',
lastSyncedAt: cached.lastSyncedAt
});
}
+1018
View File
File diff suppressed because it is too large Load Diff
+453
View File
@@ -0,0 +1,453 @@
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { createBoxSetSection, createRecentlyWatchedSection, getWatchlistLabelsForTarget } from '../src/lib/constants.js';
import { normalizeLookupItem, rankRecommendationResults } from '../src/lib/collection-tools.js';
import * as embyUserCache from '../src/lib/server/emby-user-cache.js';
const tests = [];
function test(name, fn) {
tests.push({ name, fn });
}
function withTempCache(testFn) {
const tempDir = mkdtempSync(join(tmpdir(), 'homescreenpal-cache-'));
const cachePath = join(tempDir, 'emby-users.db');
process.env.EMBY_USER_CACHE_DB_PATH = cachePath;
try {
return testFn(cachePath);
} finally {
delete process.env.EMBY_USER_CACHE_DB_PATH;
rmSync(tempDir, { recursive: true, force: true });
}
}
test('watchlist labels use the target Emby name for new watchlists', () => {
const labels = getWatchlistLabelsForTarget(
{
Name: "Matt's Watchlist",
CustomName: "Matt's Watchlist",
Query: { IsFavorite: true }
},
{
name: 'User 11',
embyName: 'Bob',
sections: []
}
);
assert.equal(labels.Name, "Bob's Watchlist");
assert.equal(labels.CustomName, "Bob's Watchlist");
});
test('watchlist labels preserve an existing matching target watchlist', () => {
const labels = getWatchlistLabelsForTarget(
{
Name: "Matt's Watchlist",
CustomName: "Matt's Watchlist",
Query: { IsFavorite: true }
},
{
name: 'User 14',
embyName: 'Bob',
sections: [
{
Name: "Bob's Watchlist",
CustomName: "Bob's Watchlist",
Query: { IsFavorite: true }
}
]
}
);
assert.equal(labels.Name, "Bob's Watchlist");
assert.equal(labels.CustomName, "Bob's Watchlist");
});
test('watchlist labels correct an existing mismatched target watchlist', () => {
const labels = getWatchlistLabelsForTarget(
{
Name: "Matt's Watchlist",
CustomName: "Matt's Watchlist",
Query: { IsFavorite: true }
},
{
name: 'User 14',
embyName: 'Bob',
sections: [
{
Name: "Matt's Watchlist",
CustomName: "Matt's Watchlist",
Query: { IsFavorite: true }
}
]
}
);
assert.equal(labels.Name, "Bob's Watchlist");
assert.equal(labels.CustomName, "Bob's Watchlist");
});
test('cached Emby users round-trip through the local SQLite cache', () => {
withTempCache(() => {
const written = embyUserCache.writeCachedEmbyUsers([
{ embyGuid: 'ABC-123', name: 'Matt' },
{ embyGuid: 'def456', name: 'Bob' }
]);
const readBack = embyUserCache.readCachedEmbyUsers();
assert.equal(written.users.length, 2);
assert.deepEqual(readBack.users, [
{ embyGuid: 'def456', name: 'Bob' },
{ embyGuid: 'abc123', name: 'Matt' }
]);
assert.ok(readBack.lastSyncedAt);
});
});
test('cached Emby users override DB display names without losing the original DB name', () => {
withTempCache(() => {
embyUserCache.writeCachedEmbyUsers([{ embyGuid: 'abc123', name: 'Matt' }]);
const enriched = embyUserCache.applyCachedEmbyNames([
{ id: 11, name: 'User 11', embyGuid: 'abc123' },
{ id: 12, name: 'Already Good', embyGuid: 'zzz999' }
]);
assert.equal(enriched.users[0].name, 'Matt');
assert.equal(enriched.users[0].embyName, 'Matt');
assert.equal(enriched.users[0].dbName, 'User 11');
assert.equal(enriched.users[1].name, 'Already Good');
assert.equal(enriched.cache.matchedCount, 1);
});
});
test('recently watched section template uses played-content defaults', () => {
const section = createRecentlyWatchedSection('abc123', 'Matt');
assert.equal(section.UserId, 'abc123');
assert.equal(section.Name, 'Recently Watched - Matt');
assert.equal(section.CustomName, 'Recently Watched - Matt');
assert.equal(section.SectionType, 'items');
assert.equal(section.SortBy, 'DatePlayed');
assert.equal(section.SortOrder, 'Descending');
assert.deepEqual(section.ItemTypes, ['Movie', 'Series']);
assert.equal(section.Query.IsPlayed, true);
});
test('box set section template links a created collection', () => {
const section = createBoxSetSection('abc123', 'Recommended for Matt', 'boxset-55');
assert.equal(section.UserId, 'abc123');
assert.equal(section.SectionType, 'boxset');
assert.equal(section.Name, 'Recommended for Matt');
assert.equal(section.CustomName, 'Recommended for Matt');
assert.equal(section.ParentId, 'boxset-55');
assert.deepEqual(section.ParentItem, {
Name: 'Recommended for Matt',
Id: 'boxset-55'
});
});
test('normalizeLookupItem preserves already-normalized recommendation items', () => {
const normalized = normalizeLookupItem({
id: 'pick-1',
name: 'Alpha',
type: 'Movie',
overview: 'Overview',
year: 1998,
communityRating: 7.4,
providerIds: { Tmdb: '123' },
genres: ['Comedy']
});
assert.deepEqual(normalized, {
id: 'pick-1',
name: 'Alpha',
type: 'Movie',
overview: 'Overview',
year: 1998,
communityRating: 7.4,
providerIds: { Tmdb: '123' },
genres: ['Comedy'],
parentId: null
});
});
test('recommendation ranking deduplicates and prioritizes overlaps across seeds', () => {
const ranked = rankRecommendationResults(
['seed-a', 'seed-b'],
[
[
{ Id: 'pick-1', Name: 'Alpha', Type: 'Movie' },
{ Id: 'pick-2', Name: 'Bravo', Type: 'Movie' }
],
[
{ Id: 'pick-2', Name: 'Bravo', Type: 'Movie' },
{ Id: 'pick-3', Name: 'Charlie', Type: 'Movie' }
]
],
5
);
assert.deepEqual(
ranked.map((item) => item.id),
['pick-2', 'pick-1', 'pick-3']
);
assert.equal(ranked[0].matchCount, 2);
});
test('recommendation ranking supports already-normalized result items', () => {
const ranked = rankRecommendationResults(
['seed-a'],
[
[
{ id: 'pick-1', name: 'Alpha', type: 'Movie' },
{ id: 'pick-2', name: 'Bravo', type: 'Movie' }
]
],
5
);
assert.deepEqual(
ranked.map((item) => item.id),
['pick-1', 'pick-2']
);
});
test('recommendation ranking filters results to the selected seed media type', () => {
const ranked = rankRecommendationResults(
['seed-a'],
[
[
{ Id: 'movie-1', Name: 'Movie Match', Type: 'Movie', Genres: ['Drama'] },
{ Id: 'series-1', Name: 'Series Match', Type: 'Series', Genres: ['Drama'] }
]
],
{
limit: 5,
allowedTypes: ['Movie']
}
);
assert.deepEqual(
ranked.map((item) => item.id),
['movie-1']
);
});
test('recommendation ranking heavily favors TMDB similar and recommendation sources over discover', () => {
const ranked = rankRecommendationResults(
['seed-a'],
[
{
items: [{ Id: 'pick-seed-like', Name: 'Seed Like', Type: 'Movie', Genres: ['Drama', 'Romance'] }],
sourceWeight: 6
},
{
items: [{ Id: 'pick-discover', Name: 'Broad Discover', Type: 'Movie', Genres: ['Drama'] }],
sourceWeight: 1
},
{
items: [{ Id: 'pick-discover', Name: 'Broad Discover', Type: 'Movie', Genres: ['Drama'] }],
sourceWeight: 1
},
{
items: [{ Id: 'pick-discover', Name: 'Broad Discover', Type: 'Movie', Genres: ['Drama'] }],
sourceWeight: 1
}
],
5
);
assert.deepEqual(
ranked.map((item) => item.id),
['pick-seed-like', 'pick-discover']
);
});
test('recommendation ranking excludes watched items passed in options', () => {
const ranked = rankRecommendationResults(
['seed-a'],
[
[
{ Id: 'watched-1', Name: 'Already Seen', Type: 'Movie' },
{ Id: 'pick-1', Name: 'Fresh Pick', Type: 'Movie' }
],
[
{ Id: 'pick-1', Name: 'Fresh Pick', Type: 'Movie' },
{ Id: 'pick-2', Name: 'Another Pick', Type: 'Movie' }
]
],
{
limit: 5,
excludeIds: ['watched-1']
}
);
assert.deepEqual(
ranked.map((item) => item.id),
['pick-1', 'pick-2']
);
});
test('recommendation ranking penalizes horror and sci-fi mismatches for romance drama seeds', () => {
const ranked = rankRecommendationResults(
['seed-a'],
[
[
{
Id: 'pick-warm',
Name: 'Quiet Hearts',
Type: 'Movie',
Overview: 'A love story about family, friendship, and second chances.',
Genres: ['Romance', 'Drama']
},
{
Id: 'pick-mismatch',
Name: 'Night Terrors',
Type: 'Movie',
Overview: 'A horror thriller about murder, zombies, and survival.',
Genres: ['Horror', 'Science Fiction', 'Thriller']
}
]
],
{
limit: 5,
seeds: [
{
Id: 'seed-a',
Name: 'The English Patient',
Type: 'Movie',
Genres: ['Romance', 'Drama']
}
]
}
);
assert.deepEqual(
ranked.map((item) => item.id),
['pick-warm', 'pick-mismatch']
);
assert.ok(ranked[0].affinityScore > ranked[1].affinityScore);
});
test('recommendation ranking ignores duplicate items within a single seed result set', () => {
const ranked = rankRecommendationResults(
['seed-a'],
[
[
{ Id: 'pick-1', Name: 'Alpha', Type: 'Movie' },
{ Id: 'pick-1', Name: 'Alpha', Type: 'Movie' },
{ Id: 'pick-2', Name: 'Bravo', Type: 'Movie' }
]
],
5
);
assert.deepEqual(
ranked.map((item) => item.id),
['pick-1', 'pick-2']
);
assert.equal(ranked[0].matchCount, 1);
});
test('classic comfort profile filters modern and mismatched picks while promoting older warm titles', () => {
const ranked = rankRecommendationResults(
['seed-a', 'seed-b'],
[
[
{
Id: 'pick-classic',
Name: 'Warm Hearts',
Type: 'Movie',
Overview: 'A romantic family comedy about friendship, second chances, and love.',
ProductionYear: 1998,
CommunityRating: 7.6,
Genres: ['Romance', 'Comedy']
},
{
Id: 'pick-modern',
Name: 'Future War',
Type: 'Movie',
Overview: 'A modern action thriller about combat and alien invasion.',
ProductionYear: 2021,
CommunityRating: 7.8,
Genres: ['Action', 'Science Fiction']
}
],
[
{
Id: 'pick-classic',
Name: 'Warm Hearts',
Type: 'Movie',
Overview: 'A romantic family comedy about friendship, second chances, and love.',
ProductionYear: 1998,
CommunityRating: 7.6,
Genres: ['Romance', 'Comedy']
},
{
Id: 'pick-animation',
Name: 'Toon Town',
Type: 'Movie',
Overview: 'An animated adventure about a magical family.',
ProductionYear: 2002,
CommunityRating: 8.0,
Genres: ['Animation', 'Family']
}
]
],
{
limit: 5,
profile: 'classicComfort',
seeds: [
{
Id: 'seed-a',
Name: 'Sleepless Style',
Type: 'Movie',
Overview: 'A romantic comedy about love and second chances.',
ProductionYear: 1993,
CommunityRating: 7.1,
Genres: ['Romance', 'Comedy']
},
{
Id: 'seed-b',
Name: 'Family Ties',
Type: 'Movie',
Overview: 'A family friendship story with warmth.',
ProductionYear: 1995,
CommunityRating: 7.0,
Genres: ['Comedy', 'Drama']
}
]
}
);
assert.deepEqual(ranked.map((item) => item.id), ['pick-classic']);
assert.equal(ranked[0].matchCount, 2);
assert.ok(ranked[0].styleScore >= 16);
assert.equal(ranked[0].qualityScore, 76);
});
let failures = 0;
for (const { name, fn } of tests) {
try {
await fn();
console.log(`PASS ${name}`);
} catch (error) {
failures++;
console.error(`FAIL ${name}`);
console.error(error);
}
}
if (failures > 0) {
console.error(`\n${failures} test(s) failed.`);
process.exit(1);
}
console.log(`\n${tests.length} test(s) passed.`);