Homelabtoolkit v2
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user