Files

858 lines
23 KiB
Svelte
Raw Permalink Normal View History

2026-06-08 21:58:16 +12:00
<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>