Homelabtoolkit v2
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<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">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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>
|
||||
@@ -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: 24px;
|
||||
font-weight: 800;
|
||||
}
|
||||
h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.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: #0f1116;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
}
|
||||
.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: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: #12151b;
|
||||
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: 10px;
|
||||
background: #111419;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
.result-card {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: #111419;
|
||||
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: 10px;
|
||||
background: #111419;
|
||||
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: 10px;
|
||||
font-size: 12px;
|
||||
background: #111419;
|
||||
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: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
border: 1px solid transparent;
|
||||
text-align: center;
|
||||
}
|
||||
.btn.ghost {
|
||||
color: var(--text);
|
||||
border-color: var(--border);
|
||||
background: #15181e;
|
||||
}
|
||||
.btn.accent {
|
||||
background: var(--accent);
|
||||
border-color: rgba(42, 215, 239, 0.35);
|
||||
color: #031014;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
.page-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
background: rgba(40, 193, 220, 0.1);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@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>
|
||||
@@ -0,0 +1,662 @@
|
||||
<script>
|
||||
import Icon from '$lib/Icon.svelte';
|
||||
|
||||
export let users = [];
|
||||
export let selectedUserId = null;
|
||||
|
||||
let sourceUserId = null;
|
||||
let mediaType = 'Movie';
|
||||
let searchTerm = '';
|
||||
let searchResults = [];
|
||||
let inspections = {};
|
||||
let searchBusy = false;
|
||||
let inspectAllBusy = false;
|
||||
let applyAllBusy = false;
|
||||
let searchError = '';
|
||||
let actionError = '';
|
||||
let actionMessage = '';
|
||||
|
||||
$: sourceUsers = users.filter((user) => user.embyGuid);
|
||||
$: 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);
|
||||
$: inspectedCount = Object.values(inspections).filter((entry) => entry?.item).length;
|
||||
$: readyToApplyCount = Object.values(inspections).filter(
|
||||
(entry) => entry?.selectedGenre && !entry?.error
|
||||
).length;
|
||||
|
||||
let lastSearchContext = '';
|
||||
$: {
|
||||
const nextSearchContext = `${sourceUserId || ''}:${mediaType}`;
|
||||
if (nextSearchContext !== lastSearchContext) {
|
||||
lastSearchContext = nextSearchContext;
|
||||
searchResults = [];
|
||||
inspections = {};
|
||||
searchError = '';
|
||||
actionError = '';
|
||||
actionMessage = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function searchLibrary() {
|
||||
actionError = '';
|
||||
actionMessage = '';
|
||||
searchError = '';
|
||||
|
||||
if (!sourceUser?.embyGuid) {
|
||||
searchError = 'Select a linked Emby user first.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!searchTerm.trim()) {
|
||||
searchResults = [];
|
||||
inspections = {};
|
||||
return;
|
||||
}
|
||||
|
||||
searchBusy = true;
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
userId: sourceUser.embyGuid,
|
||||
term: searchTerm.trim(),
|
||||
types: mediaType,
|
||||
limit: '25'
|
||||
});
|
||||
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);
|
||||
searchResults = body.items || [];
|
||||
inspections = {};
|
||||
actionMessage = searchResults.length
|
||||
? `Found ${searchResults.length} ${mediaType === 'Movie' ? 'movie' : 'show'} matches.`
|
||||
: 'No matching items were found.';
|
||||
} catch (err) {
|
||||
searchError = err.message;
|
||||
searchResults = [];
|
||||
inspections = {};
|
||||
} finally {
|
||||
searchBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectItem(item) {
|
||||
if (!sourceUser?.embyGuid || !item?.id) return;
|
||||
|
||||
inspections = {
|
||||
...inspections,
|
||||
[item.id]: {
|
||||
...(inspections[item.id] || {}),
|
||||
loading: true,
|
||||
error: '',
|
||||
updated: false
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
userId: sourceUser.embyGuid,
|
||||
itemId: item.id
|
||||
});
|
||||
const response = await fetch(`/api/emby-genre-cleanup?${params.toString()}`);
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(body.message || body.error || response.statusText);
|
||||
|
||||
inspections = {
|
||||
...inspections,
|
||||
[item.id]: {
|
||||
loading: false,
|
||||
error: '',
|
||||
updated: inspections[item.id]?.updated || false,
|
||||
...body,
|
||||
selectedGenre: body.suggestedGenre || body.tmdb?.genres?.[0] || ''
|
||||
}
|
||||
};
|
||||
} catch (err) {
|
||||
inspections = {
|
||||
...inspections,
|
||||
[item.id]: {
|
||||
...(inspections[item.id] || {}),
|
||||
loading: false,
|
||||
error: err.message
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function updateSelectedGenre(itemId, genreName) {
|
||||
inspections = {
|
||||
...inspections,
|
||||
[itemId]: {
|
||||
...(inspections[itemId] || {}),
|
||||
selectedGenre: genreName
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function syncSearchResultGenres(itemId, genreName) {
|
||||
searchResults = searchResults.map((item) =>
|
||||
item.id === itemId
|
||||
? {
|
||||
...item,
|
||||
genres: genreName ? [genreName] : []
|
||||
}
|
||||
: item
|
||||
);
|
||||
}
|
||||
|
||||
async function applyGenre(item) {
|
||||
const inspection = inspections[item.id];
|
||||
if (!sourceUser?.embyGuid || !inspection?.selectedGenre) return;
|
||||
|
||||
actionError = '';
|
||||
actionMessage = '';
|
||||
inspections = {
|
||||
...inspections,
|
||||
[item.id]: {
|
||||
...inspection,
|
||||
applying: true,
|
||||
error: ''
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/emby-genre-cleanup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userId: sourceUser.embyGuid,
|
||||
itemId: item.id,
|
||||
genreName: inspection.selectedGenre
|
||||
})
|
||||
});
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(body.message || body.error || response.statusText);
|
||||
|
||||
inspections = {
|
||||
...inspections,
|
||||
[item.id]: {
|
||||
loading: false,
|
||||
applying: false,
|
||||
error: '',
|
||||
updated: true,
|
||||
...body,
|
||||
selectedGenre: inspection.selectedGenre
|
||||
}
|
||||
};
|
||||
syncSearchResultGenres(item.id, inspection.selectedGenre);
|
||||
actionMessage = `Updated "${item.name}" to ${inspection.selectedGenre}.`;
|
||||
} catch (err) {
|
||||
inspections = {
|
||||
...inspections,
|
||||
[item.id]: {
|
||||
...inspection,
|
||||
applying: false,
|
||||
error: err.message
|
||||
}
|
||||
};
|
||||
actionError = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectAllResults() {
|
||||
if (!searchResults.length) return;
|
||||
inspectAllBusy = true;
|
||||
actionError = '';
|
||||
actionMessage = '';
|
||||
|
||||
try {
|
||||
for (const item of searchResults) {
|
||||
if (!inspections[item.id]?.item) {
|
||||
await inspectItem(item);
|
||||
}
|
||||
}
|
||||
actionMessage = `Inspected ${searchResults.length} item${searchResults.length === 1 ? '' : 's'}.`;
|
||||
} finally {
|
||||
inspectAllBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyToAllInspected() {
|
||||
const pendingItems = searchResults.filter((item) => {
|
||||
const inspection = inspections[item.id];
|
||||
return inspection?.selectedGenre && !inspection?.error;
|
||||
});
|
||||
if (!pendingItems.length) return;
|
||||
|
||||
applyAllBusy = true;
|
||||
actionError = '';
|
||||
actionMessage = '';
|
||||
|
||||
try {
|
||||
for (const item of pendingItems) {
|
||||
await applyGenre(item);
|
||||
}
|
||||
actionMessage = `Applied single-genre cleanup to ${pendingItems.length} item${pendingItems.length === 1 ? '' : 's'}.`;
|
||||
} finally {
|
||||
applyAllBusy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="genre-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-title-row">
|
||||
<span class="page-icon"><Icon name="search" size={18} /></span>
|
||||
<h2>Genre Cleanup</h2>
|
||||
</div>
|
||||
<p class="page-copy">Search movies or shows, compare Emby genres with TMDB genres, and reduce each title to one genre.</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<button class="btn ghost" on:click={inspectAllResults} disabled={!searchResults.length || inspectAllBusy || searchBusy || applyAllBusy}>
|
||||
{inspectAllBusy ? 'Inspecting…' : 'Inspect all'}
|
||||
</button>
|
||||
<button class="btn accent" on:click={applyToAllInspected} disabled={!readyToApplyCount || inspectAllBusy || searchBusy || applyAllBusy}>
|
||||
{applyAllBusy ? 'Applying…' : `Apply all (${readyToApplyCount})`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="genre-grid">
|
||||
<div class="builder-main">
|
||||
<section 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">Media type</span>
|
||||
<select bind:value={mediaType}>
|
||||
<option value="Movie">Movies</option>
|
||||
<option value="Series">TV Shows</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="lookup-row">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchTerm}
|
||||
on:keydown={(event) => event.key === 'Enter' && searchLibrary()}
|
||||
placeholder={mediaType === 'Movie' ? 'Search movies...' : 'Search shows...'}
|
||||
/>
|
||||
<button class="btn ghost" on:click={searchLibrary} disabled={searchBusy}>
|
||||
{searchBusy ? 'Searching…' : 'Search'}
|
||||
</button>
|
||||
</div>
|
||||
<p class="profile-note">The suggested genre uses TMDB order as a hint, but you can change the choice before writing it back to Emby.</p>
|
||||
</section>
|
||||
|
||||
{#if searchError}
|
||||
<div class="status error">{searchError}</div>
|
||||
{/if}
|
||||
{#if actionError}
|
||||
<div class="status error">{actionError}</div>
|
||||
{:else if actionMessage}
|
||||
<div class="status ok">{actionMessage}</div>
|
||||
{/if}
|
||||
|
||||
<section class="builder-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title-row">
|
||||
<Icon name="items" size={16} />
|
||||
<h3>Matches</h3>
|
||||
</div>
|
||||
<span class="card-copy">Inspect one title at a time or batch the current search results.</span>
|
||||
</div>
|
||||
|
||||
{#if !searchBusy && !searchResults.length}
|
||||
<div class="empty-note">Search for a movie or show to start cleaning up its genres.</div>
|
||||
{:else}
|
||||
<div class="result-list">
|
||||
{#each searchResults as item}
|
||||
{@const inspection = inspections[item.id]}
|
||||
<div class="result-card">
|
||||
<div class="result-header">
|
||||
<div>
|
||||
<div class="result-name">{item.name}</div>
|
||||
<div class="result-meta">
|
||||
{item.type}{item.year ? ` · ${item.year}` : ''}
|
||||
{#if inspection?.updated} · updated{/if}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn ghost small" on:click={() => inspectItem(item)} disabled={inspection?.loading || inspection?.applying || applyAllBusy}>
|
||||
{inspection?.loading ? 'Inspecting…' : 'Inspect'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="genre-row">
|
||||
<span class="genre-label">Emby</span>
|
||||
<span class="genre-value">{(inspection?.currentGenres || item.genres || []).join(', ') || 'None'}</span>
|
||||
</div>
|
||||
|
||||
{#if inspection?.error}
|
||||
<div class="status error inline-status">{inspection.error}</div>
|
||||
{:else if inspection?.item}
|
||||
<div class="genre-panel">
|
||||
<div class="genre-row">
|
||||
<span class="genre-label">TMDB</span>
|
||||
<span class="genre-value">{inspection.tmdb?.genres?.join(', ') || 'No genres returned'}</span>
|
||||
</div>
|
||||
<div class="genre-row">
|
||||
<span class="genre-label">Match</span>
|
||||
<span class="genre-value">
|
||||
{#if inspection.tmdb?.tmdbId}
|
||||
{inspection.tmdb.source === 'providerId' ? 'Provider ID match' : 'Title search match'} · TMDB {inspection.tmdb.tmdbId}
|
||||
{:else}
|
||||
No TMDB match
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if inspection.tmdb?.genres?.length}
|
||||
<div class="apply-grid">
|
||||
<label class="field">
|
||||
<span class="field-label">Single genre</span>
|
||||
<select
|
||||
value={inspection.selectedGenre}
|
||||
on:change={(event) => updateSelectedGenre(item.id, event.target.value)}
|
||||
>
|
||||
{#each inspection.tmdb.genres as genre}
|
||||
<option value={genre}>{genre}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<div class="apply-actions">
|
||||
<button class="btn accent" on:click={() => applyGenre(item)} disabled={!inspection.selectedGenre || inspection.applying || applyAllBusy}>
|
||||
{inspection.applying ? 'Applying…' : 'Apply single genre'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="builder-side">
|
||||
<section class="builder-card sticky-card">
|
||||
<div class="card-header">
|
||||
<div class="card-title-row">
|
||||
<Icon name="spark" size={16} />
|
||||
<h3>Summary</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span>Search results</span>
|
||||
<strong>{searchResults.length}</strong>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span>Inspected</span>
|
||||
<strong>{inspectedCount}</strong>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span>Ready to apply</span>
|
||||
<strong>{readyToApplyCount}</strong>
|
||||
</div>
|
||||
<p class="detail-message">
|
||||
TMDB can return multiple genres. This tool lets you use the first returned genre as a starting point without forcing that choice.
|
||||
</p>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.genre-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: 24px;
|
||||
font-weight: 800;
|
||||
}
|
||||
h3 {
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
.genre-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.65fr) minmax(280px, 0.85fr);
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
.builder-main,
|
||||
.builder-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
.builder-card {
|
||||
background: #0f1116;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
}
|
||||
.hero-card {
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
.sticky-card {
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
}
|
||||
.field-grid,
|
||||
.apply-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 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);
|
||||
}
|
||||
.lookup-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: #12151b;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.profile-note,
|
||||
.detail-message {
|
||||
margin: 12px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.result-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.result-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: #111419;
|
||||
padding: 14px;
|
||||
}
|
||||
.result-header,
|
||||
.detail-row,
|
||||
.genre-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.result-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.result-meta,
|
||||
.genre-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.genre-value {
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
text-align: right;
|
||||
}
|
||||
.genre-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.apply-actions {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.status {
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.status.ok {
|
||||
border-color: rgba(34, 197, 94, 0.32);
|
||||
color: #b7f3ca;
|
||||
}
|
||||
.status.error {
|
||||
border-color: rgba(239, 68, 68, 0.32);
|
||||
color: #ffc2c2;
|
||||
}
|
||||
.inline-status {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.empty-note {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.btn {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
.btn.ghost {
|
||||
background: #12151b;
|
||||
color: var(--text);
|
||||
}
|
||||
.btn.accent {
|
||||
background: linear-gradient(135deg, #2ad7ef 0%, #1798b4 100%);
|
||||
border-color: rgba(42, 215, 239, 0.35);
|
||||
color: #071217;
|
||||
}
|
||||
.btn.small {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.genre-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sticky-card {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.page-header,
|
||||
.apply-grid,
|
||||
.field-grid {
|
||||
grid-template-columns: 1fr;
|
||||
display: grid;
|
||||
}
|
||||
.page-actions {
|
||||
width: 100%;
|
||||
}
|
||||
.lookup-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.result-header,
|
||||
.detail-row,
|
||||
.genre-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
.genre-value {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,848 @@
|
||||
<script>
|
||||
import {
|
||||
SECTION_TYPES,
|
||||
COLLECTION_TYPES,
|
||||
ITEM_TYPES,
|
||||
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' ||
|
||||
section.SectionType === 'latestepisodereleases' ||
|
||||
section.SectionType === 'latestmoviereleases';
|
||||
|
||||
// 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);
|
||||
if (idx >= 0) {
|
||||
section.Query.GenreIds = section.Query.GenreIds.filter((g) => g !== id);
|
||||
} else {
|
||||
section.Query.GenreIds = [...section.Query.GenreIds, id];
|
||||
}
|
||||
dispatch('change');
|
||||
}
|
||||
|
||||
function toggleItemType(type) {
|
||||
const idx = section.ItemTypes.indexOf(type);
|
||||
if (idx >= 0) {
|
||||
section.ItemTypes = section.ItemTypes.filter((t) => t !== type);
|
||||
} else {
|
||||
section.ItemTypes = [...section.ItemTypes, type];
|
||||
}
|
||||
dispatch('change');
|
||||
}
|
||||
|
||||
function handleChange() {
|
||||
dispatch('change');
|
||||
}
|
||||
|
||||
function handleExcludedFoldersChange(e) {
|
||||
const raw = e.target.value;
|
||||
section.ExcludedFolders = raw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== '');
|
||||
dispatch('change');
|
||||
}
|
||||
|
||||
function handleTagIdsChange(e) {
|
||||
if (!section.Query) section.Query = { StudioIds: [], TagIds: [], GenreIds: [], CollectionTypes: [] };
|
||||
const raw = e.target.value;
|
||||
section.Query.TagIds = raw
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.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>
|
||||
<!-- Header row - always visible -->
|
||||
<button class="section-header" on:click={() => dispatch('toggle')}>
|
||||
<div class="reorder-btns">
|
||||
<button
|
||||
class="move-btn"
|
||||
disabled={index === 0}
|
||||
on:click|stopPropagation={() => dispatch('move', -1)}
|
||||
title="Move up">▲</button
|
||||
>
|
||||
<button
|
||||
class="move-btn"
|
||||
disabled={index === total - 1}
|
||||
on:click|stopPropagation={() => dispatch('move', 1)}
|
||||
title="Move down">▼</button
|
||||
>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{typeLabel}
|
||||
{#if genreNames} · {genreNames}{/if}
|
||||
{#if section.CollectionType} · {section.CollectionType}{/if}
|
||||
{#if section.SortBy} · {SORT_OPTIONS.find((s) => s.value === section.SortBy)?.label || section.SortBy}{/if}
|
||||
{#if section.ExcludedFolders?.length} · {section.ExcludedFolders.length} excluded{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="remove-btn"
|
||||
on:click|stopPropagation={() => dispatch('remove')}
|
||||
title="Remove section">×</button
|
||||
>
|
||||
<span class="expand-indicator">{expanded ? '▾' : '▸'}</span>
|
||||
</button>
|
||||
|
||||
<!-- Expanded editor -->
|
||||
{#if expanded}
|
||||
<div class="section-editor">
|
||||
|
||||
<!-- Core fields -->
|
||||
<div class="field-grid">
|
||||
<label class="field span2">
|
||||
<span class="field-label">Display name</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={section.CustomName}
|
||||
on:input={() => {
|
||||
section.Name = section.CustomName;
|
||||
handleChange();
|
||||
}}
|
||||
placeholder="Section name"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-label">Section type</span>
|
||||
<select bind:value={section.SectionType} on:change={handleChange}>
|
||||
{#each SECTION_TYPES as t}
|
||||
<option value={t.value}>{t.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-label">Image type</span>
|
||||
<select bind:value={section.ImageType} on:change={handleChange}>
|
||||
{#each IMAGE_TYPES as t}
|
||||
<option value={t.value}>{t.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-label">Sort by</span>
|
||||
<select bind:value={section.SortBy} on:change={handleChange}>
|
||||
{#each SORT_OPTIONS as s}
|
||||
<option value={s.value}>{s.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span class="field-label">Sort order</span>
|
||||
<select bind:value={section.SortOrder} on:change={handleChange}>
|
||||
<option value={undefined}>(none)</option>
|
||||
<option value="Ascending">Ascending</option>
|
||||
<option value="Descending">Descending</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{#if showFilters}
|
||||
<label class="field">
|
||||
<span class="field-label">Collection type</span>
|
||||
<select bind:value={section.CollectionType} on:change={handleChange}>
|
||||
{#each COLLECTION_TYPES as c}
|
||||
<option value={c.value}>{c.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
{#if section.SectionType === 'userviews'}
|
||||
<label class="field">
|
||||
<span class="field-label">View type</span>
|
||||
<select bind:value={section.ViewType} on:change={handleChange}>
|
||||
<option value={undefined}>(default)</option>
|
||||
<option value="buttons">Buttons</option>
|
||||
<option value="list">List</option>
|
||||
</select>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
<label class="field">
|
||||
<span class="field-label">Card size offset</span>
|
||||
<select
|
||||
value={section.CardSizeOffset ?? 0}
|
||||
on:change={(e) => { section.CardSizeOffset = Number(e.target.value); handleChange(); }}
|
||||
>
|
||||
<option value={-2}>-2 (Smaller)</option>
|
||||
<option value={-1}>-1 (Small)</option>
|
||||
<option value={0}>0 (Default)</option>
|
||||
<option value={1}>+1 (Large)</option>
|
||||
<option value={2}>+2 (Larger)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Checkboxes -->
|
||||
<div class="field-grid checkboxes">
|
||||
{#if section.SectionType === 'resume'}
|
||||
<label class="field-inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={section.IncludeNextUpInResume ?? true}
|
||||
on:change={(e) => { section.IncludeNextUpInResume = e.target.checked; handleChange(); }}
|
||||
/>
|
||||
<span>Include Next Up in Resume</span>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
{#if showFilters}
|
||||
<label class="field-inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={section.Query?.IsFavorite || false}
|
||||
on:change={(e) => {
|
||||
if (!section.Query) section.Query = { StudioIds: [], TagIds: [], GenreIds: [], CollectionTypes: [] };
|
||||
section.Query.IsFavorite = e.target.checked || undefined;
|
||||
handleChange();
|
||||
}}
|
||||
/>
|
||||
<span>Favourites only</span>
|
||||
</label>
|
||||
<label class="field-inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={section.Query?.IsPlayed === false}
|
||||
on:change={(e) => {
|
||||
if (!section.Query) section.Query = { StudioIds: [], TagIds: [], GenreIds: [], CollectionTypes: [] };
|
||||
section.Query.IsPlayed = e.target.checked ? false : undefined;
|
||||
handleChange();
|
||||
}}
|
||||
/>
|
||||
<span>Unplayed only</span>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showFilters}
|
||||
<!-- Item types -->
|
||||
<div class="field-group">
|
||||
<span class="field-label">Item types</span>
|
||||
<div class="chip-group">
|
||||
{#each ITEM_TYPES as type}
|
||||
<button
|
||||
class="chip"
|
||||
class:active={section.ItemTypes?.includes(type)}
|
||||
on:click={() => toggleItemType(type)}
|
||||
>
|
||||
{type}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Genres -->
|
||||
<div class="field-group">
|
||||
<span class="field-label">Genres</span>
|
||||
<div class="chip-group">
|
||||
{#each Object.entries(GENRES).sort((a, b) => a[1].localeCompare(b[1])) as [id, name]}
|
||||
<button
|
||||
class="chip"
|
||||
class:active={section.Query?.GenreIds?.includes(id)}
|
||||
on:click={() => toggleGenre(id)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tag IDs -->
|
||||
<div class="field-group">
|
||||
<span class="field-label">Tag IDs <span class="hint-inline">(comma-separated Emby tag IDs)</span></span>
|
||||
<input
|
||||
type="text"
|
||||
class="ids-input"
|
||||
value={tagIdsStr}
|
||||
on:change={handleTagIdsChange}
|
||||
placeholder="e.g. 1317339, 1336097"
|
||||
/>
|
||||
{#if section.Query?.TagIds?.length}
|
||||
<div class="ids-preview">
|
||||
{#each section.Query.TagIds as id}
|
||||
<span class="id-chip">
|
||||
{id}
|
||||
<button class="id-remove" on:click={() => {
|
||||
section.Query.TagIds = section.Query.TagIds.filter(t => t !== id);
|
||||
dispatch('change');
|
||||
}}>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Boxset parent -->
|
||||
{#if section.SectionType === 'boxset'}
|
||||
<div class="field-group">
|
||||
<span class="field-label">Linked box set</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>
|
||||
{#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>
|
||||
<input
|
||||
type="text"
|
||||
value={section.ParentItem?.Name || ''}
|
||||
on:input={(e) => {
|
||||
if (!section.ParentItem) section.ParentItem = { Name: '', Id: '' };
|
||||
section.ParentItem.Name = e.target.value;
|
||||
section.Name = e.target.value;
|
||||
handleChange();
|
||||
}}
|
||||
placeholder="Display name"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span class="field-label">Box set ID</span>
|
||||
<input
|
||||
type="text"
|
||||
value={section.ParentItem?.Id || section.ParentId || ''}
|
||||
on:input={(e) => {
|
||||
if (!section.ParentItem) section.ParentItem = { Name: '', Id: '' };
|
||||
section.ParentItem.Id = e.target.value;
|
||||
section.ParentId = e.target.value;
|
||||
handleChange();
|
||||
}}
|
||||
placeholder="Emby item ID"
|
||||
/>
|
||||
</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}
|
||||
|
||||
<!-- Excluded folders -->
|
||||
<div class="field-group">
|
||||
<span class="field-label">
|
||||
Excluded folders
|
||||
{#if section.ExcludedFolders?.length}
|
||||
<span class="badge">{section.ExcludedFolders.length}</span>
|
||||
{/if}
|
||||
<span class="hint-inline">(comma-separated folder IDs)</span>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
class="ids-input"
|
||||
value={excludedFoldersStr}
|
||||
on:change={handleExcludedFoldersChange}
|
||||
placeholder="e.g. 4309, 39975, 462878"
|
||||
/>
|
||||
{#if section.ExcludedFolders?.length}
|
||||
<div class="ids-preview">
|
||||
{#each section.ExcludedFolders as id}
|
||||
<span class="id-chip">
|
||||
{id}
|
||||
<button class="id-remove" on:click={() => {
|
||||
section.ExcludedFolders = section.ExcludedFolders.filter(f => f !== id);
|
||||
dispatch('change');
|
||||
}}>×</button>
|
||||
</span>
|
||||
{/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>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.section-card {
|
||||
background: #101318;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: 12px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.section-card:hover {
|
||||
background: #12161d;
|
||||
}
|
||||
.section-card.expanded {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.section-header {
|
||||
all: unset;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.section-header:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
.reorder-btns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
.move-btn {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
padding: 3px 4px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.move-btn:disabled {
|
||||
opacity: 0.15;
|
||||
cursor: default;
|
||||
}
|
||||
.move-btn:not(:disabled):hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.section-index {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.section-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
background: rgba(40, 193, 220, 0.1);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.section-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.section-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.section-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.remove-btn {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
color: var(--danger);
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
padding: 0 4px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.remove-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.expand-indicator {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
min-width: 14px;
|
||||
}
|
||||
|
||||
/* Editor panel */
|
||||
.section-editor {
|
||||
padding: 10px 16px 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: #0d1015;
|
||||
}
|
||||
.field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.field-grid.checkboxes {
|
||||
grid-template-columns: auto auto auto;
|
||||
justify-content: start;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.field.span2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
.field-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.hint-inline {
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.field input[type='text'],
|
||||
.field select {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: #12151b;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.field input:focus,
|
||||
.field select:focus {
|
||||
outline: none;
|
||||
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: 10px;
|
||||
text-align: center;
|
||||
color: var(--text);
|
||||
background: #15181e;
|
||||
}
|
||||
.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: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: #12151b;
|
||||
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;
|
||||
gap: 5px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.chip {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
padding: 6px 11px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
background: #12151b;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.chip:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--text);
|
||||
}
|
||||
.chip.active {
|
||||
background: var(--accent);
|
||||
border-color: rgba(42, 215, 239, 0.3);
|
||||
color: #031014;
|
||||
}
|
||||
|
||||
.field-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* IDs input (excluded folders, tag IDs) */
|
||||
.ids-input {
|
||||
width: 100%;
|
||||
margin-top: 6px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: #12151b;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ids-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.ids-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.id-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
background: #12151b;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 4px 8px 4px 10px;
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.id-remove {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
color: var(--danger);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.id-remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
background: var(--accent);
|
||||
color: #031014;
|
||||
border-radius: 999px;
|
||||
padding: 2px 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.boxset-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
margin-top: 6px;
|
||||
padding: 10px 12px;
|
||||
background: #12151b;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.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: 10px;
|
||||
background: #12151b;
|
||||
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>
|
||||
@@ -0,0 +1,335 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let config = { embyUrl: '', apiKey: '', tmdbApiKey: '', dbPath: '' };
|
||||
|
||||
let localConfig = { ...config };
|
||||
let status = '';
|
||||
let statusType = ''; // 'ok' | 'error' | 'info'
|
||||
let busy = false;
|
||||
|
||||
function setStatus(msg, type = 'info') {
|
||||
status = msg;
|
||||
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…');
|
||||
try {
|
||||
const res = await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(localConfig)
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
config = { ...localConfig };
|
||||
dispatch('configSaved', { ...localConfig });
|
||||
setStatus('Settings saved.', 'ok');
|
||||
} catch (e) {
|
||||
setStatus(`Save failed: ${e.message}`, 'error');
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection() {
|
||||
if (!localConfig.embyUrl || !localConfig.apiKey) {
|
||||
setStatus('Enter Emby URL and API key first.', 'error');
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
setStatus('Connecting to Emby…');
|
||||
try {
|
||||
// Save config first so the server endpoint can read it
|
||||
await fetch('/api/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(localConfig)
|
||||
});
|
||||
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');
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus(`Connection failed: ${e.message}`, 'error');
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshNames() {
|
||||
busy = true;
|
||||
setStatus('Fetching user names from Emby…');
|
||||
try {
|
||||
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');
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus(`Failed: ${e.message}`, 'error');
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFromDb() {
|
||||
if (!localConfig.dbPath) {
|
||||
setStatus('Enter the DB path first.', 'error');
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
setStatus('Reading database…');
|
||||
try {
|
||||
const res = await fetch('/api/db-read', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dbPath: localConfig.dbPath })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new Error(err.message || res.statusText);
|
||||
}
|
||||
const { users, validation } = await res.json();
|
||||
dispatch('usersLoaded', { users, validation });
|
||||
|
||||
if (validation?.mismatchedUsers > 0 || validation?.missingSectionUserIds > 0) {
|
||||
setStatus(
|
||||
`Loaded ${users.length} users from ${validation.userSource}. ` +
|
||||
`${validation.mismatchedUsers} user(s) had mismatched section UserIds and ` +
|
||||
`${validation.normalizedUsers} user(s) were normalized on load.`,
|
||||
'ok'
|
||||
);
|
||||
} else {
|
||||
setStatus(
|
||||
`Loaded ${users.length} users from ${validation?.userSource || 'database'}. ` +
|
||||
'UserSettings IDs and section UserIds match.',
|
||||
'ok'
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus(`Failed: ${e.message}`, 'error');
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="panel">
|
||||
<h3>Settings</h3>
|
||||
|
||||
<section>
|
||||
<h4>Emby connection</h4>
|
||||
<label>
|
||||
<span>Server URL</span>
|
||||
<input type="text" bind:value={localConfig.embyUrl} placeholder="http://localhost:8096" />
|
||||
</label>
|
||||
<label>
|
||||
<span>API key</span>
|
||||
<input type="password" bind:value={localConfig.apiKey} placeholder="Paste your API key" />
|
||||
</label>
|
||||
<p class="hint">
|
||||
Get your API key from Emby: Dashboard → Advanced → API Keys → New API Key.
|
||||
</p>
|
||||
<div class="row">
|
||||
<button class="btn ghost" on:click={saveConfig} disabled={busy}>Save</button>
|
||||
<button class="btn ghost" on:click={testConnection} disabled={busy}>Test connection</button>
|
||||
<button class="btn ghost" on:click={refreshNames} disabled={busy || !config.apiKey}>
|
||||
Refresh user names
|
||||
</button>
|
||||
</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>
|
||||
<span>Path to users.db</span>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={localConfig.dbPath}
|
||||
placeholder="C:\ProgramData\Emby-Server\data\users.db"
|
||||
/>
|
||||
</label>
|
||||
<p class="hint">
|
||||
Stop Emby before loading or writing to avoid corruption. Typical path:
|
||||
<code>C:\ProgramData\Emby-Server\data\users.db</code>
|
||||
</p>
|
||||
<div class="row">
|
||||
<button class="btn ghost" on:click={saveConfig} disabled={busy}>Save path</button>
|
||||
<button class="btn accent" on:click={loadFromDb} disabled={busy || !localConfig.dbPath}>
|
||||
Load from DB
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if status}
|
||||
<div class="status" class:ok={statusType === 'ok'} class:error={statusType === 'error'}>
|
||||
{status}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.panel {
|
||||
padding: 0;
|
||||
}
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
margin: 0 0 16px;
|
||||
color: var(--text);
|
||||
}
|
||||
h4 {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
section {
|
||||
margin-bottom: 18px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: #111419;
|
||||
}
|
||||
section:last-of-type {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
label span {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
input {
|
||||
background: #12151b;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 10px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
code {
|
||||
font-size: 11px;
|
||||
background: #0c0e12;
|
||||
padding: 2px 5px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.btn {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
padding: 9px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.btn.ghost {
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
background: #15181e;
|
||||
}
|
||||
.btn.ghost:hover:not(:disabled) {
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.btn.accent {
|
||||
background: var(--accent);
|
||||
color: #031014;
|
||||
border: 1px solid rgba(42, 215, 239, 0.35);
|
||||
}
|
||||
.btn.accent:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
.status {
|
||||
margin-top: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
background: #111419;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.status.ok {
|
||||
border-color: rgba(34, 197, 94, 0.3);
|
||||
color: #86efac;
|
||||
}
|
||||
.status.error {
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
color: #fca5a5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,144 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
export let sql = '';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let copied = false;
|
||||
|
||||
function copyToClipboard() {
|
||||
navigator.clipboard.writeText(sql).then(() => {
|
||||
copied = true;
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function download() {
|
||||
const blob = new Blob([sql], { type: 'text/sql' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `emby-homescreen-update-${new Date().toISOString().slice(0, 10)}.sql`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<pre class="sql-output">{sql}</pre>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="action-btn secondary" on:click={copyToClipboard}>
|
||||
{copied ? '✓ Copied' : 'Copy to clipboard'}
|
||||
</button>
|
||||
<button class="action-btn primary" on:click={download}> Download .sql file </button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.82);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
padding: 20px;
|
||||
}
|
||||
.modal {
|
||||
background: #0f1116;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.42);
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: var(--text);
|
||||
}
|
||||
.close-btn {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
font-size: 22px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
}
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
.sql-output {
|
||||
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
margin: 0;
|
||||
background: #12151b;
|
||||
padding: 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.action-btn {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
padding: 8px 18px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
}
|
||||
.action-btn.secondary {
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
background: #15181e;
|
||||
}
|
||||
.action-btn.secondary:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
.action-btn.primary {
|
||||
background: var(--accent);
|
||||
border: 1px solid rgba(42, 215, 239, 0.35);
|
||||
color: #031014;
|
||||
}
|
||||
.action-btn.primary:hover {
|
||||
background: #35d2ea;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,331 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { getSectionTypeLabel, getGenreNames } from '$lib/constants.js';
|
||||
|
||||
export let users = [];
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let sourceUserId = null;
|
||||
let targetUserIds = [];
|
||||
let selectedSectionIndices = [];
|
||||
let syncMode = 'replace'; // 'replace' = overwrite all, 'append' = add to end, 'selected' = only selected sections
|
||||
|
||||
$: sourceUser = users.find((u) => u.id === sourceUserId);
|
||||
$: sourceSections = sourceUser?.sections || [];
|
||||
$: availableTargets = users.filter((u) => u.id !== sourceUserId && u.sections?.length >= 0);
|
||||
|
||||
function toggleTarget(id) {
|
||||
if (targetUserIds.includes(id)) {
|
||||
targetUserIds = targetUserIds.filter((t) => t !== id);
|
||||
} else {
|
||||
targetUserIds = [...targetUserIds, id];
|
||||
}
|
||||
}
|
||||
|
||||
function selectAllTargets() {
|
||||
targetUserIds = availableTargets.map((u) => u.id);
|
||||
}
|
||||
|
||||
function deselectAllTargets() {
|
||||
targetUserIds = [];
|
||||
}
|
||||
|
||||
function toggleSectionIndex(idx) {
|
||||
if (selectedSectionIndices.includes(idx)) {
|
||||
selectedSectionIndices = selectedSectionIndices.filter((i) => i !== idx);
|
||||
} else {
|
||||
selectedSectionIndices = [...selectedSectionIndices, idx];
|
||||
}
|
||||
}
|
||||
|
||||
function doSync() {
|
||||
if (!sourceUser || targetUserIds.length === 0) return;
|
||||
|
||||
let sectionsToSync;
|
||||
if (syncMode === 'selected') {
|
||||
sectionsToSync = selectedSectionIndices.map((i) => sourceSections[i]).filter(Boolean);
|
||||
} else {
|
||||
sectionsToSync = [...sourceSections];
|
||||
}
|
||||
|
||||
dispatch('sync', {
|
||||
sourceUserId,
|
||||
targetUserIds,
|
||||
sections: sectionsToSync,
|
||||
mode: syncMode
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="sync-panel">
|
||||
<h3>Sync sections between users</h3>
|
||||
|
||||
<div class="sync-step">
|
||||
<span class="step-label">1. Source user</span>
|
||||
<select bind:value={sourceUserId} class="select-input">
|
||||
<option value={null}>Select source user...</option>
|
||||
{#each users.filter((u) => u.sections?.length > 0) as user}
|
||||
<option value={user.id}>{user.name} ({user.sections.length} sections)</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if sourceUser}
|
||||
<div class="sync-step">
|
||||
<span class="step-label">2. Sync mode</span>
|
||||
<div class="mode-group">
|
||||
<label class="mode-option" class:active={syncMode === 'replace'}>
|
||||
<input type="radio" bind:group={syncMode} value="replace" />
|
||||
<div>
|
||||
<strong>Replace all</strong>
|
||||
<span class="mode-desc">Overwrite target's sections entirely</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="mode-option" class:active={syncMode === 'append'}>
|
||||
<input type="radio" bind:group={syncMode} value="append" />
|
||||
<div>
|
||||
<strong>Append all</strong>
|
||||
<span class="mode-desc">Add source sections to end of target</span>
|
||||
</div>
|
||||
</label>
|
||||
<label class="mode-option" class:active={syncMode === 'selected'}>
|
||||
<input type="radio" bind:group={syncMode} value="selected" />
|
||||
<div>
|
||||
<strong>Selected only</strong>
|
||||
<span class="mode-desc">Pick specific sections to sync</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if syncMode === 'selected'}
|
||||
<div class="sync-step">
|
||||
<span class="step-label">Sections to sync</span>
|
||||
<div class="section-pick-list">
|
||||
{#each sourceSections as section, idx}
|
||||
<label class="section-pick" class:selected={selectedSectionIndices.includes(idx)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedSectionIndices.includes(idx)}
|
||||
on:change={() => toggleSectionIndex(idx)}
|
||||
/>
|
||||
<span class="pick-name">{section.CustomName || section.Name || '(unnamed)'}</span>
|
||||
<span class="pick-type">{getSectionTypeLabel(section.SectionType)}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="sync-step">
|
||||
<div class="step-label-row">
|
||||
<span class="step-label">3. Target users</span>
|
||||
<div class="select-btns">
|
||||
<button class="link-btn" on:click={selectAllTargets}>All</button>
|
||||
<button class="link-btn" on:click={deselectAllTargets}>None</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="target-list">
|
||||
{#each availableTargets as user}
|
||||
<label class="target-option" class:selected={targetUserIds.includes(user.id)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={targetUserIds.includes(user.id)}
|
||||
on:change={() => toggleTarget(user.id)}
|
||||
/>
|
||||
<span class="target-name">{user.name}</span>
|
||||
<span class="target-count">{user.sections?.length || 0} sections</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="sync-btn"
|
||||
disabled={targetUserIds.length === 0 ||
|
||||
(syncMode === 'selected' && selectedSectionIndices.length === 0)}
|
||||
on:click={doSync}
|
||||
>
|
||||
Sync {syncMode === 'selected' ? `${selectedSectionIndices.length} section(s)` : 'all sections'}
|
||||
to {targetUserIds.length} user{targetUserIds.length !== 1 ? 's' : ''}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sync-panel {
|
||||
padding: 0;
|
||||
}
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
margin: 0 0 18px;
|
||||
color: var(--text);
|
||||
}
|
||||
.sync-step {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.step-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.step-label-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.select-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: #12151b;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.mode-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.mode-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: #111419;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s;
|
||||
}
|
||||
.mode-option.active {
|
||||
border-color: var(--border-strong);
|
||||
background: var(--surface-active);
|
||||
}
|
||||
.mode-option input {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.mode-option strong {
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
display: block;
|
||||
}
|
||||
.mode-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.section-pick-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.section-pick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.section-pick:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
.section-pick.selected {
|
||||
background: var(--surface-active);
|
||||
border: 1px solid var(--border-strong);
|
||||
}
|
||||
.pick-name {
|
||||
flex: 1;
|
||||
color: var(--text);
|
||||
}
|
||||
.pick-type {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.target-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.target-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.target-option:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
.target-option.selected {
|
||||
background: var(--surface-active);
|
||||
border: 1px solid var(--border-strong);
|
||||
}
|
||||
.target-name {
|
||||
flex: 1;
|
||||
color: var(--text);
|
||||
}
|
||||
.target-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.link-btn {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.link-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.sync-btn {
|
||||
all: unset;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background: var(--accent);
|
||||
border: 1px solid rgba(42, 215, 239, 0.35);
|
||||
color: #031014;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
transition: opacity 0.12s, background 0.12s ease;
|
||||
}
|
||||
.sync-btn:hover:not(:disabled) {
|
||||
background: #35d2ea;
|
||||
}
|
||||
.sync-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.select-btns {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
export const GENRES = {
|
||||
'2042': 'Action',
|
||||
'1212': 'Sci-Fi',
|
||||
'4910': 'Crime',
|
||||
'62': 'Drama',
|
||||
'82': 'Comedy',
|
||||
'293': 'Animation',
|
||||
'36': 'Documentary',
|
||||
'5024': 'Horror',
|
||||
'4835': 'Romance',
|
||||
'4428': 'Thriller',
|
||||
'5709': 'War',
|
||||
'14124': 'Western',
|
||||
'218': 'Food',
|
||||
'396654': 'Reality',
|
||||
'19618': 'Travel',
|
||||
'17565': 'Mini Series',
|
||||
'2008': 'Mystery',
|
||||
'235': 'Family',
|
||||
'480': 'Fantasy'
|
||||
};
|
||||
|
||||
export const SECTION_TYPES = [
|
||||
{ value: 'resume', label: 'Resume / Next Up' },
|
||||
{ value: 'items', label: 'Items (filtered)' },
|
||||
{ value: 'userviews', label: 'Libraries' },
|
||||
{ value: 'boxset', label: 'Box Set' },
|
||||
{ value: 'collections', label: 'Collections' },
|
||||
{ value: 'latestepisodereleases', label: 'Latest episode releases' },
|
||||
{ value: 'latestmoviereleases', label: 'Latest movie releases' },
|
||||
{ value: 'latestmediablock', label: 'Latest media' }
|
||||
];
|
||||
|
||||
export const COLLECTION_TYPES = [
|
||||
{ value: '', label: '(none)' },
|
||||
{ value: 'movies', label: 'Movies' },
|
||||
{ value: 'tvshows', label: 'TV Shows' },
|
||||
{ value: 'boxsets', label: 'Box Sets' }
|
||||
];
|
||||
|
||||
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' },
|
||||
{ value: 'CriticRating,SortName', label: 'Critic rating' },
|
||||
{ value: 'DateCreated,SortName', label: 'Date created' },
|
||||
{ value: 'Random', label: 'Random' },
|
||||
{ value: 'SortName', label: 'Name' }
|
||||
];
|
||||
|
||||
export const IMAGE_TYPES = [
|
||||
{ value: '', label: 'Default' },
|
||||
{ value: 'Thumb', label: 'Thumb' },
|
||||
{ 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);
|
||||
}
|
||||
|
||||
export function createEmptySection(userId) {
|
||||
return {
|
||||
UserId: userId,
|
||||
Name: 'New Section',
|
||||
CustomName: 'New Section',
|
||||
Id: genId(),
|
||||
SectionType: 'items',
|
||||
ImageType: 'Thumb',
|
||||
CollectionType: 'movies',
|
||||
SortBy: 'Random',
|
||||
SortOrder: 'Descending',
|
||||
Monitor: [],
|
||||
ItemTypes: ['Movie'],
|
||||
ExcludedFolders: [],
|
||||
CardSizeOffset: 0,
|
||||
IncludeNextUpInResume: true,
|
||||
Query: {
|
||||
StudioIds: [],
|
||||
TagIds: [],
|
||||
GenreIds: [],
|
||||
CollectionTypes: [],
|
||||
IsPlayed: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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: 'Random',
|
||||
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(', ');
|
||||
}
|
||||
|
||||
export function extractUserName(sections) {
|
||||
for (const s of sections) {
|
||||
const name = s.CustomName || s.Name || '';
|
||||
const extracted = extractWatchlistOwnerName(name);
|
||||
if (extracted && extracted !== name) return extracted;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isWatchlistSection(section) {
|
||||
if (!section) return false;
|
||||
|
||||
const name = `${section.CustomName || ''} ${section.Name || ''}`.toLowerCase();
|
||||
return !!section.Query?.IsFavorite || name.includes('watchlist') || name.includes('watch list');
|
||||
}
|
||||
|
||||
export function isUpNextSection(section) {
|
||||
if (!section) return false;
|
||||
if (section.SectionType === 'resume') return true;
|
||||
|
||||
const name = `${section.CustomName || ''} ${section.Name || ''}`.trim().toLowerCase();
|
||||
return name === 'up next' || name === 'next up' || name === 'resume / up next';
|
||||
}
|
||||
|
||||
export function isNewToEmbySection(section) {
|
||||
if (!section) return false;
|
||||
const name = `${section.CustomName || ''} ${section.Name || ''}`.toLowerCase();
|
||||
return name.includes('new to emby');
|
||||
}
|
||||
|
||||
export function isRecentlyWatchedSection(section) {
|
||||
if (!section) return false;
|
||||
const name = `${section.CustomName || ''} ${section.Name || ''}`.toLowerCase();
|
||||
return section.Query?.IsPlayed === true || name.includes('recently watched');
|
||||
}
|
||||
|
||||
export function isFixedOrderSection(section) {
|
||||
if (!section) return false;
|
||||
return (
|
||||
isUpNextSection(section) ||
|
||||
isWatchlistSection(section) ||
|
||||
isNewToEmbySection(section) ||
|
||||
isRecentlyWatchedSection(section) ||
|
||||
section.SectionType === 'latestepisodereleases' ||
|
||||
section.SectionType === 'latestmoviereleases' ||
|
||||
section.SectionType === 'latestmediablock' ||
|
||||
section.SectionType === 'userviews'
|
||||
);
|
||||
}
|
||||
|
||||
function renameWatchlistLabel(label, targetName) {
|
||||
if (!label || !targetName) return label;
|
||||
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 (/watchlist/i.test(label)) {
|
||||
return label.replace(/^.*?watchlist/i, `${targetName}'s Watchlist`);
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
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)
|
||||
};
|
||||
}
|
||||
|
||||
export function applySectionStandards(sourceSection, targetUser) {
|
||||
const section = JSON.parse(JSON.stringify(sourceSection || {}));
|
||||
|
||||
if (isUpNextSection(section)) {
|
||||
section.Name = 'Up Next';
|
||||
section.CustomName = 'Up Next';
|
||||
return section;
|
||||
}
|
||||
|
||||
if (isWatchlistSection(section)) {
|
||||
const labels = getWatchlistLabelsForTarget(section, targetUser);
|
||||
if (labels.Name) section.Name = labels.Name;
|
||||
if (labels.CustomName) section.CustomName = labels.CustomName;
|
||||
return section;
|
||||
}
|
||||
|
||||
if (isNewToEmbySection(section)) {
|
||||
section.Name = 'New to Emby';
|
||||
section.CustomName = 'New to Emby';
|
||||
section.SortBy = 'DateLastContentAdded,SortName';
|
||||
section.SortOrder = 'Descending';
|
||||
return section;
|
||||
}
|
||||
|
||||
if (isRecentlyWatchedSection(section)) {
|
||||
const targetName = getPreferredUserName(targetUser);
|
||||
const label = `Recently Watched${targetName ? ` - ${targetName}` : ''}`;
|
||||
section.Name = label;
|
||||
section.CustomName = label;
|
||||
section.SortBy = 'DatePlayed';
|
||||
section.SortOrder = 'Descending';
|
||||
return section;
|
||||
}
|
||||
|
||||
if (!isFixedOrderSection(section) && ['items', 'collections', 'boxset'].includes(section.SectionType)) {
|
||||
section.SortBy = 'Random';
|
||||
section.SortOrder = 'Descending';
|
||||
}
|
||||
|
||||
if (section.SectionType === 'userviews') {
|
||||
section.Name = 'Libraries';
|
||||
section.CustomName = 'Libraries';
|
||||
}
|
||||
|
||||
return section;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build SQL UPDATE statements from modified user data.
|
||||
* Each user's entire homescreensettings JSON is replaced.
|
||||
*/
|
||||
export function generateSQL(users, originalUsers) {
|
||||
const statements = [];
|
||||
|
||||
for (const user of users) {
|
||||
if (!user.sections || user.sections.length === 0) continue;
|
||||
|
||||
const original = originalUsers.find((u) => u.id === user.id);
|
||||
if (!original) continue;
|
||||
|
||||
const origJSON = JSON.stringify({ Sections: original.sections });
|
||||
const newJSON = JSON.stringify({ Sections: user.sections });
|
||||
|
||||
if (origJSON === newJSON) continue;
|
||||
|
||||
const escapedValue = newJSON.replace(/'/g, "''");
|
||||
statements.push(
|
||||
`-- User: ${user.name} (DB ID: ${user.id})`,
|
||||
`UPDATE UserSettings SET Value = '${escapedValue}' WHERE UserId = ${user.id} AND UserSettingsKeyId = (SELECT UserSettingsKeyId FROM UserSettingsKeys WHERE Name = 'homescreensettings');`,
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
if (statements.length === 0) {
|
||||
return '-- No changes detected';
|
||||
}
|
||||
|
||||
return [
|
||||
'-- ===========================================',
|
||||
'-- Emby Home Screen Settings Update',
|
||||
`-- Generated: ${new Date().toISOString()}`,
|
||||
'-- ===========================================',
|
||||
'-- IMPORTANT: Stop Emby before running this!',
|
||||
'-- sqlite3 /path/to/users.db < this_file.sql',
|
||||
'-- Then restart Emby.',
|
||||
'-- ===========================================',
|
||||
'',
|
||||
'BEGIN TRANSACTION;',
|
||||
'',
|
||||
...statements,
|
||||
'COMMIT;'
|
||||
].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
function normalizeGenreKey(value) {
|
||||
return String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '');
|
||||
}
|
||||
|
||||
export function normalizeGenreNames(values) {
|
||||
const seen = new Set();
|
||||
const names = [];
|
||||
|
||||
for (const value of values || []) {
|
||||
const trimmed = String(value || '').trim();
|
||||
if (!trimmed) continue;
|
||||
const key = normalizeGenreKey(trimmed);
|
||||
if (!key || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
names.push(trimmed);
|
||||
}
|
||||
|
||||
return names;
|
||||
}
|
||||
|
||||
export function pickSuggestedGenre(tmdbGenres, currentGenres = []) {
|
||||
const normalizedTmdbGenres = normalizeGenreNames(tmdbGenres);
|
||||
if (!normalizedTmdbGenres.length) return '';
|
||||
|
||||
const currentKeys = new Set(normalizeGenreNames(currentGenres).map(normalizeGenreKey));
|
||||
const matched = normalizedTmdbGenres.find((genre) => currentKeys.has(normalizeGenreKey(genre)));
|
||||
|
||||
return matched || normalizedTmdbGenres[0];
|
||||
}
|
||||
|
||||
export function buildSingleGenreUpdate(item, genreName) {
|
||||
const selectedGenre = String(genreName || '').trim();
|
||||
if (!selectedGenre) {
|
||||
throw new Error('A genre is required');
|
||||
}
|
||||
|
||||
const nextItem = JSON.parse(JSON.stringify(item || {}));
|
||||
const existingGenreItems = Array.isArray(item?.GenreItems) ? item.GenreItems : [];
|
||||
const matchedGenreItem = existingGenreItems.find(
|
||||
(entry) => normalizeGenreKey(entry?.Name) === normalizeGenreKey(selectedGenre)
|
||||
);
|
||||
|
||||
nextItem.Genres = [selectedGenre];
|
||||
nextItem.GenreItems = [
|
||||
matchedGenreItem
|
||||
? { ...matchedGenreItem, Name: selectedGenre }
|
||||
: { Name: selectedGenre }
|
||||
];
|
||||
|
||||
return nextItem;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const CONFIG_PATH = resolve('config.json');
|
||||
|
||||
export function loadEmbyConfig() {
|
||||
if (!existsSync(CONFIG_PATH)) return {};
|
||||
try {
|
||||
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeEmbyGuid(value) {
|
||||
return typeof value === 'string' ? value.replace(/-/g, '').trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
export function buildEmbyUrl(pathname, params = {}) {
|
||||
const { embyUrl, apiKey } = loadEmbyConfig();
|
||||
if (!embyUrl || !apiKey) {
|
||||
throw new Error('Emby URL and API key not configured');
|
||||
}
|
||||
|
||||
const base = embyUrl.replace(/\/+$/, '');
|
||||
const path = pathname.startsWith('/') ? pathname : `/${pathname}`;
|
||||
const url = new URL(`${base}${path}`);
|
||||
|
||||
for (const [key, value] of Object.entries({
|
||||
...params,
|
||||
api_key: apiKey
|
||||
})) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
|
||||
return { url, apiKey };
|
||||
}
|
||||
|
||||
export async function fetchEmby(pathname, options = {}) {
|
||||
const {
|
||||
params = {},
|
||||
method = 'GET',
|
||||
headers = {},
|
||||
body
|
||||
} = options;
|
||||
const { url, apiKey } = buildEmbyUrl(pathname, params);
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Emby-Token': apiKey,
|
||||
...headers
|
||||
},
|
||||
body
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => response.statusText);
|
||||
throw new Error(text || `${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function fetchEmbyJson(pathname, options = {}) {
|
||||
const response = await fetchEmby(pathname, options);
|
||||
return response.json();
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
function getCacheDbPath() {
|
||||
return process.env.EMBY_USER_CACHE_DB_PATH || resolve('.cache', 'emby-users.db');
|
||||
}
|
||||
|
||||
function ensureCacheDir() {
|
||||
const dir = dirname(getCacheDbPath());
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function openCacheDb() {
|
||||
ensureCacheDir();
|
||||
const db = new DatabaseSync(getCacheDbPath());
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS EmbyUsers (
|
||||
embyGuid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
fetchedAt TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS CacheMeta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
return db;
|
||||
}
|
||||
|
||||
function normalizeGuid(value) {
|
||||
return typeof value === 'string' ? value.replace(/-/g, '').trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
export function normalizeEmbyUsers(users) {
|
||||
if (!Array.isArray(users)) return [];
|
||||
|
||||
return users
|
||||
.map((user) => ({
|
||||
embyGuid: normalizeGuid(user?.embyGuid ?? user?.Id),
|
||||
name: String(user?.name ?? user?.Name ?? '').trim()
|
||||
}))
|
||||
.filter((user) => user.embyGuid && user.name);
|
||||
}
|
||||
|
||||
export function writeCachedEmbyUsers(users) {
|
||||
const normalizedUsers = normalizeEmbyUsers(users);
|
||||
const fetchedAt = new Date().toISOString();
|
||||
const db = openCacheDb();
|
||||
|
||||
try {
|
||||
const clearStmt = db.prepare('DELETE FROM EmbyUsers');
|
||||
const insertStmt = db.prepare(
|
||||
'INSERT INTO EmbyUsers (embyGuid, name, fetchedAt) VALUES (?, ?, ?)'
|
||||
);
|
||||
const metaStmt = db.prepare(
|
||||
'INSERT INTO CacheMeta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
);
|
||||
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
clearStmt.run();
|
||||
for (const user of normalizedUsers) {
|
||||
insertStmt.run(user.embyGuid, user.name, fetchedAt);
|
||||
}
|
||||
metaStmt.run('lastSyncedAt', fetchedAt);
|
||||
db.exec('COMMIT');
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
return { users: normalizedUsers, lastSyncedAt: fetchedAt };
|
||||
}
|
||||
|
||||
export function readCachedEmbyUsers() {
|
||||
if (!existsSync(getCacheDbPath())) {
|
||||
return { users: [], lastSyncedAt: null };
|
||||
}
|
||||
|
||||
const db = openCacheDb();
|
||||
try {
|
||||
const users = db
|
||||
.prepare('SELECT embyGuid, name, fetchedAt FROM EmbyUsers ORDER BY lower(name), embyGuid')
|
||||
.all()
|
||||
.map((row) => ({
|
||||
embyGuid: normalizeGuid(row.embyGuid),
|
||||
name: row.name
|
||||
}));
|
||||
const meta = db.prepare("SELECT value FROM CacheMeta WHERE key = 'lastSyncedAt'").get();
|
||||
|
||||
return {
|
||||
users,
|
||||
lastSyncedAt: meta?.value || users[0]?.fetchedAt || null
|
||||
};
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedEmbyUserMap() {
|
||||
return new Map(readCachedEmbyUsers().users.map((user) => [user.embyGuid, user]));
|
||||
}
|
||||
|
||||
export function applyCachedEmbyNames(users) {
|
||||
const cached = readCachedEmbyUsers();
|
||||
const lookup = new Map(cached.users.map((user) => [user.embyGuid, user]));
|
||||
let matchedCount = 0;
|
||||
|
||||
const hydratedUsers = (users || []).map((user) => {
|
||||
const embyGuid = normalizeGuid(user?.embyGuid);
|
||||
const cachedUser = lookup.get(embyGuid);
|
||||
if (!cachedUser) {
|
||||
return user;
|
||||
}
|
||||
|
||||
matchedCount++;
|
||||
return {
|
||||
...user,
|
||||
dbName: user.dbName || user.name,
|
||||
embyName: cachedUser.name,
|
||||
name: cachedUser.name
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
users: hydratedUsers,
|
||||
cache: {
|
||||
matchedCount,
|
||||
totalCachedUsers: lookup.size,
|
||||
lastSyncedAt: cached.lastSyncedAt
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { dirname, resolve } from 'path';
|
||||
|
||||
const CACHE_PATH = resolve('.cache', 'emby-user-context.json');
|
||||
|
||||
function ensureCacheDir() {
|
||||
const dir = dirname(CACHE_PATH);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGuid(value) {
|
||||
return typeof value === 'string' ? value.replace(/-/g, '').trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
function loadCacheFile() {
|
||||
if (!existsSync(CACHE_PATH)) {
|
||||
return { users: {} };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(CACHE_PATH, 'utf8'));
|
||||
return parsed && typeof parsed === 'object' ? parsed : { users: {} };
|
||||
} catch {
|
||||
return { users: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function saveCacheFile(cache) {
|
||||
ensureCacheDir();
|
||||
writeFileSync(CACHE_PATH, JSON.stringify(cache, null, 2));
|
||||
}
|
||||
|
||||
export function readCachedEmbyUserContext(embyGuid) {
|
||||
const normalizedGuid = normalizeGuid(embyGuid);
|
||||
if (!normalizedGuid) return null;
|
||||
|
||||
const cache = loadCacheFile();
|
||||
return cache.users?.[normalizedGuid] || null;
|
||||
}
|
||||
|
||||
export function writeCachedEmbyUserContext(embyGuid, context) {
|
||||
const normalizedGuid = normalizeGuid(embyGuid);
|
||||
if (!normalizedGuid) return null;
|
||||
|
||||
const cache = loadCacheFile();
|
||||
const nextContext = {
|
||||
views: Array.isArray(context?.views) ? context.views : [],
|
||||
recentlyPlayed: Array.isArray(context?.recentlyPlayed) ? context.recentlyPlayed : [],
|
||||
excludedFolderLookup: context?.excludedFolderLookup || {},
|
||||
lastSyncedAt: context?.lastSyncedAt || new Date().toISOString()
|
||||
};
|
||||
|
||||
cache.users ||= {};
|
||||
cache.users[normalizedGuid] = nextContext;
|
||||
saveCacheFile(cache);
|
||||
return nextContext;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
function parseJsonBlob(blob) {
|
||||
if (!blob) return null;
|
||||
try {
|
||||
const text = typeof blob === 'string' ? blob : Buffer.from(blob).toString('utf8');
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emby stores GUIDs in SQLite as 16-byte blobs using Microsoft mixed-endian ordering.
|
||||
* Components 1-3 are little-endian; components 4-5 are big-endian.
|
||||
*/
|
||||
export function blobToEmbyGuid(blob) {
|
||||
if (!blob) return '';
|
||||
const b = blob instanceof Uint8Array ? blob : new Uint8Array(blob);
|
||||
if (b.length !== 16) return Buffer.from(b).toString('hex').toLowerCase();
|
||||
const out = new Uint8Array([
|
||||
b[3], b[2], b[1], b[0],
|
||||
b[5], b[4],
|
||||
b[7], b[6],
|
||||
b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]
|
||||
]);
|
||||
return Buffer.from(out).toString('hex').toLowerCase();
|
||||
}
|
||||
|
||||
function hasTable(db, tableName) {
|
||||
const row = db
|
||||
.prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(tableName);
|
||||
return !!row?.found;
|
||||
}
|
||||
|
||||
function normalizeSectionUserId(sectionUserId) {
|
||||
return typeof sectionUserId === 'string' ? sectionUserId.trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
export function normalizeSectionsForUser(sections, expectedEmbyGuid) {
|
||||
if (!Array.isArray(sections)) return [];
|
||||
if (!expectedEmbyGuid) return sections;
|
||||
|
||||
return sections.map((section) => ({
|
||||
...section,
|
||||
UserId: expectedEmbyGuid
|
||||
}));
|
||||
}
|
||||
|
||||
function loadUsersTableUsers(db) {
|
||||
const cols = db.prepare('PRAGMA table_info(Users)').all().map((c) => c.name);
|
||||
const nameCol = cols.find((c) => /^username$/i.test(c)) || cols.find((c) => /^name$/i.test(c));
|
||||
const guidCol = cols.find((c) => /^guid$/i.test(c));
|
||||
const idCol = cols.find((c) => /^id$/i.test(c)) || 'Id';
|
||||
|
||||
if (!nameCol) {
|
||||
throw new Error(`Cannot find a name column in Users table. Columns found: ${cols.join(', ')}`);
|
||||
}
|
||||
|
||||
const selectCols = [idCol, nameCol, guidCol].filter(Boolean).join(', ');
|
||||
const rows = db.prepare(`SELECT ${selectCols} FROM Users`).all();
|
||||
|
||||
return rows.map((row) => {
|
||||
const rawId = row[idCol] ?? row.Id ?? row.id;
|
||||
const rawGuid = guidCol ? row[guidCol] : null;
|
||||
let embyGuid = '';
|
||||
let guid = '';
|
||||
|
||||
if (rawGuid) {
|
||||
if (rawGuid instanceof Uint8Array || rawGuid instanceof Buffer) {
|
||||
const buf = Buffer.from(rawGuid);
|
||||
guid = buf.toString('hex').toUpperCase();
|
||||
embyGuid = blobToEmbyGuid(rawGuid);
|
||||
} else if (typeof rawGuid === 'string') {
|
||||
const clean = rawGuid.replace(/-/g, '').toLowerCase();
|
||||
embyGuid = clean;
|
||||
guid = clean.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: rawId,
|
||||
name: row[nameCol] || `User ${rawId}`,
|
||||
guid,
|
||||
embyGuid,
|
||||
sourceTable: 'Users'
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function loadLocalUsers(db) {
|
||||
const rows = db.prepare('SELECT Id, guid, data FROM LocalUsersv2').all();
|
||||
|
||||
return rows.map((row) => {
|
||||
const parsed = parseJsonBlob(row.data);
|
||||
const guid = row.guid ? Buffer.from(row.guid).toString('hex').toUpperCase() : '';
|
||||
const embyGuidFromBlob = blobToEmbyGuid(row.guid);
|
||||
const embyGuidFromJson = normalizeSectionUserId(parsed?.IdString);
|
||||
const embyGuid = embyGuidFromJson || embyGuidFromBlob;
|
||||
|
||||
return {
|
||||
id: row.Id,
|
||||
name: parsed?.Name || `User ${row.Id}`,
|
||||
guid,
|
||||
embyGuid,
|
||||
sourceTable: 'LocalUsersv2',
|
||||
profile: parsed || null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function loadCanonicalUsers(db) {
|
||||
if (hasTable(db, 'LocalUsersv2')) {
|
||||
return loadLocalUsers(db);
|
||||
}
|
||||
if (hasTable(db, 'Users')) {
|
||||
return loadUsersTableUsers(db);
|
||||
}
|
||||
throw new Error('No supported user table found. Expected LocalUsersv2 or Users.');
|
||||
}
|
||||
|
||||
export function loadHomeScreenUsers(db) {
|
||||
const users = loadCanonicalUsers(db);
|
||||
const settingsRows = db
|
||||
.prepare(
|
||||
`SELECT us.UserId, us.Value
|
||||
FROM UserSettings us
|
||||
JOIN UserSettingsKeys usk ON us.UserSettingsKeyId = usk.UserSettingsKeyId
|
||||
WHERE usk.Name = 'homescreensettings'`
|
||||
)
|
||||
.all();
|
||||
|
||||
const settingsMap = new Map(settingsRows.map((row) => [String(row.UserId), row.Value]));
|
||||
const userIds = new Set(users.map((user) => String(user.id)));
|
||||
|
||||
let matchedUsers = 0;
|
||||
let mismatchedUsers = 0;
|
||||
let normalizedUsers = 0;
|
||||
let missingSectionUserIds = 0;
|
||||
|
||||
const hydratedUsers = users.map((user) => {
|
||||
const rawValue = settingsMap.get(String(user.id));
|
||||
let sections = [];
|
||||
|
||||
try {
|
||||
if (rawValue) sections = JSON.parse(rawValue).Sections || [];
|
||||
} catch {
|
||||
sections = [];
|
||||
}
|
||||
|
||||
const actualUserIds = [...new Set(sections.map((section) => normalizeSectionUserId(section?.UserId)).filter(Boolean))];
|
||||
const mismatchedSectionUserIds = user.embyGuid
|
||||
? actualUserIds.filter((id) => id !== user.embyGuid)
|
||||
: actualUserIds;
|
||||
const missingIdsForUser = sections.filter((section) => !normalizeSectionUserId(section?.UserId)).length;
|
||||
const normalizedSections = normalizeSectionsForUser(sections, user.embyGuid);
|
||||
const sectionsWereNormalized =
|
||||
user.embyGuid &&
|
||||
JSON.stringify(sections) !== JSON.stringify(normalizedSections);
|
||||
|
||||
if (mismatchedSectionUserIds.length === 0) matchedUsers++;
|
||||
else mismatchedUsers++;
|
||||
if (sectionsWereNormalized) normalizedUsers++;
|
||||
missingSectionUserIds += missingIdsForUser;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
guid: user.guid,
|
||||
embyGuid: user.embyGuid,
|
||||
sections: normalizedSections,
|
||||
details: {
|
||||
sourceTable: user.sourceTable,
|
||||
lastLoginDate: user.profile?.LastLoginDate || null,
|
||||
lastActivityDate: user.profile?.LastActivityDate || null,
|
||||
usesIdForConfigurationPath: user.profile?.UsesIdForConfigurationPath ?? null,
|
||||
importedCollectionsCount: Array.isArray(user.profile?.ImportedCollections) ? user.profile.ImportedCollections.length : 0
|
||||
},
|
||||
match: {
|
||||
sourceTable: user.sourceTable,
|
||||
settingsUserId: user.id,
|
||||
expectedSectionUserId: user.embyGuid,
|
||||
actualSectionUserIds: actualUserIds,
|
||||
mismatchedSectionUserIds,
|
||||
missingSectionUserIds: missingIdsForUser,
|
||||
ok: mismatchedSectionUserIds.length === 0
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const orphanedSettingsUserIds = settingsRows
|
||||
.map((row) => String(row.UserId))
|
||||
.filter((userId, index, all) => all.indexOf(userId) === index && !userIds.has(userId));
|
||||
|
||||
return {
|
||||
users: hydratedUsers,
|
||||
validation: {
|
||||
userSource: users[0]?.sourceTable || null,
|
||||
userCount: hydratedUsers.length,
|
||||
settingsCount: settingsRows.length,
|
||||
matchedUsers,
|
||||
mismatchedUsers,
|
||||
normalizedUsers,
|
||||
missingSectionUserIds,
|
||||
orphanedSettingsUserIds
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function loadUserLookup(db) {
|
||||
return new Map(loadCanonicalUsers(db).map((user) => [String(user.id), user]));
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const CONFIG_PATH = resolve('config.json');
|
||||
const TMDB_BASE = 'https://api.themoviedb.org/3';
|
||||
|
||||
function loadConfig() {
|
||||
if (!existsSync(CONFIG_PATH)) return {};
|
||||
try {
|
||||
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function loadTmdbConfig() {
|
||||
const config = loadConfig();
|
||||
return {
|
||||
tmdbApiKey: String(config?.tmdbApiKey || '').trim()
|
||||
};
|
||||
}
|
||||
|
||||
export function hasTmdbConfig() {
|
||||
return !!loadTmdbConfig().tmdbApiKey;
|
||||
}
|
||||
|
||||
async function fetchTmdb(pathname, params = {}) {
|
||||
const { tmdbApiKey } = loadTmdbConfig();
|
||||
if (!tmdbApiKey) {
|
||||
throw new Error('TMDB API key not configured');
|
||||
}
|
||||
|
||||
const url = new URL(`${TMDB_BASE}${pathname.startsWith('/') ? pathname : `/${pathname}`}`);
|
||||
for (const [key, value] of Object.entries({
|
||||
api_key: tmdbApiKey,
|
||||
language: 'en-US',
|
||||
include_adult: 'false',
|
||||
...params
|
||||
})) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: { Accept: 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => response.statusText);
|
||||
throw new Error(text || `${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function normalizeTmdbSearchItem(item, mediaType) {
|
||||
return {
|
||||
tmdbId: Number(item?.id || 0) || null,
|
||||
name: item?.title || item?.name || '',
|
||||
year: Number(String(item?.release_date || item?.first_air_date || '').slice(0, 4)) || null,
|
||||
mediaType,
|
||||
overview: item?.overview || '',
|
||||
genreIds: Array.isArray(item?.genre_ids) ? item.genre_ids : [],
|
||||
originalLanguage: item?.original_language || '',
|
||||
popularity: Number(item?.popularity || 0),
|
||||
voteAverage: Number(item?.vote_average || 0),
|
||||
voteCount: Number(item?.vote_count || 0)
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchTmdbByTitle({ mediaType, name, year }) {
|
||||
const searchType = mediaType === 'tv' ? 'tv' : 'movie';
|
||||
const payload = await fetchTmdb(`/search/${searchType}`, {
|
||||
query: name,
|
||||
page: 1,
|
||||
...(year ? (searchType === 'movie' ? { year } : { first_air_date_year: year }) : {})
|
||||
});
|
||||
|
||||
return (payload?.results || []).map((item) => normalizeTmdbSearchItem(item, searchType));
|
||||
}
|
||||
|
||||
export async function fetchTmdbSimilar({ mediaType, tmdbId, page = 1 }) {
|
||||
const pathType = mediaType === 'tv' ? 'tv' : 'movie';
|
||||
const payload = await fetchTmdb(`/${pathType}/${encodeURIComponent(tmdbId)}/similar`, { page });
|
||||
return (payload?.results || []).map((item) => normalizeTmdbSearchItem(item, pathType));
|
||||
}
|
||||
|
||||
export async function fetchTmdbRecommendations({ mediaType, tmdbId, page = 1 }) {
|
||||
const pathType = mediaType === 'tv' ? 'tv' : 'movie';
|
||||
const payload = await fetchTmdb(`/${pathType}/${encodeURIComponent(tmdbId)}/recommendations`, { page });
|
||||
return (payload?.results || []).map((item) => normalizeTmdbSearchItem(item, pathType));
|
||||
}
|
||||
|
||||
export async function fetchTmdbDetails({ mediaType, tmdbId }) {
|
||||
const pathType = mediaType === 'tv' ? 'tv' : 'movie';
|
||||
return fetchTmdb(`/${pathType}/${encodeURIComponent(tmdbId)}`);
|
||||
}
|
||||
|
||||
export async function fetchTmdbKeywords({ mediaType, tmdbId }) {
|
||||
const pathType = mediaType === 'tv' ? 'tv' : 'movie';
|
||||
const payload = await fetchTmdb(`/${pathType}/${encodeURIComponent(tmdbId)}/keywords`);
|
||||
return payload?.keywords || payload?.results || [];
|
||||
}
|
||||
|
||||
export async function fetchTmdbCredits({ mediaType, tmdbId }) {
|
||||
if (mediaType === 'tv') {
|
||||
return fetchTmdb(`/tv/${encodeURIComponent(tmdbId)}/aggregate_credits`);
|
||||
}
|
||||
|
||||
return fetchTmdb(`/movie/${encodeURIComponent(tmdbId)}/credits`);
|
||||
}
|
||||
|
||||
export async function fetchTmdbGenres(mediaType) {
|
||||
const pathType = mediaType === 'tv' ? 'tv' : 'movie';
|
||||
const payload = await fetchTmdb(`/genre/${pathType}/list`, { language: 'en' });
|
||||
return payload?.genres || [];
|
||||
}
|
||||
|
||||
export async function fetchTmdbDiscover({ mediaType, params = {} }) {
|
||||
const pathType = mediaType === 'tv' ? 'tv' : 'movie';
|
||||
const payload = await fetchTmdb(`/discover/${pathType}`, params);
|
||||
return (payload?.results || []).map((item) => normalizeTmdbSearchItem(item, pathType));
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { applyCachedEmbyNames } from '../lib/server/emby-user-cache.js';
|
||||
|
||||
/** @type {import('./$types').PageServerLoad} */
|
||||
export async function load() {
|
||||
const dataPath = resolve('static/db_export.json');
|
||||
const raw = readFileSync(dataPath, 'utf-8');
|
||||
const data = JSON.parse(raw);
|
||||
const enrichedUsers = applyCachedEmbyNames(data.users);
|
||||
|
||||
const configPath = resolve('config.json');
|
||||
let config = { embyUrl: '', apiKey: '', tmdbApiKey: '', dbPath: '' };
|
||||
if (existsSync(configPath)) {
|
||||
try {
|
||||
config = {
|
||||
...config,
|
||||
...JSON.parse(readFileSync(configPath, 'utf-8'))
|
||||
};
|
||||
} catch { /* use defaults */ }
|
||||
}
|
||||
|
||||
return {
|
||||
users: enrichedUsers.users,
|
||||
genres: data.genres,
|
||||
enums: data.enums,
|
||||
config,
|
||||
embyCache: enrichedUsers.cache
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { json } from '@sveltejs/kit';
|
||||
|
||||
const CONFIG_PATH = resolve('config.json');
|
||||
const DEFAULT_CONFIG = { embyUrl: '', apiKey: '', tmdbApiKey: '', dbPath: '' };
|
||||
|
||||
function loadConfig() {
|
||||
if (!existsSync(CONFIG_PATH)) return DEFAULT_CONFIG;
|
||||
try {
|
||||
return {
|
||||
...DEFAULT_CONFIG,
|
||||
...JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'))
|
||||
};
|
||||
} catch {
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return json(loadConfig());
|
||||
}
|
||||
|
||||
export async function POST({ request }) {
|
||||
const body = await request.json();
|
||||
const config = {
|
||||
embyUrl: String(body.embyUrl || '').trim(),
|
||||
apiKey: String(body.apiKey || '').trim(),
|
||||
tmdbApiKey: String(body.tmdbApiKey || '').trim(),
|
||||
dbPath: String(body.dbPath || '').trim()
|
||||
};
|
||||
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
||||
return json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { existsSync } from 'fs';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { loadHomeScreenUsers } from '../../../lib/server/emby-user-db.js';
|
||||
import { applyCachedEmbyNames } from '../../../lib/server/emby-user-cache.js';
|
||||
|
||||
export async function POST({ request }) {
|
||||
const { dbPath } = await request.json();
|
||||
if (!dbPath) throw error(400, 'No dbPath provided');
|
||||
if (!existsSync(dbPath)) throw error(404, `Database file not found: ${dbPath}`);
|
||||
|
||||
let db;
|
||||
try {
|
||||
db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
const result = loadHomeScreenUsers(db);
|
||||
const enriched = applyCachedEmbyNames(result.users);
|
||||
db.close();
|
||||
return json({
|
||||
...result,
|
||||
users: enriched.users,
|
||||
validation: {
|
||||
...result.validation,
|
||||
embyCacheMatchedUsers: enriched.cache.matchedCount,
|
||||
embyCacheUserCount: enriched.cache.totalCachedUsers,
|
||||
embyCacheLastSyncedAt: enriched.cache.lastSyncedAt
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
if (db) try { db.close(); } catch { /* ignore */ }
|
||||
throw error(500, err.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { existsSync } from 'fs';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { loadUserLookup, normalizeSectionsForUser } from '../../../lib/server/emby-user-db.js';
|
||||
|
||||
export async function POST({ request }) {
|
||||
const { dbPath, changes } = await request.json();
|
||||
if (!dbPath) throw error(400, 'No dbPath provided');
|
||||
if (!existsSync(dbPath)) throw error(404, `Database file not found: ${dbPath}`);
|
||||
if (!changes?.length) return json({ ok: true, count: 0 });
|
||||
|
||||
let db;
|
||||
try {
|
||||
db = new DatabaseSync(dbPath);
|
||||
const userLookup = loadUserLookup(db);
|
||||
|
||||
const keyRow = db
|
||||
.prepare("SELECT UserSettingsKeyId FROM UserSettingsKeys WHERE Name = 'homescreensettings'")
|
||||
.get();
|
||||
if (!keyRow) throw new Error("'homescreensettings' key not found in UserSettingsKeys table");
|
||||
const keyId = keyRow.UserSettingsKeyId;
|
||||
|
||||
const checkStmt = db.prepare(
|
||||
'SELECT 1 FROM UserSettings WHERE UserId = ? AND UserSettingsKeyId = ?'
|
||||
);
|
||||
const updateStmt = db.prepare(
|
||||
'UPDATE UserSettings SET Value = ? WHERE UserId = ? AND UserSettingsKeyId = ?'
|
||||
);
|
||||
const insertStmt = db.prepare(
|
||||
'INSERT INTO UserSettings (UserId, UserSettingsKeyId, Value) VALUES (?, ?, ?)'
|
||||
);
|
||||
|
||||
let count = 0;
|
||||
let normalizedSections = 0;
|
||||
|
||||
// node:sqlite transactions: use db.exec('BEGIN') / db.exec('COMMIT') manually
|
||||
// or wrap in a function with db.transaction() if supported
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
for (const { userId, sections } of changes) {
|
||||
const user = userLookup.get(String(userId));
|
||||
if (!user) {
|
||||
throw new Error(`UserId ${userId} does not exist in ${dbPath}`);
|
||||
}
|
||||
|
||||
const nextSections = normalizeSectionsForUser(sections, user.embyGuid);
|
||||
if (JSON.stringify(nextSections) !== JSON.stringify(sections)) {
|
||||
normalizedSections += nextSections.length;
|
||||
}
|
||||
|
||||
const value = JSON.stringify({ Sections: nextSections });
|
||||
const exists = checkStmt.get(userId, keyId);
|
||||
if (exists) {
|
||||
updateStmt.run(value, userId, keyId);
|
||||
} else {
|
||||
insertStmt.run(userId, keyId, value);
|
||||
}
|
||||
count++;
|
||||
}
|
||||
db.exec('COMMIT');
|
||||
} catch (err) {
|
||||
db.exec('ROLLBACK');
|
||||
throw err;
|
||||
}
|
||||
|
||||
db.close();
|
||||
return json({ ok: true, count, normalizedSections });
|
||||
} catch (err) {
|
||||
if (db) try { db.close(); } catch { /* ignore */ }
|
||||
throw error(500, err.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import {
|
||||
RECOMMENDATION_PROFILES,
|
||||
rankRecommendationResults,
|
||||
normalizeLookupItem
|
||||
} from '../../../lib/collection-tools.js';
|
||||
import { fetchEmby, fetchEmbyJson, normalizeEmbyGuid } from '../../../lib/server/emby-api.js';
|
||||
import {
|
||||
fetchTmdbCredits,
|
||||
fetchTmdbDetails,
|
||||
fetchTmdbDiscover,
|
||||
fetchTmdbGenres,
|
||||
fetchTmdbKeywords,
|
||||
fetchTmdbRecommendations,
|
||||
fetchTmdbSimilar,
|
||||
searchTmdbByTitle
|
||||
} from '../../../lib/server/tmdb-api.js';
|
||||
|
||||
const EMBY_PAGE_SIZE = 200;
|
||||
|
||||
function normalizeTitle(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '');
|
||||
}
|
||||
|
||||
function normalizeYear(value) {
|
||||
const year = Number(value || 0);
|
||||
return Number.isFinite(year) && year > 0 ? year : null;
|
||||
}
|
||||
|
||||
function titleYearKey(name, year) {
|
||||
return `${normalizeTitle(name)}::${normalizeYear(year) || ''}`;
|
||||
}
|
||||
|
||||
function normalizeCollectionName(name) {
|
||||
return String(name || '')
|
||||
.toLowerCase()
|
||||
.replace(/['’]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '');
|
||||
}
|
||||
|
||||
function getSeedTmdbId(item) {
|
||||
const providerIds = item?.providerIds || {};
|
||||
return String(providerIds.Tmdb || providerIds.TMDB || providerIds.tmdb || '').trim();
|
||||
}
|
||||
|
||||
function getTmdbMediaType(item) {
|
||||
return item?.type === 'Series' ? 'tv' : 'movie';
|
||||
}
|
||||
|
||||
function summarizeItems(items, limit = 5) {
|
||||
return (items || []).slice(0, limit).map((item) => ({
|
||||
name: item?.name || item?.title || item?.Name || '',
|
||||
year: item?.year || item?.ProductionYear || null,
|
||||
id: item?.id || item?.Id || '',
|
||||
type: item?.type || item?.Type || ''
|
||||
}));
|
||||
}
|
||||
|
||||
function filterEnglishCandidates(items) {
|
||||
return (items || []).filter((item) => !item?.originalLanguage || item.originalLanguage === 'en');
|
||||
}
|
||||
|
||||
async function fetchAllUserItems(userId, params) {
|
||||
const allItems = [];
|
||||
let startIndex = 0;
|
||||
|
||||
while (true) {
|
||||
const payload = await fetchEmbyJson(`/Users/${encodeURIComponent(userId)}/Items`, {
|
||||
params: {
|
||||
Recursive: true,
|
||||
GroupItemsIntoCollections: false,
|
||||
Limit: EMBY_PAGE_SIZE,
|
||||
StartIndex: startIndex,
|
||||
...params
|
||||
}
|
||||
});
|
||||
|
||||
const pageItems = (payload.Items || payload || []).map(normalizeLookupItem);
|
||||
allItems.push(...pageItems);
|
||||
|
||||
const total = Number(payload.TotalRecordCount || 0);
|
||||
if (!pageItems.length) break;
|
||||
if (total > 0 && allItems.length >= total) break;
|
||||
if (pageItems.length < EMBY_PAGE_SIZE) break;
|
||||
|
||||
startIndex += pageItems.length;
|
||||
}
|
||||
|
||||
return allItems;
|
||||
}
|
||||
|
||||
async function fetchAllLibraryItems(userId) {
|
||||
return fetchAllUserItems(userId, {
|
||||
IncludeItemTypes: 'Movie,Series',
|
||||
Fields: 'Overview,Genres,CommunityRating,ProductionYear,ProviderIds'
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAllPlayedItems(userId) {
|
||||
return fetchAllUserItems(userId, {
|
||||
Filters: 'IsPlayed',
|
||||
IncludeItemTypes: 'Movie,Series',
|
||||
SortBy: 'DatePlayed',
|
||||
SortOrder: 'Descending',
|
||||
Fields: 'Overview,Genres,CommunityRating,ProductionYear,ProviderIds,UserData'
|
||||
});
|
||||
}
|
||||
|
||||
function uniqueById(items) {
|
||||
const seen = new Set();
|
||||
const unique = [];
|
||||
for (const item of items || []) {
|
||||
if (!item?.id || seen.has(item.id)) continue;
|
||||
seen.add(item.id);
|
||||
unique.push(item);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function buildLibraryIndex(items) {
|
||||
const byId = new Map();
|
||||
const byTmdbId = new Map();
|
||||
const byTitleYear = new Map();
|
||||
const byTitle = new Map();
|
||||
|
||||
for (const item of items || []) {
|
||||
if (!item?.id) continue;
|
||||
byId.set(item.id, item);
|
||||
|
||||
const tmdbId = getSeedTmdbId(item);
|
||||
if (tmdbId && !byTmdbId.has(tmdbId)) {
|
||||
byTmdbId.set(tmdbId, item);
|
||||
}
|
||||
|
||||
const titleKey = titleYearKey(item.name, item.year);
|
||||
if (normalizeTitle(item.name) && !byTitleYear.has(titleKey)) {
|
||||
byTitleYear.set(titleKey, item);
|
||||
}
|
||||
|
||||
const normalizedName = normalizeTitle(item.name);
|
||||
if (normalizedName && !byTitle.has(normalizedName)) {
|
||||
byTitle.set(normalizedName, item);
|
||||
}
|
||||
}
|
||||
|
||||
return { byId, byTmdbId, byTitleYear, byTitle };
|
||||
}
|
||||
|
||||
async function fetchSeedItems(userId, ids, libraryIndex) {
|
||||
const fromLibrary = ids
|
||||
.map((id) => libraryIndex.byId.get(String(id)))
|
||||
.filter(Boolean);
|
||||
|
||||
if (fromLibrary.length === ids.length) {
|
||||
return fromLibrary;
|
||||
}
|
||||
|
||||
const payload = await fetchEmbyJson(`/Users/${encodeURIComponent(userId)}/Items`, {
|
||||
params: {
|
||||
Ids: ids.join(','),
|
||||
Limit: ids.length,
|
||||
Fields: 'Overview,Genres,CommunityRating,ProductionYear,ProviderIds',
|
||||
GroupItemsIntoCollections: false
|
||||
}
|
||||
});
|
||||
|
||||
return uniqueById((payload.Items || payload || []).map(normalizeLookupItem));
|
||||
}
|
||||
|
||||
async function findExistingCollection(userId, name) {
|
||||
const trimmedName = String(name || '').trim();
|
||||
if (!userId || !trimmedName) return null;
|
||||
|
||||
const payload = await fetchEmbyJson(`/Users/${encodeURIComponent(userId)}/Items`, {
|
||||
params: {
|
||||
Recursive: true,
|
||||
SearchTerm: trimmedName,
|
||||
Limit: 20,
|
||||
SortBy: 'SortName',
|
||||
SortOrder: 'Ascending',
|
||||
Fields: 'Overview',
|
||||
IncludeItemTypes: 'BoxSet',
|
||||
GroupItemsIntoCollections: false
|
||||
}
|
||||
});
|
||||
|
||||
const normalizedTarget = normalizeCollectionName(trimmedName);
|
||||
const exact = (payload.Items || payload || [])
|
||||
.map(normalizeLookupItem)
|
||||
.find((item) => item.type === 'BoxSet' && normalizeCollectionName(item.name) === normalizedTarget);
|
||||
|
||||
return exact || null;
|
||||
}
|
||||
|
||||
async function resolveTmdbMatch(item) {
|
||||
const directTmdbId = getSeedTmdbId(item);
|
||||
if (directTmdbId) {
|
||||
return {
|
||||
tmdbId: Number(directTmdbId),
|
||||
mediaType: getTmdbMediaType(item)
|
||||
};
|
||||
}
|
||||
|
||||
const matches = await searchTmdbByTitle({
|
||||
mediaType: getTmdbMediaType(item),
|
||||
name: item.name,
|
||||
year: item.year
|
||||
});
|
||||
const best = matches[0];
|
||||
if (!best?.tmdbId) return null;
|
||||
|
||||
return {
|
||||
tmdbId: best.tmdbId,
|
||||
mediaType: best.mediaType
|
||||
};
|
||||
}
|
||||
|
||||
function resolveLocalCandidate(index, candidate) {
|
||||
const tmdbId = String(candidate?.tmdbId || '').trim();
|
||||
if (tmdbId && index.byTmdbId.has(tmdbId)) {
|
||||
return index.byTmdbId.get(tmdbId);
|
||||
}
|
||||
|
||||
const exactTitleYear = index.byTitleYear.get(titleYearKey(candidate?.name, candidate?.year));
|
||||
if (exactTitleYear) {
|
||||
return exactTitleYear;
|
||||
}
|
||||
|
||||
return index.byTitle.get(normalizeTitle(candidate?.name)) || null;
|
||||
}
|
||||
|
||||
function addWeight(map, key, amount = 1) {
|
||||
if (key === null || key === undefined || key === '') return;
|
||||
map.set(key, (map.get(key) || 0) + amount);
|
||||
}
|
||||
|
||||
function sortWeightedEntries(map, limit) {
|
||||
return [...map.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
function getGenreLookup(genres) {
|
||||
return new Map((genres || []).map((genre) => [normalizeTitle(genre.name), Number(genre.id)]));
|
||||
}
|
||||
|
||||
function resolveGenreIdsFromLocalItems(items, genreLookup, weight, targetMap) {
|
||||
for (const item of items || []) {
|
||||
for (const genreName of item.genres || []) {
|
||||
const id = genreLookup.get(normalizeTitle(genreName));
|
||||
if (id) addWeight(targetMap, id, weight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeKeywordIds(keywords) {
|
||||
return (keywords || []).map((keyword) => Number(keyword?.id || 0)).filter(Boolean);
|
||||
}
|
||||
|
||||
function extractPeopleIds(credits, mediaType) {
|
||||
const ids = new Set();
|
||||
|
||||
for (const person of (credits?.cast || []).slice(0, 5)) {
|
||||
if (person?.id) ids.add(Number(person.id));
|
||||
}
|
||||
|
||||
if (mediaType === 'movie') {
|
||||
for (const crew of credits?.crew || []) {
|
||||
if (!crew?.id) continue;
|
||||
if (crew.job === 'Director' || crew.job === 'Writer' || crew.job === 'Screenplay') {
|
||||
ids.add(Number(crew.id));
|
||||
}
|
||||
if (ids.size >= 8) break;
|
||||
}
|
||||
}
|
||||
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
function buildDiscoverQueries({ mediaType, genreIds, keywordIds, peopleIds }) {
|
||||
const base = {
|
||||
page: 1,
|
||||
sort_by: 'popularity.desc',
|
||||
'vote_count.gte': mediaType === 'tv' ? 10 : 25,
|
||||
with_original_language: 'en'
|
||||
};
|
||||
const queries = [];
|
||||
|
||||
if (genreIds.length) {
|
||||
queries.push({
|
||||
label: `${mediaType}-genres`,
|
||||
params: {
|
||||
...base,
|
||||
with_genres: genreIds.slice(0, 4).join('|')
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (keywordIds.length) {
|
||||
queries.push({
|
||||
label: `${mediaType}-keywords`,
|
||||
params: {
|
||||
...base,
|
||||
with_keywords: keywordIds.slice(0, 6).join('|')
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (genreIds.length && keywordIds.length) {
|
||||
queries.push({
|
||||
label: `${mediaType}-genres-keywords`,
|
||||
params: {
|
||||
...base,
|
||||
with_genres: genreIds.slice(0, 3).join('|'),
|
||||
with_keywords: keywordIds.slice(0, 4).join('|')
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (mediaType === 'movie' && peopleIds.length) {
|
||||
queries.push({
|
||||
label: 'movie-people',
|
||||
params: {
|
||||
...base,
|
||||
with_people: peopleIds.slice(0, 5).join('|')
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return queries;
|
||||
}
|
||||
|
||||
function inspectCandidateSets(seedIds, resultSets) {
|
||||
const excluded = new Set((seedIds || []).map((id) => String(id)));
|
||||
const uniqueIncluded = new Map();
|
||||
const uniqueExcluded = new Map();
|
||||
|
||||
for (const resultSet of resultSets || []) {
|
||||
const items = Array.isArray(resultSet) ? resultSet : (Array.isArray(resultSet?.items) ? resultSet.items : []);
|
||||
for (const item of items) {
|
||||
const normalized = normalizeLookupItem(item);
|
||||
if (!normalized.id) continue;
|
||||
if (excluded.has(normalized.id)) {
|
||||
if (!uniqueExcluded.has(normalized.id)) uniqueExcluded.set(normalized.id, normalized);
|
||||
continue;
|
||||
}
|
||||
if (!uniqueIncluded.has(normalized.id)) uniqueIncluded.set(normalized.id, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uniqueCandidateCount: uniqueIncluded.size,
|
||||
excludedSeedCandidateCount: uniqueExcluded.size,
|
||||
sampleExcludedSeedCandidates: summarizeItems([...uniqueExcluded.values()])
|
||||
};
|
||||
}
|
||||
|
||||
async function buildSeedContext(seed, libraryIndex) {
|
||||
const diagnostic = {
|
||||
seedId: seed.id,
|
||||
seedName: seed.name,
|
||||
seedType: seed.type,
|
||||
seedYear: seed.year,
|
||||
providerTmdbId: getSeedTmdbId(seed) || null,
|
||||
tmdbMatch: null,
|
||||
similarCount: 0,
|
||||
recommendationCount: 0,
|
||||
localSimilarCount: 0,
|
||||
localRecommendationCount: 0,
|
||||
error: null
|
||||
};
|
||||
|
||||
try {
|
||||
const tmdbMatch = await resolveTmdbMatch(seed);
|
||||
if (!tmdbMatch?.tmdbId) {
|
||||
diagnostic.error = 'No TMDB match resolved for seed';
|
||||
return { seed, diagnostic, resultSets: [], details: null, keywords: [], peopleIds: [] };
|
||||
}
|
||||
|
||||
diagnostic.tmdbMatch = tmdbMatch;
|
||||
|
||||
const [details, keywords, credits, similar, recommendations] = await Promise.all([
|
||||
fetchTmdbDetails(tmdbMatch),
|
||||
fetchTmdbKeywords(tmdbMatch),
|
||||
fetchTmdbCredits(tmdbMatch),
|
||||
fetchTmdbSimilar({ ...tmdbMatch, page: 1 }),
|
||||
fetchTmdbRecommendations({ ...tmdbMatch, page: 1 })
|
||||
]);
|
||||
|
||||
const englishSimilar = filterEnglishCandidates(similar);
|
||||
const englishRecommendations = filterEnglishCandidates(recommendations);
|
||||
const localSimilar = uniqueById(englishSimilar.map((candidate) => resolveLocalCandidate(libraryIndex, candidate)).filter(Boolean));
|
||||
const localRecommendations = uniqueById(
|
||||
englishRecommendations.map((candidate) => resolveLocalCandidate(libraryIndex, candidate)).filter(Boolean)
|
||||
);
|
||||
|
||||
diagnostic.similarCount = englishSimilar.length;
|
||||
diagnostic.recommendationCount = englishRecommendations.length;
|
||||
diagnostic.localSimilarCount = localSimilar.length;
|
||||
diagnostic.localRecommendationCount = localRecommendations.length;
|
||||
|
||||
return {
|
||||
seed,
|
||||
details,
|
||||
keywords,
|
||||
peopleIds: extractPeopleIds(credits, tmdbMatch.mediaType),
|
||||
diagnostic,
|
||||
resultSets: [
|
||||
{ items: localSimilar, sourceWeight: 5, label: 'tmdb-similar' },
|
||||
{ items: localRecommendations, sourceWeight: 6, label: 'tmdb-recommendations' }
|
||||
]
|
||||
};
|
||||
} catch (err) {
|
||||
diagnostic.error = err.message;
|
||||
return { seed, diagnostic, resultSets: [], details: null, keywords: [], peopleIds: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function buildPreview(userId, seedIds, limit, profile) {
|
||||
const normalizedSeedIds = [...new Set(seedIds.map((id) => String(id).trim()).filter(Boolean))];
|
||||
const libraryItems = uniqueById(await fetchAllLibraryItems(userId));
|
||||
const playedItems = uniqueById(await fetchAllPlayedItems(userId));
|
||||
const excludedIds = [...new Set([...normalizedSeedIds, ...playedItems.map((item) => item.id).filter(Boolean)])];
|
||||
const libraryIndex = buildLibraryIndex(libraryItems);
|
||||
const seeds = await fetchSeedItems(userId, normalizedSeedIds, libraryIndex);
|
||||
const allowedTypes = [...new Set(seeds.map((item) => item.type).filter(Boolean))];
|
||||
const includeMovies = allowedTypes.includes('Movie');
|
||||
const includeSeries = allowedTypes.includes('Series');
|
||||
|
||||
if (!seeds.length) {
|
||||
return {
|
||||
seeds: [],
|
||||
recommendations: [],
|
||||
diagnostics: {
|
||||
libraryItemCount: libraryItems.length,
|
||||
watchedItemCount: playedItems.length,
|
||||
seedCount: 0,
|
||||
embyCandidates: 0,
|
||||
tmdbEnabled: true,
|
||||
tmdbCandidates: 0,
|
||||
tmdbResolved: 0,
|
||||
uniqueCandidateCount: 0,
|
||||
excludedSeedCandidateCount: 0,
|
||||
sampleExcludedSeedCandidates: [],
|
||||
perSeed: [],
|
||||
discoverQueries: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const seedContexts = await Promise.all(seeds.map((seed) => buildSeedContext(seed, libraryIndex)));
|
||||
const movieGenres = await fetchTmdbGenres('movie');
|
||||
const tvGenres = await fetchTmdbGenres('tv');
|
||||
const movieGenreLookup = getGenreLookup(movieGenres);
|
||||
const tvGenreLookup = getGenreLookup(tvGenres);
|
||||
|
||||
const movieGenreWeights = new Map();
|
||||
const tvGenreWeights = new Map();
|
||||
const movieKeywordWeights = new Map();
|
||||
const tvKeywordWeights = new Map();
|
||||
const moviePeopleWeights = new Map();
|
||||
|
||||
for (const context of seedContexts) {
|
||||
const mediaType = getTmdbMediaType(context.seed);
|
||||
const genreTarget = mediaType === 'tv' ? tvGenreWeights : movieGenreWeights;
|
||||
const keywordTarget = mediaType === 'tv' ? tvKeywordWeights : movieKeywordWeights;
|
||||
|
||||
for (const genre of context.details?.genres || []) {
|
||||
addWeight(genreTarget, Number(genre.id), 5);
|
||||
}
|
||||
|
||||
for (const keywordId of normalizeKeywordIds(context.keywords)) {
|
||||
addWeight(keywordTarget, keywordId, 4);
|
||||
}
|
||||
|
||||
if (mediaType === 'movie') {
|
||||
for (const personId of context.peopleIds || []) {
|
||||
addWeight(moviePeopleWeights, personId, 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolveGenreIdsFromLocalItems(seeds.filter((item) => getTmdbMediaType(item) === 'movie'), movieGenreLookup, 3, movieGenreWeights);
|
||||
resolveGenreIdsFromLocalItems(seeds.filter((item) => getTmdbMediaType(item) === 'tv'), tvGenreLookup, 3, tvGenreWeights);
|
||||
resolveGenreIdsFromLocalItems(playedItems.filter((item) => getTmdbMediaType(item) === 'movie'), movieGenreLookup, 1, movieGenreWeights);
|
||||
resolveGenreIdsFromLocalItems(playedItems.filter((item) => getTmdbMediaType(item) === 'tv'), tvGenreLookup, 1, tvGenreWeights);
|
||||
|
||||
const discoverQueries = [
|
||||
...(includeMovies
|
||||
? buildDiscoverQueries({
|
||||
mediaType: 'movie',
|
||||
genreIds: sortWeightedEntries(movieGenreWeights, 6).map(([id]) => id),
|
||||
keywordIds: sortWeightedEntries(movieKeywordWeights, 8).map(([id]) => id),
|
||||
peopleIds: sortWeightedEntries(moviePeopleWeights, 6).map(([id]) => id)
|
||||
})
|
||||
: []),
|
||||
...(includeSeries
|
||||
? buildDiscoverQueries({
|
||||
mediaType: 'tv',
|
||||
genreIds: sortWeightedEntries(tvGenreWeights, 6).map(([id]) => id),
|
||||
keywordIds: sortWeightedEntries(tvKeywordWeights, 8).map(([id]) => id),
|
||||
peopleIds: []
|
||||
})
|
||||
: [])
|
||||
];
|
||||
|
||||
const queryResults = await Promise.all(
|
||||
discoverQueries.map(async (query) => {
|
||||
try {
|
||||
const tmdbResults = filterEnglishCandidates(await fetchTmdbDiscover({
|
||||
mediaType: query.label.startsWith('tv') ? 'tv' : 'movie',
|
||||
params: query.params
|
||||
}));
|
||||
const localMatches = uniqueById(
|
||||
tmdbResults.map((candidate) => resolveLocalCandidate(libraryIndex, candidate)).filter(Boolean)
|
||||
);
|
||||
|
||||
return {
|
||||
label: query.label,
|
||||
params: query.params,
|
||||
tmdbCount: tmdbResults.length,
|
||||
localCount: localMatches.length,
|
||||
localMatches,
|
||||
sourceWeight: 1
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
label: query.label,
|
||||
params: query.params,
|
||||
tmdbCount: 0,
|
||||
localCount: 0,
|
||||
localMatches: [],
|
||||
error: err.message
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
const resultSets = [
|
||||
...seedContexts.flatMap((context) => context.resultSets),
|
||||
...queryResults.map((query) => ({
|
||||
items: query.localMatches,
|
||||
sourceWeight: query.sourceWeight || 1,
|
||||
label: query.label
|
||||
}))
|
||||
].filter((resultSet) => (Array.isArray(resultSet) ? resultSet.length : resultSet.items.length) > 0);
|
||||
|
||||
const candidateInspection = inspectCandidateSets(excludedIds, resultSets);
|
||||
const recommendations = rankRecommendationResults(normalizedSeedIds, resultSets, {
|
||||
limit,
|
||||
profile,
|
||||
seeds,
|
||||
excludeIds: excludedIds,
|
||||
allowedTypes
|
||||
});
|
||||
|
||||
return {
|
||||
seeds,
|
||||
recommendations,
|
||||
diagnostics: {
|
||||
libraryItemCount: libraryItems.length,
|
||||
watchedItemCount: playedItems.length,
|
||||
seedCount: seeds.length,
|
||||
embyCandidates: seedContexts.reduce(
|
||||
(total, context) => total + context.diagnostic.localSimilarCount + context.diagnostic.localRecommendationCount,
|
||||
0
|
||||
),
|
||||
tmdbEnabled: true,
|
||||
tmdbCandidates:
|
||||
seedContexts.reduce(
|
||||
(total, context) => total + context.diagnostic.similarCount + context.diagnostic.recommendationCount,
|
||||
0
|
||||
) + queryResults.reduce((total, query) => total + query.tmdbCount, 0),
|
||||
tmdbResolved:
|
||||
seedContexts.reduce(
|
||||
(total, context) => total + context.diagnostic.localSimilarCount + context.diagnostic.localRecommendationCount,
|
||||
0
|
||||
) + queryResults.reduce((total, query) => total + query.localCount, 0),
|
||||
uniqueCandidateCount: candidateInspection.uniqueCandidateCount,
|
||||
excludedSeedCandidateCount: candidateInspection.excludedSeedCandidateCount,
|
||||
sampleExcludedSeedCandidates: candidateInspection.sampleExcludedSeedCandidates,
|
||||
perSeed: seedContexts.map((context) => context.diagnostic),
|
||||
discoverQueries: queryResults.map((query) => ({
|
||||
label: query.label,
|
||||
tmdbCount: query.tmdbCount,
|
||||
localCount: query.localCount,
|
||||
error: query.error || null,
|
||||
params: query.params
|
||||
}))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function createOrUpdateCollection(userId, name, itemIds) {
|
||||
const existingCollection = await findExistingCollection(userId, name);
|
||||
|
||||
if (existingCollection?.id) {
|
||||
await fetchEmby(`/Collections/${encodeURIComponent(existingCollection.id)}/Items`, {
|
||||
method: 'POST',
|
||||
params: {
|
||||
Ids: itemIds.join(',')
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: existingCollection.id,
|
||||
name: existingCollection.name || name,
|
||||
updated: true
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetchEmby('/Collections', {
|
||||
method: 'POST',
|
||||
params: {
|
||||
Name: name,
|
||||
Ids: itemIds.join(',')
|
||||
}
|
||||
});
|
||||
const created = await response.json();
|
||||
|
||||
return {
|
||||
id: String(created?.Id || ''),
|
||||
name: created?.Name || name,
|
||||
updated: false
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST({ request }) {
|
||||
const body = await request.json();
|
||||
const mode = body?.mode === 'create' ? 'create' : 'preview';
|
||||
const userId = normalizeEmbyGuid(body?.userId);
|
||||
const seedIds = Array.isArray(body?.seedIds) ? body.seedIds : [];
|
||||
const limit = Math.min(Math.max(Number(body?.limit || 18), 1), 48);
|
||||
const includeSeeds = body?.includeSeeds !== false;
|
||||
const name = String(body?.name || '').trim();
|
||||
const profile = RECOMMENDATION_PROFILES[body?.profile] ? body.profile : 'balanced';
|
||||
|
||||
if (!userId) {
|
||||
throw error(400, 'Missing userId');
|
||||
}
|
||||
|
||||
if (seedIds.length === 0) {
|
||||
throw error(400, 'Select at least one seed item');
|
||||
}
|
||||
|
||||
try {
|
||||
const preview = await buildPreview(userId, seedIds, limit, profile);
|
||||
const existingCollection = name ? await findExistingCollection(userId, name) : null;
|
||||
|
||||
if (mode !== 'create') {
|
||||
return json({
|
||||
...preview,
|
||||
profile,
|
||||
collection: existingCollection
|
||||
? {
|
||||
id: existingCollection.id,
|
||||
name: existingCollection.name,
|
||||
updated: true
|
||||
}
|
||||
: null
|
||||
});
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
throw error(400, 'Collection name is required');
|
||||
}
|
||||
|
||||
const collectionIds = [
|
||||
...(includeSeeds ? preview.seeds.map((item) => item.id) : []),
|
||||
...preview.recommendations.map((item) => item.id)
|
||||
];
|
||||
const uniqueIds = [...new Set(collectionIds.filter(Boolean))];
|
||||
|
||||
if (uniqueIds.length === 0) {
|
||||
throw error(400, 'No items were available to add to the collection');
|
||||
}
|
||||
|
||||
const collection = await createOrUpdateCollection(userId, name, uniqueIds);
|
||||
|
||||
return json({
|
||||
seeds: preview.seeds,
|
||||
recommendations: preview.recommendations,
|
||||
profile,
|
||||
collection
|
||||
});
|
||||
} catch (err) {
|
||||
if (err?.status) throw err;
|
||||
if (String(err.message || '').includes('not configured')) {
|
||||
throw error(400, err.message);
|
||||
}
|
||||
throw error(502, `Could not build Emby collection: ${err.message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { normalizeLookupItem } from '../../../lib/collection-tools.js';
|
||||
import {
|
||||
buildSingleGenreUpdate,
|
||||
normalizeGenreNames,
|
||||
pickSuggestedGenre
|
||||
} from '../../../lib/genre-cleanup.js';
|
||||
import { fetchEmby, fetchEmbyJson, normalizeEmbyGuid } from '../../../lib/server/emby-api.js';
|
||||
import { fetchTmdbDetails, searchTmdbByTitle } from '../../../lib/server/tmdb-api.js';
|
||||
|
||||
function getItemTmdbId(item) {
|
||||
const providerIds = item?.ProviderIds || item?.providerIds || {};
|
||||
return String(providerIds.Tmdb || providerIds.TMDB || providerIds.tmdb || '').trim();
|
||||
}
|
||||
|
||||
function getTmdbMediaType(item) {
|
||||
return item?.Type === 'Series' || item?.type === 'Series' ? 'tv' : 'movie';
|
||||
}
|
||||
|
||||
function normalizeTitle(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '');
|
||||
}
|
||||
|
||||
async function fetchFullItem(userId, itemId) {
|
||||
const candidates = [
|
||||
() => fetchEmbyJson(`/Users/${encodeURIComponent(userId)}/Items/${encodeURIComponent(itemId)}`),
|
||||
() =>
|
||||
fetchEmbyJson(`/Items/${encodeURIComponent(itemId)}`, {
|
||||
params: { UserId: userId }
|
||||
})
|
||||
];
|
||||
|
||||
let lastError = null;
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
return await candidate();
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Could not fetch item details from Emby');
|
||||
}
|
||||
|
||||
async function resolveTmdbMatch(item) {
|
||||
const directTmdbId = getItemTmdbId(item);
|
||||
if (directTmdbId) {
|
||||
return {
|
||||
tmdbId: Number(directTmdbId),
|
||||
mediaType: getTmdbMediaType(item),
|
||||
source: 'providerId'
|
||||
};
|
||||
}
|
||||
|
||||
const matches = await searchTmdbByTitle({
|
||||
mediaType: getTmdbMediaType(item),
|
||||
name: item?.Name || item?.name,
|
||||
year: item?.ProductionYear || item?.year
|
||||
});
|
||||
const exactTitle = normalizeTitle(item?.Name || item?.name);
|
||||
const bestMatch =
|
||||
matches.find(
|
||||
(candidate) =>
|
||||
normalizeTitle(candidate?.name) === exactTitle &&
|
||||
(!item?.ProductionYear || !candidate?.year || candidate.year === item.ProductionYear)
|
||||
) || matches[0];
|
||||
|
||||
if (!bestMatch?.tmdbId) return null;
|
||||
|
||||
return {
|
||||
tmdbId: Number(bestMatch.tmdbId),
|
||||
mediaType: bestMatch.mediaType,
|
||||
source: 'search'
|
||||
};
|
||||
}
|
||||
|
||||
async function inspectItemGenres(userId, itemId) {
|
||||
const fullItem = await fetchFullItem(userId, itemId);
|
||||
const normalizedItem = normalizeLookupItem(fullItem);
|
||||
const currentGenres = normalizeGenreNames([
|
||||
...(fullItem?.Genres || []),
|
||||
...((fullItem?.GenreItems || []).map((entry) => entry?.Name))
|
||||
]);
|
||||
|
||||
const tmdbMatch = await resolveTmdbMatch(fullItem);
|
||||
if (!tmdbMatch?.tmdbId) {
|
||||
return {
|
||||
item: normalizedItem,
|
||||
currentGenres,
|
||||
tmdb: null,
|
||||
suggestedGenre: ''
|
||||
};
|
||||
}
|
||||
|
||||
const details = await fetchTmdbDetails(tmdbMatch);
|
||||
const tmdbGenres = normalizeGenreNames((details?.genres || []).map((genre) => genre?.name));
|
||||
|
||||
return {
|
||||
item: normalizedItem,
|
||||
currentGenres,
|
||||
tmdb: {
|
||||
tmdbId: tmdbMatch.tmdbId,
|
||||
mediaType: tmdbMatch.mediaType,
|
||||
source: tmdbMatch.source,
|
||||
genres: tmdbGenres
|
||||
},
|
||||
suggestedGenre: pickSuggestedGenre(tmdbGenres, currentGenres)
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET({ url }) {
|
||||
const userId = normalizeEmbyGuid(url.searchParams.get('userId'));
|
||||
const itemId = String(url.searchParams.get('itemId') || '').trim();
|
||||
|
||||
if (!userId) {
|
||||
throw error(400, 'Missing userId');
|
||||
}
|
||||
|
||||
if (!itemId) {
|
||||
throw error(400, 'Missing itemId');
|
||||
}
|
||||
|
||||
try {
|
||||
return json(await inspectItemGenres(userId, itemId));
|
||||
} catch (err) {
|
||||
if (String(err.message || '').includes('not configured')) {
|
||||
throw error(400, err.message);
|
||||
}
|
||||
throw error(502, `Could not inspect item genres: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST({ request }) {
|
||||
const body = await request.json();
|
||||
const userId = normalizeEmbyGuid(body?.userId);
|
||||
const itemId = String(body?.itemId || '').trim();
|
||||
const genreName = String(body?.genreName || '').trim();
|
||||
|
||||
if (!userId) {
|
||||
throw error(400, 'Missing userId');
|
||||
}
|
||||
|
||||
if (!itemId) {
|
||||
throw error(400, 'Missing itemId');
|
||||
}
|
||||
|
||||
if (!genreName) {
|
||||
throw error(400, 'Missing genreName');
|
||||
}
|
||||
|
||||
try {
|
||||
const fullItem = await fetchFullItem(userId, itemId);
|
||||
const updatedItem = buildSingleGenreUpdate(fullItem, genreName);
|
||||
|
||||
await fetchEmby(`/Items/${encodeURIComponent(itemId)}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(updatedItem)
|
||||
});
|
||||
|
||||
return json(await inspectItemGenres(userId, itemId));
|
||||
} catch (err) {
|
||||
if (err?.status) throw err;
|
||||
if (String(err.message || '').includes('not configured')) {
|
||||
throw error(400, err.message);
|
||||
}
|
||||
throw error(502, `Could not update item genres: ${err.message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { error, json } from '@sveltejs/kit';
|
||||
import { fetchEmbyJson, normalizeEmbyGuid } from '../../../lib/server/emby-api.js';
|
||||
import { normalizeLookupItem } from '../../../lib/collection-tools.js';
|
||||
|
||||
export async function GET({ url }) {
|
||||
const userId = normalizeEmbyGuid(url.searchParams.get('userId'));
|
||||
const term = String(url.searchParams.get('term') || '').trim();
|
||||
const limit = Math.min(Math.max(Number(url.searchParams.get('limit') || 12), 1), 50);
|
||||
const types = (url.searchParams.get('types') || '')
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!userId) {
|
||||
throw error(400, 'Missing userId');
|
||||
}
|
||||
|
||||
if (!term) {
|
||||
return json({ items: [] });
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await fetchEmbyJson(`/Users/${encodeURIComponent(userId)}/Items`, {
|
||||
params: {
|
||||
Recursive: true,
|
||||
SearchTerm: term,
|
||||
Limit: limit,
|
||||
SortBy: 'SortName',
|
||||
SortOrder: 'Ascending',
|
||||
Fields: 'Overview,Genres,ProviderIds,ProductionYear',
|
||||
IncludeItemTypes: types.join(','),
|
||||
GroupItemsIntoCollections: false
|
||||
}
|
||||
});
|
||||
|
||||
return json({
|
||||
items: (payload.Items || payload || []).map(normalizeLookupItem)
|
||||
});
|
||||
} catch (err) {
|
||||
if (String(err.message || '').includes('not configured')) {
|
||||
throw error(400, err.message);
|
||||
}
|
||||
throw error(502, `Could not search Emby items: ${err.message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import {
|
||||
readCachedEmbyUserContext,
|
||||
writeCachedEmbyUserContext
|
||||
} from '../../../lib/server/emby-user-context-cache.js';
|
||||
|
||||
const CONFIG_PATH = resolve('config.json');
|
||||
const EMBY_PAGE_SIZE = 200;
|
||||
|
||||
function loadConfig() {
|
||||
if (!existsSync(CONFIG_PATH)) return {};
|
||||
try {
|
||||
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGuid(value) {
|
||||
return typeof value === 'string' ? value.replace(/-/g, '').trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function fetchAllUserItems(base, embyGuid, apiKey, params = {}) {
|
||||
const allItems = [];
|
||||
let startIndex = 0;
|
||||
|
||||
while (true) {
|
||||
const query = new URLSearchParams({
|
||||
api_key: apiKey,
|
||||
Recursive: 'true',
|
||||
GroupItemsIntoCollections: 'false',
|
||||
Limit: String(EMBY_PAGE_SIZE),
|
||||
StartIndex: String(startIndex),
|
||||
...Object.fromEntries(
|
||||
Object.entries(params).map(([key, value]) => [key, String(value)])
|
||||
)
|
||||
});
|
||||
const payload = await fetchJson(`${base}/Users/${encodeURIComponent(embyGuid)}/Items?${query.toString()}`);
|
||||
const pageItems = payload.Items || payload || [];
|
||||
allItems.push(...pageItems);
|
||||
|
||||
const total = Number(payload.TotalRecordCount || 0);
|
||||
if (!pageItems.length) break;
|
||||
if (total > 0 && allItems.length >= total) break;
|
||||
if (pageItems.length < EMBY_PAGE_SIZE) break;
|
||||
|
||||
startIndex += pageItems.length;
|
||||
}
|
||||
|
||||
return allItems;
|
||||
}
|
||||
|
||||
function normalizeViews(items = []) {
|
||||
return items.map((item) => ({
|
||||
id: String(item.Id || ''),
|
||||
name: item.Name || 'Unnamed view',
|
||||
type: item.CollectionType || item.Type || 'View'
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeRecentlyPlayed(items = []) {
|
||||
return items.map((item) => ({
|
||||
id: String(item.Id || ''),
|
||||
name: item.Name || item.SeriesName || 'Unknown item',
|
||||
type: item.Type || 'Item',
|
||||
seriesName: item.SeriesName || null,
|
||||
datePlayed: item.UserData?.LastPlayedDate || item.DateLastMediaAdded || null,
|
||||
isPlayed: item.UserData?.Played ?? true
|
||||
}));
|
||||
}
|
||||
|
||||
function buildExcludedFolderLookup(items = []) {
|
||||
return Object.fromEntries(
|
||||
items
|
||||
.filter((item) => item?.Id)
|
||||
.map((item) => [
|
||||
String(item.Id),
|
||||
{
|
||||
name: item.Name || item.Path || `Item ${item.Id}`,
|
||||
type: item.CollectionType || item.Type || 'Item'
|
||||
}
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
export async function GET({ url }) {
|
||||
const embyGuid = normalizeGuid(url.searchParams.get('embyGuid'));
|
||||
const excludedIds = (url.searchParams.get('excludedIds') || '')
|
||||
.split(',')
|
||||
.map((id) => id.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!embyGuid) {
|
||||
throw error(400, 'Missing embyGuid');
|
||||
}
|
||||
|
||||
const { embyUrl, apiKey } = loadConfig();
|
||||
const cached = readCachedEmbyUserContext(embyGuid);
|
||||
|
||||
if (!embyUrl || !apiKey) {
|
||||
if (cached) {
|
||||
return json({ ...cached, source: 'cache' });
|
||||
}
|
||||
throw error(400, 'Emby URL and API key not configured');
|
||||
}
|
||||
|
||||
const base = embyUrl.replace(/\/+$/, '');
|
||||
|
||||
try {
|
||||
const [viewsPayload, recentlyPlayedPayload, excludedPayload] = await Promise.all([
|
||||
fetchJson(`${base}/Users/${encodeURIComponent(embyGuid)}/Views?api_key=${encodeURIComponent(apiKey)}`),
|
||||
fetchAllUserItems(base, embyGuid, apiKey, {
|
||||
Filters: 'IsPlayed',
|
||||
IncludeItemTypes: 'Movie,Series',
|
||||
SortBy: 'DatePlayed',
|
||||
SortOrder: 'Descending',
|
||||
Fields: 'UserData'
|
||||
}),
|
||||
excludedIds.length > 0
|
||||
? fetchJson(
|
||||
`${base}/Users/${encodeURIComponent(embyGuid)}/Items?api_key=${encodeURIComponent(apiKey)}&Ids=${encodeURIComponent(excludedIds.join(','))}&Fields=Path`
|
||||
)
|
||||
: Promise.resolve({ Items: [] })
|
||||
]);
|
||||
|
||||
const context = writeCachedEmbyUserContext(embyGuid, {
|
||||
views: normalizeViews(viewsPayload.Items || viewsPayload || []),
|
||||
recentlyPlayed: normalizeRecentlyPlayed(recentlyPlayedPayload),
|
||||
excludedFolderLookup: buildExcludedFolderLookup(excludedPayload.Items || excludedPayload || []),
|
||||
lastSyncedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
return json({ ...context, source: 'live' });
|
||||
} catch (err) {
|
||||
if (cached) {
|
||||
const filteredLookup = excludedIds.length
|
||||
? Object.fromEntries(
|
||||
Object.entries(cached.excludedFolderLookup || {}).filter(([id]) => excludedIds.includes(id))
|
||||
)
|
||||
: (cached.excludedFolderLookup || {});
|
||||
return json({
|
||||
...cached,
|
||||
excludedFolderLookup: filteredLookup,
|
||||
source: 'cache',
|
||||
message: `Using cached Emby user context because live fetch failed: ${err.message}`
|
||||
});
|
||||
}
|
||||
throw error(502, `Could not load Emby user context: ${err.message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { readCachedEmbyUsers, writeCachedEmbyUsers } from '../../../lib/server/emby-user-cache.js';
|
||||
|
||||
const CONFIG_PATH = resolve('config.json');
|
||||
|
||||
function loadConfig() {
|
||||
if (!existsSync(CONFIG_PATH)) return {};
|
||||
try {
|
||||
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const { embyUrl, apiKey } = loadConfig();
|
||||
if (!embyUrl || !apiKey) {
|
||||
throw error(400, 'Emby URL and API key not configured');
|
||||
}
|
||||
|
||||
const base = embyUrl.replace(/\/+$/, '');
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`${base}/Users?api_key=${encodeURIComponent(apiKey)}`);
|
||||
} catch (e) {
|
||||
const cached = readCachedEmbyUsers();
|
||||
if (cached.users.length > 0) {
|
||||
return json({
|
||||
users: cached.users,
|
||||
source: 'cache',
|
||||
lastSyncedAt: cached.lastSyncedAt,
|
||||
message: `Using cached Emby users because the server could not be reached: ${e.message}`
|
||||
});
|
||||
}
|
||||
throw error(502, `Could not reach Emby server: ${e.message}`);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status >= 500) {
|
||||
const cached = readCachedEmbyUsers();
|
||||
if (cached.users.length > 0) {
|
||||
return json({
|
||||
users: cached.users,
|
||||
source: 'cache',
|
||||
lastSyncedAt: cached.lastSyncedAt,
|
||||
message: `Using cached Emby users because the Emby API returned ${res.status} ${res.statusText}`
|
||||
});
|
||||
}
|
||||
}
|
||||
throw error(res.status, `Emby API error: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const users = await res.json();
|
||||
const cached = writeCachedEmbyUsers(
|
||||
users.map((u) => ({
|
||||
embyGuid: u.Id,
|
||||
name: u.Name
|
||||
}))
|
||||
);
|
||||
|
||||
return json({
|
||||
users: cached.users,
|
||||
source: 'live',
|
||||
lastSyncedAt: cached.lastSyncedAt
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user