Genre cleanup tools
This commit is contained in:
@@ -1386,7 +1386,7 @@
|
|||||||
"isPlayed": true
|
"isPlayed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "1330174",
|
"id": "1345802",
|
||||||
"name": "Project Nazi: The Blueprints of Evil",
|
"name": "Project Nazi: The Blueprints of Evil",
|
||||||
"type": "Series",
|
"type": "Series",
|
||||||
"seriesName": null,
|
"seriesName": null,
|
||||||
@@ -1410,15 +1410,7 @@
|
|||||||
"isPlayed": true
|
"isPlayed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "1329610",
|
"id": "1345819",
|
||||||
"name": "Prodigal Son",
|
|
||||||
"type": "Series",
|
|
||||||
"seriesName": null,
|
|
||||||
"datePlayed": null,
|
|
||||||
"isPlayed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "1330186",
|
|
||||||
"name": "We Are Who We Are",
|
"name": "We Are Who We Are",
|
||||||
"type": "Series",
|
"type": "Series",
|
||||||
"seriesName": null,
|
"seriesName": null,
|
||||||
@@ -1426,7 +1418,7 @@
|
|||||||
"isPlayed": true
|
"isPlayed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "1328552",
|
"id": "1345818",
|
||||||
"name": "WandaVision",
|
"name": "WandaVision",
|
||||||
"type": "Series",
|
"type": "Series",
|
||||||
"seriesName": null,
|
"seriesName": null,
|
||||||
@@ -1434,7 +1426,7 @@
|
|||||||
"isPlayed": true
|
"isPlayed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "1328551",
|
"id": "1345812",
|
||||||
"name": "The Falcon and The Winter Soldier",
|
"name": "The Falcon and The Winter Soldier",
|
||||||
"type": "Series",
|
"type": "Series",
|
||||||
"seriesName": null,
|
"seriesName": null,
|
||||||
@@ -1442,7 +1434,7 @@
|
|||||||
"isPlayed": true
|
"isPlayed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "1328548",
|
"id": "1345806",
|
||||||
"name": "Snowpiercer",
|
"name": "Snowpiercer",
|
||||||
"type": "Series",
|
"type": "Series",
|
||||||
"seriesName": null,
|
"seriesName": null,
|
||||||
@@ -1450,15 +1442,15 @@
|
|||||||
"isPlayed": true
|
"isPlayed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "1330183",
|
"id": "1345815",
|
||||||
"name": "The Outsider",
|
"name": "The Outsider (2020)",
|
||||||
"type": "Series",
|
"type": "Series",
|
||||||
"seriesName": null,
|
"seriesName": null,
|
||||||
"datePlayed": null,
|
"datePlayed": null,
|
||||||
"isPlayed": true
|
"isPlayed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "1328549",
|
"id": "1345808",
|
||||||
"name": "Station Eleven",
|
"name": "Station Eleven",
|
||||||
"type": "Series",
|
"type": "Series",
|
||||||
"seriesName": null,
|
"seriesName": null,
|
||||||
@@ -1466,7 +1458,7 @@
|
|||||||
"isPlayed": true
|
"isPlayed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "1330988",
|
"id": "1348189",
|
||||||
"name": "Moon Knight",
|
"name": "Moon Knight",
|
||||||
"type": "Series",
|
"type": "Series",
|
||||||
"seriesName": null,
|
"seriesName": null,
|
||||||
@@ -1474,7 +1466,7 @@
|
|||||||
"isPlayed": true
|
"isPlayed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "1330175",
|
"id": "1345803",
|
||||||
"name": "Rise of the Nazis",
|
"name": "Rise of the Nazis",
|
||||||
"type": "Series",
|
"type": "Series",
|
||||||
"seriesName": null,
|
"seriesName": null,
|
||||||
@@ -1482,23 +1474,15 @@
|
|||||||
"isPlayed": true
|
"isPlayed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "1329606",
|
"id": "1345813",
|
||||||
"name": "Coyote",
|
"name": "The Head (2020)",
|
||||||
"type": "Series",
|
"type": "Series",
|
||||||
"seriesName": null,
|
"seriesName": null,
|
||||||
"datePlayed": null,
|
"datePlayed": null,
|
||||||
"isPlayed": true
|
"isPlayed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "1330182",
|
"id": "1344688",
|
||||||
"name": "The Head",
|
|
||||||
"type": "Series",
|
|
||||||
"seriesName": null,
|
|
||||||
"datePlayed": null,
|
|
||||||
"isPlayed": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "1312988",
|
|
||||||
"name": "Domina",
|
"name": "Domina",
|
||||||
"type": "Series",
|
"type": "Series",
|
||||||
"seriesName": null,
|
"seriesName": null,
|
||||||
@@ -1506,7 +1490,7 @@
|
|||||||
"isPlayed": true
|
"isPlayed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "1330172",
|
"id": "1345800",
|
||||||
"name": "Obi-Wan Kenobi",
|
"name": "Obi-Wan Kenobi",
|
||||||
"type": "Series",
|
"type": "Series",
|
||||||
"seriesName": null,
|
"seriesName": null,
|
||||||
@@ -1514,7 +1498,7 @@
|
|||||||
"isPlayed": true
|
"isPlayed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "1328550",
|
"id": "1345810",
|
||||||
"name": "The Book of Boba Fett",
|
"name": "The Book of Boba Fett",
|
||||||
"type": "Series",
|
"type": "Series",
|
||||||
"seriesName": null,
|
"seriesName": null,
|
||||||
@@ -1522,7 +1506,7 @@
|
|||||||
"isPlayed": true
|
"isPlayed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "1330178",
|
"id": "1345807",
|
||||||
"name": "Stanley Tucci: Searching for Italy",
|
"name": "Stanley Tucci: Searching for Italy",
|
||||||
"type": "Series",
|
"type": "Series",
|
||||||
"seriesName": null,
|
"seriesName": null,
|
||||||
@@ -1546,7 +1530,7 @@
|
|||||||
"isPlayed": true
|
"isPlayed": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "1330989",
|
"id": "1348190",
|
||||||
"name": "The Man Who Fell to Earth",
|
"name": "The Man Who Fell to Earth",
|
||||||
"type": "Series",
|
"type": "Series",
|
||||||
"seriesName": null,
|
"seriesName": null,
|
||||||
@@ -1619,7 +1603,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"excludedFolderLookup": {},
|
"excludedFolderLookup": {},
|
||||||
"lastSyncedAt": "2026-04-26T22:31:15.087Z"
|
"lastSyncedAt": "2026-04-28T01:58:15.752Z"
|
||||||
},
|
},
|
||||||
"ff5a825760c24f9ab6f63b04513909a4": {
|
"ff5a825760c24f9ab6f63b04513909a4": {
|
||||||
"views": [
|
"views": [
|
||||||
|
|||||||
@@ -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,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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import SectionCard from '$lib/SectionCard.svelte';
|
import SectionCard from '$lib/SectionCard.svelte';
|
||||||
import CollectionBuilderPage from '$lib/CollectionBuilderPage.svelte';
|
import CollectionBuilderPage from '$lib/CollectionBuilderPage.svelte';
|
||||||
|
import GenreCleanupPage from '$lib/GenreCleanupPage.svelte';
|
||||||
import Icon from '$lib/Icon.svelte';
|
import Icon from '$lib/Icon.svelte';
|
||||||
import SyncPanel from '$lib/SyncPanel.svelte';
|
import SyncPanel from '$lib/SyncPanel.svelte';
|
||||||
import SqlModal from '$lib/SqlModal.svelte';
|
import SqlModal from '$lib/SqlModal.svelte';
|
||||||
@@ -69,6 +70,11 @@
|
|||||||
title: 'Recommendation Collections',
|
title: 'Recommendation Collections',
|
||||||
description: 'Generate box set sections from recent activity and seeded recommendations.'
|
description: 'Generate box set sections from recent activity and seeded recommendations.'
|
||||||
},
|
},
|
||||||
|
genres: {
|
||||||
|
label: 'Genres',
|
||||||
|
title: 'Genre Cleanup',
|
||||||
|
description: 'Inspect Emby and TMDB genres, then reduce a movie or show to a single genre.'
|
||||||
|
},
|
||||||
settings: {
|
settings: {
|
||||||
label: 'Settings',
|
label: 'Settings',
|
||||||
title: 'Connections and Database',
|
title: 'Connections and Database',
|
||||||
@@ -214,6 +220,7 @@
|
|||||||
const navigationItems = [
|
const navigationItems = [
|
||||||
{ id: 'edit', label: 'Editor', icon: 'edit' },
|
{ id: 'edit', label: 'Editor', icon: 'edit' },
|
||||||
{ id: 'collections', label: 'Collections', icon: 'collections' },
|
{ id: 'collections', label: 'Collections', icon: 'collections' },
|
||||||
|
{ id: 'genres', label: 'Genres', icon: 'search' },
|
||||||
{ id: 'sync', label: 'Sync', icon: 'sync' },
|
{ id: 'sync', label: 'Sync', icon: 'sync' },
|
||||||
{ id: 'settings', label: 'Settings', icon: 'settings' }
|
{ id: 'settings', label: 'Settings', icon: 'settings' }
|
||||||
];
|
];
|
||||||
@@ -473,6 +480,25 @@
|
|||||||
You can also add a box set row from the editor and look up an existing collection directly inside that section.
|
You can also add a box set row from the editor and look up an existing collection directly inside that section.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if activeTab === 'genres'}
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="sidebar-title">Genre Cleanup</div>
|
||||||
|
<div class="sidebar-copy">
|
||||||
|
Search your Emby library, inspect TMDB genres, and write back a single genre per title.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-note">
|
||||||
|
<div class="note-title">Suggestion</div>
|
||||||
|
<div class="note-copy">
|
||||||
|
The tool suggests the first TMDB genre, but keeps the choice editable before anything is written.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-note">
|
||||||
|
<div class="note-title">Workflow</div>
|
||||||
|
<div class="note-copy">
|
||||||
|
Search, inspect, confirm the single genre you want, then apply it to one title or to the current inspected results.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{:else if activeTab === 'settings'}
|
{:else if activeTab === 'settings'}
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
<div class="sidebar-title">Operations</div>
|
<div class="sidebar-title">Operations</div>
|
||||||
@@ -740,6 +766,10 @@
|
|||||||
<div class="view-shell">
|
<div class="view-shell">
|
||||||
<CollectionBuilderPage {users} {selectedUserId} on:created={onCollectionCreated} />
|
<CollectionBuilderPage {users} {selectedUserId} on:created={onCollectionCreated} />
|
||||||
</div>
|
</div>
|
||||||
|
{:else if activeTab === 'genres'}
|
||||||
|
<div class="view-shell">
|
||||||
|
<GenreCleanupPage {users} {selectedUserId} />
|
||||||
|
</div>
|
||||||
{:else if activeTab === 'settings'}
|
{:else if activeTab === 'settings'}
|
||||||
<div class="view-shell settings-grid">
|
<div class="view-shell settings-grid">
|
||||||
<div class="content-card">
|
<div class="content-card">
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ export async function GET({ url }) {
|
|||||||
Limit: limit,
|
Limit: limit,
|
||||||
SortBy: 'SortName',
|
SortBy: 'SortName',
|
||||||
SortOrder: 'Ascending',
|
SortOrder: 'Ascending',
|
||||||
Fields: 'Overview',
|
Fields: 'Overview,Genres,ProviderIds,ProductionYear',
|
||||||
IncludeItemTypes: types.join(','),
|
IncludeItemTypes: types.join(','),
|
||||||
GroupItemsIntoCollections: false
|
GroupItemsIntoCollections: false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import {
|
|||||||
getWatchlistLabelsForTarget
|
getWatchlistLabelsForTarget
|
||||||
} from '../src/lib/constants.js';
|
} from '../src/lib/constants.js';
|
||||||
import { normalizeLookupItem, rankRecommendationResults } from '../src/lib/collection-tools.js';
|
import { normalizeLookupItem, rankRecommendationResults } from '../src/lib/collection-tools.js';
|
||||||
|
import {
|
||||||
|
buildSingleGenreUpdate,
|
||||||
|
normalizeGenreNames,
|
||||||
|
pickSuggestedGenre
|
||||||
|
} from '../src/lib/genre-cleanup.js';
|
||||||
import * as embyUserCache from '../src/lib/server/emby-user-cache.js';
|
import * as embyUserCache from '../src/lib/server/emby-user-cache.js';
|
||||||
|
|
||||||
const tests = [];
|
const tests = [];
|
||||||
@@ -280,6 +285,44 @@ test('normalizeLookupItem preserves already-normalized recommendation items', ()
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('normalizeGenreNames trims and deduplicates genre labels', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
normalizeGenreNames([' Drama ', 'Comedy', 'drama', '', null, 'Comedy ']),
|
||||||
|
['Drama', 'Comedy']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickSuggestedGenre prefers an existing Emby genre if TMDB also includes it', () => {
|
||||||
|
assert.equal(
|
||||||
|
pickSuggestedGenre(['Action', 'Drama', 'Thriller'], ['Drama', 'Crime']),
|
||||||
|
'Drama'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pickSuggestedGenre falls back to the first TMDB genre when there is no overlap', () => {
|
||||||
|
assert.equal(
|
||||||
|
pickSuggestedGenre(['Action', 'Adventure'], ['Comedy']),
|
||||||
|
'Action'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildSingleGenreUpdate keeps only the selected genre in both genre fields', () => {
|
||||||
|
const updated = buildSingleGenreUpdate(
|
||||||
|
{
|
||||||
|
Name: 'Example',
|
||||||
|
Genres: ['Drama', 'Crime'],
|
||||||
|
GenreItems: [
|
||||||
|
{ Name: 'Drama', Id: 62 },
|
||||||
|
{ Name: 'Crime', Id: 4910 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'Crime'
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(updated.Genres, ['Crime']);
|
||||||
|
assert.deepEqual(updated.GenreItems, [{ Name: 'Crime', Id: 4910 }]);
|
||||||
|
});
|
||||||
|
|
||||||
test('recommendation ranking deduplicates and prioritizes overlaps across seeds', () => {
|
test('recommendation ranking deduplicates and prioritizes overlaps across seeds', () => {
|
||||||
const ranked = rankRecommendationResults(
|
const ranked = rankRecommendationResults(
|
||||||
['seed-a', 'seed-b'],
|
['seed-a', 'seed-b'],
|
||||||
|
|||||||
Reference in New Issue
Block a user