Files
embycovers/homescreen_editor/lib/CollectionBuilderPage.svelte
T
2026-06-08 21:58:16 +12:00

858 lines
23 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>