This commit is contained in:
2026-04-18 23:08:52 +12:00
commit e68a8b1622
20 changed files with 4207 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
node_modules/
.svelte-kit/
.vite/
dist/
build/
.DS_Store
Thumbs.db
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
.env
.env.*
!.env.example
config.json
users.db
View File
+1464
View File
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
{
"name": "emby-homescreen-editor",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"svelte": "^4.0.0",
"vite": "^5.0.0"
}
}
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Emby Home Screen Editor</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+649
View File
@@ -0,0 +1,649 @@
<script>
import {
SECTION_TYPES,
COLLECTION_TYPES,
ITEM_TYPES,
SORT_OPTIONS,
IMAGE_TYPES,
GENRES,
getSectionTypeLabel,
getGenreNames
} from '$lib/constants.js';
export let section;
export let index;
export let total;
export let expanded = false;
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
$: genreNames = getGenreNames(section.Query?.GenreIds);
$: typeLabel = getSectionTypeLabel(section.SectionType);
$: showFilters =
section.SectionType === 'items' ||
section.SectionType === 'collections' ||
section.SectionType === 'latestepisodereleases' ||
section.SectionType === 'latestmoviereleases';
// ExcludedFolders as a comma-separated string for editing
$: excludedFoldersStr = (section.ExcludedFolders || []).join(', ');
// TagIds as a comma-separated string for editing
$: tagIdsStr = (section.Query?.TagIds || []).join(', ');
function toggleGenre(id) {
if (!section.Query) section.Query = { StudioIds: [], TagIds: [], GenreIds: [], CollectionTypes: [] };
const idx = section.Query.GenreIds.indexOf(id);
if (idx >= 0) {
section.Query.GenreIds = section.Query.GenreIds.filter((g) => g !== id);
} else {
section.Query.GenreIds = [...section.Query.GenreIds, id];
}
dispatch('change');
}
function toggleItemType(type) {
const idx = section.ItemTypes.indexOf(type);
if (idx >= 0) {
section.ItemTypes = section.ItemTypes.filter((t) => t !== type);
} else {
section.ItemTypes = [...section.ItemTypes, type];
}
dispatch('change');
}
function handleChange() {
dispatch('change');
}
function handleExcludedFoldersChange(e) {
const raw = e.target.value;
section.ExcludedFolders = raw
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '');
dispatch('change');
}
function handleTagIdsChange(e) {
if (!section.Query) section.Query = { StudioIds: [], TagIds: [], GenreIds: [], CollectionTypes: [] };
const raw = e.target.value;
section.Query.TagIds = raw
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '');
dispatch('change');
}
</script>
<div class="section-card" class:expanded>
<!-- Header row - always visible -->
<button class="section-header" on:click={() => dispatch('toggle')}>
<div class="reorder-btns">
<button
class="move-btn"
disabled={index === 0}
on:click|stopPropagation={() => dispatch('move', -1)}
title="Move up"></button
>
<button
class="move-btn"
disabled={index === total - 1}
on:click|stopPropagation={() => dispatch('move', 1)}
title="Move down"></button
>
</div>
<span class="section-index">{index + 1}</span>
<div class="section-info">
<div class="section-name">{section.CustomName || section.Name || '(unnamed)'}</div>
<div class="section-meta">
{typeLabel}
{#if genreNames} · {genreNames}{/if}
{#if section.CollectionType} · {section.CollectionType}{/if}
{#if section.SortBy} · {SORT_OPTIONS.find((s) => s.value === section.SortBy)?.label || section.SortBy}{/if}
{#if section.ExcludedFolders?.length} · {section.ExcludedFolders.length} excluded{/if}
</div>
</div>
<button
class="remove-btn"
on:click|stopPropagation={() => dispatch('remove')}
title="Remove section">×</button
>
<span class="expand-indicator">{expanded ? '▾' : '▸'}</span>
</button>
<!-- Expanded editor -->
{#if expanded}
<div class="section-editor">
<!-- Core fields -->
<div class="field-grid">
<label class="field span2">
<span class="field-label">Display name</span>
<input
type="text"
bind:value={section.CustomName}
on:input={() => {
section.Name = section.CustomName;
handleChange();
}}
placeholder="Section name"
/>
</label>
<label class="field">
<span class="field-label">Section type</span>
<select bind:value={section.SectionType} on:change={handleChange}>
{#each SECTION_TYPES as t}
<option value={t.value}>{t.label}</option>
{/each}
</select>
</label>
<label class="field">
<span class="field-label">Image type</span>
<select bind:value={section.ImageType} on:change={handleChange}>
{#each IMAGE_TYPES as t}
<option value={t.value}>{t.label}</option>
{/each}
</select>
</label>
<label class="field">
<span class="field-label">Sort by</span>
<select bind:value={section.SortBy} on:change={handleChange}>
{#each SORT_OPTIONS as s}
<option value={s.value}>{s.label}</option>
{/each}
</select>
</label>
<label class="field">
<span class="field-label">Sort order</span>
<select bind:value={section.SortOrder} on:change={handleChange}>
<option value={undefined}>(none)</option>
<option value="Ascending">Ascending</option>
<option value="Descending">Descending</option>
</select>
</label>
{#if showFilters}
<label class="field">
<span class="field-label">Collection type</span>
<select bind:value={section.CollectionType} on:change={handleChange}>
{#each COLLECTION_TYPES as c}
<option value={c.value}>{c.label}</option>
{/each}
</select>
</label>
{/if}
{#if section.SectionType === 'userviews'}
<label class="field">
<span class="field-label">View type</span>
<select bind:value={section.ViewType} on:change={handleChange}>
<option value={undefined}>(default)</option>
<option value="buttons">Buttons</option>
<option value="list">List</option>
</select>
</label>
{/if}
<label class="field">
<span class="field-label">Card size offset</span>
<select
value={section.CardSizeOffset ?? 0}
on:change={(e) => { section.CardSizeOffset = Number(e.target.value); handleChange(); }}
>
<option value={-2}>-2 (Smaller)</option>
<option value={-1}>-1 (Small)</option>
<option value={0}>0 (Default)</option>
<option value={1}>+1 (Large)</option>
<option value={2}>+2 (Larger)</option>
</select>
</label>
</div>
<!-- Checkboxes -->
<div class="field-grid checkboxes">
{#if section.SectionType === 'resume'}
<label class="field-inline">
<input
type="checkbox"
checked={section.IncludeNextUpInResume ?? true}
on:change={(e) => { section.IncludeNextUpInResume = e.target.checked; handleChange(); }}
/>
<span>Include Next Up in Resume</span>
</label>
{/if}
{#if showFilters}
<label class="field-inline">
<input
type="checkbox"
checked={section.Query?.IsFavorite || false}
on:change={(e) => {
if (!section.Query) section.Query = { StudioIds: [], TagIds: [], GenreIds: [], CollectionTypes: [] };
section.Query.IsFavorite = e.target.checked || undefined;
handleChange();
}}
/>
<span>Favourites only</span>
</label>
<label class="field-inline">
<input
type="checkbox"
checked={section.Query?.IsPlayed === false}
on:change={(e) => {
if (!section.Query) section.Query = { StudioIds: [], TagIds: [], GenreIds: [], CollectionTypes: [] };
section.Query.IsPlayed = e.target.checked ? false : undefined;
handleChange();
}}
/>
<span>Unplayed only</span>
</label>
{/if}
</div>
{#if showFilters}
<!-- Item types -->
<div class="field-group">
<span class="field-label">Item types</span>
<div class="chip-group">
{#each ITEM_TYPES as type}
<button
class="chip"
class:active={section.ItemTypes?.includes(type)}
on:click={() => toggleItemType(type)}
>
{type}
</button>
{/each}
</div>
</div>
<!-- Genres -->
<div class="field-group">
<span class="field-label">Genres</span>
<div class="chip-group">
{#each Object.entries(GENRES).sort((a, b) => a[1].localeCompare(b[1])) as [id, name]}
<button
class="chip"
class:active={section.Query?.GenreIds?.includes(id)}
on:click={() => toggleGenre(id)}
>
{name}
</button>
{/each}
</div>
</div>
<!-- Tag IDs -->
<div class="field-group">
<span class="field-label">Tag IDs <span class="hint-inline">(comma-separated Emby tag IDs)</span></span>
<input
type="text"
class="ids-input"
value={tagIdsStr}
on:change={handleTagIdsChange}
placeholder="e.g. 1317339, 1336097"
/>
{#if section.Query?.TagIds?.length}
<div class="ids-preview">
{#each section.Query.TagIds as id}
<span class="id-chip">
{id}
<button class="id-remove" on:click={() => {
section.Query.TagIds = section.Query.TagIds.filter(t => t !== id);
dispatch('change');
}}>×</button>
</span>
{/each}
</div>
{/if}
</div>
{/if}
<!-- Boxset parent -->
{#if section.SectionType === 'boxset'}
<div class="field-group">
<span class="field-label">Linked box set</span>
{#if section.ParentItem}
<div class="boxset-info">
<span>{section.ParentItem.Name}</span>
<span class="text-muted">ID: {section.ParentItem.Id}</span>
</div>
{:else}
<div class="field-grid">
<label class="field">
<span class="field-label">Box set name</span>
<input
type="text"
value={section.ParentItem?.Name || ''}
on:input={(e) => {
if (!section.ParentItem) section.ParentItem = { Name: '', Id: '' };
section.ParentItem.Name = e.target.value;
section.Name = e.target.value;
handleChange();
}}
placeholder="Display name"
/>
</label>
<label class="field">
<span class="field-label">Box set ID</span>
<input
type="text"
value={section.ParentItem?.Id || section.ParentId || ''}
on:input={(e) => {
if (!section.ParentItem) section.ParentItem = { Name: '', Id: '' };
section.ParentItem.Id = e.target.value;
section.ParentId = e.target.value;
handleChange();
}}
placeholder="Emby item ID"
/>
</label>
</div>
{/if}
</div>
{/if}
<!-- Excluded folders -->
<div class="field-group">
<span class="field-label">
Excluded folders
{#if section.ExcludedFolders?.length}
<span class="badge">{section.ExcludedFolders.length}</span>
{/if}
<span class="hint-inline">(comma-separated folder IDs)</span>
</span>
<input
type="text"
class="ids-input"
value={excludedFoldersStr}
on:change={handleExcludedFoldersChange}
placeholder="e.g. 4309, 39975, 462878"
/>
{#if section.ExcludedFolders?.length}
<div class="ids-preview">
{#each section.ExcludedFolders as id}
<span class="id-chip">
{id}
<button class="id-remove" on:click={() => {
section.ExcludedFolders = section.ExcludedFolders.filter(f => f !== id);
dispatch('change');
}}>×</button>
</span>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</div>
<style>
.section-card {
background: var(--surface);
border-radius: 10px;
border: 1px solid var(--border);
margin-bottom: 6px;
overflow: hidden;
transition: border-color 0.15s;
}
.section-card.expanded {
border-color: var(--accent);
}
.section-header {
all: unset;
display: flex;
align-items: center;
padding: 10px 14px;
gap: 10px;
cursor: pointer;
width: 100%;
box-sizing: border-box;
}
.section-header:hover {
background: var(--surface-hover);
}
.reorder-btns {
display: flex;
flex-direction: column;
gap: 1px;
}
.move-btn {
all: unset;
cursor: pointer;
font-size: 10px;
line-height: 1;
opacity: 0.5;
padding: 1px 2px;
color: var(--text);
}
.move-btn:disabled {
opacity: 0.15;
cursor: default;
}
.move-btn:not(:disabled):hover {
opacity: 1;
}
.section-index {
font-size: 11px;
color: var(--text-muted);
min-width: 18px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.section-info {
flex: 1;
min-width: 0;
}
.section-name {
font-weight: 600;
font-size: 14px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.section-meta {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.remove-btn {
all: unset;
cursor: pointer;
color: var(--danger);
font-size: 20px;
line-height: 1;
padding: 0 4px;
opacity: 0.5;
}
.remove-btn:hover {
opacity: 1;
}
.expand-indicator {
font-size: 12px;
color: var(--text-muted);
min-width: 14px;
}
/* Editor panel */
.section-editor {
padding: 4px 16px 16px;
border-top: 1px solid var(--border);
}
.field-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 12px;
}
.field-grid.checkboxes {
grid-template-columns: auto auto auto;
justify-content: start;
gap: 16px;
margin-bottom: 12px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field.span2 {
grid-column: span 2;
}
.field-label {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.hint-inline {
font-size: 10px;
font-weight: 400;
text-transform: none;
letter-spacing: 0;
opacity: 0.7;
}
.field input[type='text'],
.field select {
padding: 7px 10px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
font-size: 13px;
font-family: inherit;
}
.field input:focus,
.field select:focus {
outline: none;
border-color: var(--accent);
}
.field-group {
margin-bottom: 12px;
}
.chip-group {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 6px;
}
.chip {
all: unset;
cursor: pointer;
padding: 4px 10px;
border-radius: 14px;
font-size: 12px;
border: 1px solid var(--border);
color: var(--text-muted);
transition: all 0.12s;
}
.chip:hover {
border-color: var(--accent);
color: var(--text);
}
.chip.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.field-inline {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text);
cursor: pointer;
}
/* IDs input (excluded folders, tag IDs) */
.ids-input {
width: 100%;
margin-top: 6px;
padding: 7px 10px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
font-size: 12px;
font-family: monospace;
box-sizing: border-box;
}
.ids-input:focus {
outline: none;
border-color: var(--accent);
}
.ids-preview {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.id-chip {
display: inline-flex;
align-items: center;
gap: 3px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 2px 6px 2px 8px;
font-size: 11px;
font-family: monospace;
color: var(--text-muted);
}
.id-remove {
all: unset;
cursor: pointer;
color: var(--danger);
font-size: 14px;
line-height: 1;
opacity: 0.6;
}
.id-remove:hover {
opacity: 1;
}
.badge {
display: inline-block;
background: var(--accent);
color: #fff;
border-radius: 8px;
padding: 0 5px;
font-size: 10px;
font-weight: 700;
margin-left: 4px;
vertical-align: middle;
text-transform: none;
letter-spacing: 0;
}
.boxset-info {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--text);
margin-top: 6px;
padding: 6px 10px;
background: var(--bg);
border-radius: 6px;
}
.text-muted {
color: var(--text-muted);
font-size: 12px;
}
</style>
+295
View File
@@ -0,0 +1,295 @@
<script>
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
export let config = { embyUrl: '', apiKey: '', dbPath: '' };
let localConfig = { ...config };
let status = '';
let statusType = ''; // 'ok' | 'error' | 'info'
let busy = false;
function setStatus(msg, type = 'info') {
status = msg;
statusType = type;
}
async function saveConfig() {
busy = true;
setStatus('Saving…');
try {
const res = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(localConfig)
});
if (!res.ok) throw new Error(await res.text());
config = { ...localConfig };
dispatch('configSaved', { ...localConfig });
setStatus('Settings saved.', 'ok');
} catch (e) {
setStatus(`Save failed: ${e.message}`, 'error');
} finally {
busy = false;
}
}
async function testConnection() {
if (!localConfig.embyUrl || !localConfig.apiKey) {
setStatus('Enter Emby URL and API key first.', 'error');
return;
}
busy = true;
setStatus('Connecting to Emby…');
try {
// Save config first so the server endpoint can read it
await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(localConfig)
});
const res = await fetch('/api/emby-users');
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(err.message || res.statusText);
}
const users = await res.json();
setStatus(`Connected — ${users.length} users found.`, 'ok');
} catch (e) {
setStatus(`Connection failed: ${e.message}`, 'error');
} finally {
busy = false;
}
}
async function refreshNames() {
busy = true;
setStatus('Fetching user names from Emby…');
try {
const res = await fetch('/api/emby-users');
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(err.message || res.statusText);
}
const embyUsers = await res.json();
dispatch('namesRefreshed', embyUsers);
setStatus(`Names refreshed — ${embyUsers.length} users from Emby.`, 'ok');
} catch (e) {
setStatus(`Failed: ${e.message}`, 'error');
} finally {
busy = false;
}
}
async function loadFromDb() {
if (!localConfig.dbPath) {
setStatus('Enter the DB path first.', 'error');
return;
}
busy = true;
setStatus('Reading database…');
try {
const res = await fetch('/api/db-read', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dbPath: localConfig.dbPath })
});
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(err.message || res.statusText);
}
const { users, validation } = await res.json();
dispatch('usersLoaded', { users, validation });
if (validation?.mismatchedUsers > 0 || validation?.missingSectionUserIds > 0) {
setStatus(
`Loaded ${users.length} users from ${validation.userSource}. ` +
`${validation.mismatchedUsers} user(s) had mismatched section UserIds and ` +
`${validation.normalizedUsers} user(s) were normalized on load.`,
'ok'
);
} else {
setStatus(
`Loaded ${users.length} users from ${validation?.userSource || 'database'}. ` +
'UserSettings IDs and section UserIds match.',
'ok'
);
}
} catch (e) {
setStatus(`Failed: ${e.message}`, 'error');
} finally {
busy = false;
}
}
</script>
<div class="panel">
<h3>Settings</h3>
<section>
<h4>Emby connection</h4>
<label>
<span>Server URL</span>
<input type="text" bind:value={localConfig.embyUrl} placeholder="http://localhost:8096" />
</label>
<label>
<span>API key</span>
<input type="password" bind:value={localConfig.apiKey} placeholder="Paste your API key" />
</label>
<p class="hint">
Get your API key from Emby: Dashboard → Advanced → API Keys → New API Key.
</p>
<div class="row">
<button class="btn ghost" on:click={saveConfig} disabled={busy}>Save</button>
<button class="btn ghost" on:click={testConnection} disabled={busy}>Test connection</button>
<button class="btn ghost" on:click={refreshNames} disabled={busy || !config.apiKey}>
Refresh user names
</button>
</div>
</section>
<section>
<h4>Database file</h4>
<label>
<span>Path to users.db</span>
<input
type="text"
bind:value={localConfig.dbPath}
placeholder="C:\ProgramData\Emby-Server\data\users.db"
/>
</label>
<p class="hint">
Stop Emby before loading or writing to avoid corruption. Typical path:
<code>C:\ProgramData\Emby-Server\data\users.db</code>
</p>
<div class="row">
<button class="btn ghost" on:click={saveConfig} disabled={busy}>Save path</button>
<button class="btn accent" on:click={loadFromDb} disabled={busy || !localConfig.dbPath}>
Load from DB
</button>
</div>
</section>
{#if status}
<div class="status" class:ok={statusType === 'ok'} class:error={statusType === 'error'}>
{status}
</div>
{/if}
</div>
<style>
.panel {
padding: 0 4px;
}
h3 {
font-size: 15px;
font-weight: 700;
margin: 0 0 16px;
color: var(--text);
}
h4 {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
margin: 0 0 10px;
}
section {
margin-bottom: 24px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border);
}
section:last-of-type {
border-bottom: none;
}
label {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
}
label span {
font-size: 12px;
color: var(--text-muted);
font-weight: 500;
}
input {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 7px 10px;
font-size: 13px;
color: var(--text);
font-family: inherit;
width: 100%;
box-sizing: border-box;
}
input:focus {
outline: none;
border-color: var(--accent);
}
.hint {
font-size: 11px;
color: var(--text-muted);
margin: 0 0 10px;
line-height: 1.5;
}
code {
font-size: 11px;
background: var(--bg);
padding: 1px 4px;
border-radius: 3px;
}
.row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.btn {
all: unset;
cursor: pointer;
padding: 6px 14px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
font-family: inherit;
transition: all 0.12s;
}
.btn.ghost {
color: var(--text-muted);
border: 1px solid var(--border);
}
.btn.ghost:hover:not(:disabled) {
background: var(--surface-hover);
color: var(--text);
}
.btn.accent {
background: var(--accent);
color: #fff;
}
.btn.accent:hover:not(:disabled) {
opacity: 0.9;
}
.btn:disabled {
opacity: 0.4;
cursor: default;
}
.status {
margin-top: 8px;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
background: var(--surface-hover);
color: var(--text-muted);
border-left: 3px solid var(--border);
}
.status.ok {
border-left-color: #22c55e;
color: #86efac;
}
.status.error {
border-left-color: var(--danger);
color: #fca5a5;
}
</style>
+134
View File
@@ -0,0 +1,134 @@
<script>
import { createEventDispatcher } from 'svelte';
export let sql = '';
const dispatch = createEventDispatcher();
let copied = false;
function copyToClipboard() {
navigator.clipboard.writeText(sql).then(() => {
copied = true;
setTimeout(() => (copied = false), 2000);
});
}
function download() {
const blob = new Blob([sql], { type: 'text/sql' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `emby-homescreen-update-${new Date().toISOString().slice(0, 10)}.sql`;
a.click();
URL.revokeObjectURL(url);
}
</script>
<div class="overlay" on:click|self={() => dispatch('close')} on:keydown={() => {}}>
<div class="modal">
<div class="modal-header">
<h3>Generated SQL</h3>
<button class="close-btn" on:click={() => dispatch('close')}>×</button>
</div>
<div class="modal-body">
<pre class="sql-output">{sql}</pre>
</div>
<div class="modal-actions">
<button class="action-btn secondary" on:click={copyToClipboard}>
{copied ? '✓ Copied' : 'Copy to clipboard'}
</button>
<button class="action-btn primary" on:click={download}> Download .sql file </button>
</div>
</div>
</div>
<style>
.overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 20px;
}
.modal {
background: var(--bg);
border-radius: 14px;
width: 100%;
max-width: 720px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.modal-header h3 {
margin: 0;
font-size: 16px;
color: var(--text);
}
.close-btn {
all: unset;
cursor: pointer;
font-size: 22px;
color: var(--text-muted);
line-height: 1;
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
}
.sql-output {
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: 12px;
line-height: 1.6;
color: var(--text);
white-space: pre-wrap;
word-break: break-all;
margin: 0;
background: var(--surface);
padding: 14px;
border-radius: 8px;
border: 1px solid var(--border);
}
.modal-actions {
display: flex;
gap: 10px;
padding: 16px 20px;
border-top: 1px solid var(--border);
justify-content: flex-end;
}
.action-btn {
all: unset;
cursor: pointer;
padding: 8px 18px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
}
.action-btn.secondary {
border: 1px solid var(--border);
color: var(--text);
}
.action-btn.secondary:hover {
background: var(--surface);
}
.action-btn.primary {
background: var(--accent);
color: #fff;
}
.action-btn.primary:hover {
opacity: 0.9;
}
</style>
+325
View File
@@ -0,0 +1,325 @@
<script>
import { createEventDispatcher } from 'svelte';
import { getSectionTypeLabel, getGenreNames } from '$lib/constants.js';
export let users = [];
const dispatch = createEventDispatcher();
let sourceUserId = null;
let targetUserIds = [];
let selectedSectionIndices = [];
let syncMode = 'replace'; // 'replace' = overwrite all, 'append' = add to end, 'selected' = only selected sections
$: sourceUser = users.find((u) => u.id === sourceUserId);
$: sourceSections = sourceUser?.sections || [];
$: availableTargets = users.filter((u) => u.id !== sourceUserId && u.sections?.length >= 0);
function toggleTarget(id) {
if (targetUserIds.includes(id)) {
targetUserIds = targetUserIds.filter((t) => t !== id);
} else {
targetUserIds = [...targetUserIds, id];
}
}
function selectAllTargets() {
targetUserIds = availableTargets.map((u) => u.id);
}
function deselectAllTargets() {
targetUserIds = [];
}
function toggleSectionIndex(idx) {
if (selectedSectionIndices.includes(idx)) {
selectedSectionIndices = selectedSectionIndices.filter((i) => i !== idx);
} else {
selectedSectionIndices = [...selectedSectionIndices, idx];
}
}
function doSync() {
if (!sourceUser || targetUserIds.length === 0) return;
let sectionsToSync;
if (syncMode === 'selected') {
sectionsToSync = selectedSectionIndices.map((i) => sourceSections[i]).filter(Boolean);
} else {
sectionsToSync = [...sourceSections];
}
dispatch('sync', {
sourceUserId,
targetUserIds,
sections: sectionsToSync,
mode: syncMode
});
}
</script>
<div class="sync-panel">
<h3>Sync sections between users</h3>
<div class="sync-step">
<span class="step-label">1. Source user</span>
<select bind:value={sourceUserId} class="select-input">
<option value={null}>Select source user...</option>
{#each users.filter((u) => u.sections?.length > 0) as user}
<option value={user.id}>{user.name} ({user.sections.length} sections)</option>
{/each}
</select>
</div>
{#if sourceUser}
<div class="sync-step">
<span class="step-label">2. Sync mode</span>
<div class="mode-group">
<label class="mode-option" class:active={syncMode === 'replace'}>
<input type="radio" bind:group={syncMode} value="replace" />
<div>
<strong>Replace all</strong>
<span class="mode-desc">Overwrite target's sections entirely</span>
</div>
</label>
<label class="mode-option" class:active={syncMode === 'append'}>
<input type="radio" bind:group={syncMode} value="append" />
<div>
<strong>Append all</strong>
<span class="mode-desc">Add source sections to end of target</span>
</div>
</label>
<label class="mode-option" class:active={syncMode === 'selected'}>
<input type="radio" bind:group={syncMode} value="selected" />
<div>
<strong>Selected only</strong>
<span class="mode-desc">Pick specific sections to sync</span>
</div>
</label>
</div>
</div>
{#if syncMode === 'selected'}
<div class="sync-step">
<span class="step-label">Sections to sync</span>
<div class="section-pick-list">
{#each sourceSections as section, idx}
<label class="section-pick" class:selected={selectedSectionIndices.includes(idx)}>
<input
type="checkbox"
checked={selectedSectionIndices.includes(idx)}
on:change={() => toggleSectionIndex(idx)}
/>
<span class="pick-name">{section.CustomName || section.Name || '(unnamed)'}</span>
<span class="pick-type">{getSectionTypeLabel(section.SectionType)}</span>
</label>
{/each}
</div>
</div>
{/if}
<div class="sync-step">
<div class="step-label-row">
<span class="step-label">3. Target users</span>
<div class="select-btns">
<button class="link-btn" on:click={selectAllTargets}>All</button>
<button class="link-btn" on:click={deselectAllTargets}>None</button>
</div>
</div>
<div class="target-list">
{#each availableTargets as user}
<label class="target-option" class:selected={targetUserIds.includes(user.id)}>
<input
type="checkbox"
checked={targetUserIds.includes(user.id)}
on:change={() => toggleTarget(user.id)}
/>
<span class="target-name">{user.name}</span>
<span class="target-count">{user.sections?.length || 0} sections</span>
</label>
{/each}
</div>
</div>
<button
class="sync-btn"
disabled={targetUserIds.length === 0 ||
(syncMode === 'selected' && selectedSectionIndices.length === 0)}
on:click={doSync}
>
Sync {syncMode === 'selected' ? `${selectedSectionIndices.length} section(s)` : 'all sections'}
to {targetUserIds.length} user{targetUserIds.length !== 1 ? 's' : ''}
</button>
{/if}
</div>
<style>
.sync-panel {
padding: 0;
}
h3 {
font-size: 15px;
font-weight: 600;
margin: 0 0 16px;
color: var(--text);
}
.sync-step {
margin-bottom: 16px;
}
.step-label {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
display: block;
margin-bottom: 6px;
}
.step-label-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.select-input {
width: 100%;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
font-size: 13px;
font-family: inherit;
}
.mode-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.mode-option {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--border);
cursor: pointer;
transition: all 0.12s;
}
.mode-option.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
}
.mode-option input {
margin-top: 2px;
}
.mode-option strong {
font-size: 13px;
color: var(--text);
display: block;
}
.mode-desc {
font-size: 12px;
color: var(--text-muted);
}
.section-pick-list {
display: flex;
flex-direction: column;
gap: 3px;
max-height: 200px;
overflow-y: auto;
}
.section-pick {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.section-pick:hover {
background: var(--surface-hover);
}
.section-pick.selected {
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.pick-name {
flex: 1;
color: var(--text);
}
.pick-type {
font-size: 11px;
color: var(--text-muted);
}
.target-list {
display: flex;
flex-direction: column;
gap: 3px;
}
.target-option {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.target-option:hover {
background: var(--surface-hover);
}
.target-option.selected {
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.target-name {
flex: 1;
color: var(--text);
}
.target-count {
font-size: 11px;
color: var(--text-muted);
}
.link-btn {
all: unset;
cursor: pointer;
font-size: 12px;
color: var(--accent);
}
.link-btn:hover {
text-decoration: underline;
}
.sync-btn {
all: unset;
cursor: pointer;
display: block;
width: 100%;
text-align: center;
padding: 10px;
border-radius: 8px;
background: var(--accent);
color: #fff;
font-size: 14px;
font-weight: 600;
font-family: inherit;
box-sizing: border-box;
transition: opacity 0.12s;
}
.sync-btn:hover:not(:disabled) {
opacity: 0.9;
}
.sync-btn:disabled {
opacity: 0.4;
cursor: default;
}
.select-btns {
display: flex;
gap: 10px;
}
</style>
+201
View File
@@ -0,0 +1,201 @@
export const GENRES = {
'2042': 'Action',
'1212': 'Sci-Fi',
'4910': 'Crime',
'62': 'Drama',
'82': 'Comedy',
'293': 'Animation',
'36': 'Documentary',
'5024': 'Horror',
'4835': 'Romance',
'4428': 'Thriller',
'5709': 'War',
'14124': 'Western',
'218': 'Food',
'396654': 'Reality',
'19618': 'Travel',
'17565': 'Mini Series',
'2008': 'Mystery',
'235': 'Family',
'480': 'Fantasy'
};
export const SECTION_TYPES = [
{ value: 'resume', label: 'Resume / Next Up' },
{ value: 'items', label: 'Items (filtered)' },
{ value: 'userviews', label: 'Libraries' },
{ value: 'boxset', label: 'Box Set' },
{ value: 'collections', label: 'Collections' },
{ value: 'latestepisodereleases', label: 'Latest episode releases' },
{ value: 'latestmoviereleases', label: 'Latest movie releases' },
{ value: 'latestmediablock', label: 'Latest media' }
];
export const COLLECTION_TYPES = [
{ value: '', label: '(none)' },
{ value: 'movies', label: 'Movies' },
{ value: 'tvshows', label: 'TV Shows' },
{ value: 'boxsets', label: 'Box Sets' }
];
export const ITEM_TYPES = ['Movie', 'Series', 'Episode', 'BoxSet'];
export const SORT_OPTIONS = [
{ value: '', label: '(none)' },
{ value: 'default', label: 'Default (boxset)' },
{ value: 'DateLastContentAdded,SortName', label: 'Date added' },
{ value: 'ProductionYear,PremiereDate,SortName', label: 'Release year' },
{ value: 'CommunityRating', label: 'Community rating' },
{ value: 'CriticRating,SortName', label: 'Critic rating' },
{ value: 'DateCreated,SortName', label: 'Date created' },
{ value: 'Random', label: 'Random' },
{ value: 'SortName', label: 'Name' }
];
export const IMAGE_TYPES = [
{ value: '', label: 'Default' },
{ value: 'Thumb', label: 'Thumb' },
{ value: 'Primary', label: 'Primary / Poster' }
];
export function genId() {
return crypto.randomUUID().replace(/-/g, '').slice(0, 32);
}
export function createEmptySection(userId) {
return {
UserId: userId,
Name: 'New Section',
CustomName: 'New Section',
Id: genId(),
SectionType: 'items',
ImageType: 'Thumb',
CollectionType: 'movies',
SortBy: 'DateLastContentAdded,SortName',
SortOrder: 'Descending',
Monitor: [],
ItemTypes: ['Movie'],
ExcludedFolders: [],
CardSizeOffset: 0,
IncludeNextUpInResume: true,
Query: {
StudioIds: [],
TagIds: [],
GenreIds: [],
CollectionTypes: [],
IsPlayed: false
}
};
}
export function getSectionTypeLabel(type) {
const found = SECTION_TYPES.find((t) => t.value === type);
return found ? found.label : type;
}
export function getGenreNames(genreIds) {
if (!genreIds || genreIds.length === 0) return '';
return genreIds.map((id) => GENRES[id] || id).join(', ');
}
export function extractUserName(sections) {
for (const s of sections) {
const name = s.CustomName || s.Name || '';
const lower = name.toLowerCase();
if (lower.includes('watchlist')) {
const extracted = name
.replace(/['']s\s*Watchlist/i, '')
.replace(/\s*Watchlist/i, '')
.trim();
if (extracted && extracted !== name) return extracted;
}
}
return null;
}
export function isWatchlistSection(section) {
if (!section) return false;
const name = `${section.CustomName || ''} ${section.Name || ''}`.toLowerCase();
return !!section.Query?.IsFavorite || name.includes('watchlist') || name.includes('watch list');
}
function renameWatchlistLabel(label, targetName) {
if (!label || !targetName) return label;
if (/^\s*watch\s*list\s*$/i.test(label)) return 'Watch List';
if (/^\s*watchlist\s*$/i.test(label)) return 'Watchlist';
if (/watch\s*list/i.test(label)) {
return label.replace(/^.*?watch\s*list/i, `${targetName}'s Watch List`);
}
if (/watchlist/i.test(label)) {
return label.replace(/^.*?watchlist/i, `${targetName}'s Watchlist`);
}
return label;
}
export function getWatchlistLabelsForTarget(sourceSection, targetUser) {
const existingTargetWatchlist = targetUser?.sections?.find((section) => isWatchlistSection(section));
if (existingTargetWatchlist) {
return {
Name: existingTargetWatchlist.Name || sourceSection.Name,
CustomName: existingTargetWatchlist.CustomName || existingTargetWatchlist.Name || sourceSection.CustomName || sourceSection.Name
};
}
const targetName = targetUser?.name || '';
return {
Name: renameWatchlistLabel(sourceSection.Name, targetName),
CustomName: renameWatchlistLabel(sourceSection.CustomName, targetName)
};
}
/**
* Build SQL UPDATE statements from modified user data.
* Each user's entire homescreensettings JSON is replaced.
*/
export function generateSQL(users, originalUsers) {
const statements = [];
for (const user of users) {
if (!user.sections || user.sections.length === 0) continue;
const original = originalUsers.find((u) => u.id === user.id);
if (!original) continue;
const origJSON = JSON.stringify({ Sections: original.sections });
const newJSON = JSON.stringify({ Sections: user.sections });
if (origJSON === newJSON) continue;
const escapedValue = newJSON.replace(/'/g, "''");
statements.push(
`-- User: ${user.name} (DB ID: ${user.id})`,
`UPDATE UserSettings SET Value = '${escapedValue}' WHERE UserId = ${user.id} AND UserSettingsKeyId = (SELECT UserSettingsKeyId FROM UserSettingsKeys WHERE Name = 'homescreensettings');`,
''
);
}
if (statements.length === 0) {
return '-- No changes detected';
}
return [
'-- ===========================================',
'-- Emby Home Screen Settings Update',
`-- Generated: ${new Date().toISOString()}`,
'-- ===========================================',
'-- IMPORTANT: Stop Emby before running this!',
'-- sqlite3 /path/to/users.db < this_file.sql',
'-- Then restart Emby.',
'-- ===========================================',
'',
'BEGIN TRANSACTION;',
'',
...statements,
'COMMIT;'
].join('\n');
}
+204
View File
@@ -0,0 +1,204 @@
function parseJsonBlob(blob) {
if (!blob) return null;
try {
const text = typeof blob === 'string' ? blob : Buffer.from(blob).toString('utf8');
return JSON.parse(text);
} catch {
return null;
}
}
/**
* Emby stores GUIDs in SQLite as 16-byte blobs using Microsoft mixed-endian ordering.
* Components 1-3 are little-endian; components 4-5 are big-endian.
*/
export function blobToEmbyGuid(blob) {
if (!blob) return '';
const b = blob instanceof Uint8Array ? blob : new Uint8Array(blob);
if (b.length !== 16) return Buffer.from(b).toString('hex').toLowerCase();
const out = new Uint8Array([
b[3], b[2], b[1], b[0],
b[5], b[4],
b[7], b[6],
b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]
]);
return Buffer.from(out).toString('hex').toLowerCase();
}
function hasTable(db, tableName) {
const row = db
.prepare("SELECT 1 AS found FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(tableName);
return !!row?.found;
}
function normalizeSectionUserId(sectionUserId) {
return typeof sectionUserId === 'string' ? sectionUserId.trim().toLowerCase() : '';
}
export function normalizeSectionsForUser(sections, expectedEmbyGuid) {
if (!Array.isArray(sections)) return [];
if (!expectedEmbyGuid) return sections;
return sections.map((section) => ({
...section,
UserId: expectedEmbyGuid
}));
}
function loadUsersTableUsers(db) {
const cols = db.prepare('PRAGMA table_info(Users)').all().map((c) => c.name);
const nameCol = cols.find((c) => /^username$/i.test(c)) || cols.find((c) => /^name$/i.test(c));
const guidCol = cols.find((c) => /^guid$/i.test(c));
const idCol = cols.find((c) => /^id$/i.test(c)) || 'Id';
if (!nameCol) {
throw new Error(`Cannot find a name column in Users table. Columns found: ${cols.join(', ')}`);
}
const selectCols = [idCol, nameCol, guidCol].filter(Boolean).join(', ');
const rows = db.prepare(`SELECT ${selectCols} FROM Users`).all();
return rows.map((row) => {
const rawId = row[idCol] ?? row.Id ?? row.id;
const rawGuid = guidCol ? row[guidCol] : null;
let embyGuid = '';
let guid = '';
if (rawGuid) {
if (rawGuid instanceof Uint8Array || rawGuid instanceof Buffer) {
const buf = Buffer.from(rawGuid);
guid = buf.toString('hex').toUpperCase();
embyGuid = blobToEmbyGuid(rawGuid);
} else if (typeof rawGuid === 'string') {
const clean = rawGuid.replace(/-/g, '').toLowerCase();
embyGuid = clean;
guid = clean.toUpperCase();
}
}
return {
id: rawId,
name: row[nameCol] || `User ${rawId}`,
guid,
embyGuid,
sourceTable: 'Users'
};
});
}
function loadLocalUsers(db) {
const rows = db.prepare('SELECT Id, guid, data FROM LocalUsersv2').all();
return rows.map((row) => {
const parsed = parseJsonBlob(row.data);
const guid = row.guid ? Buffer.from(row.guid).toString('hex').toUpperCase() : '';
const embyGuidFromBlob = blobToEmbyGuid(row.guid);
const embyGuidFromJson = normalizeSectionUserId(parsed?.IdString);
const embyGuid = embyGuidFromJson || embyGuidFromBlob;
return {
id: row.Id,
name: parsed?.Name || `User ${row.Id}`,
guid,
embyGuid,
sourceTable: 'LocalUsersv2',
profile: parsed || null
};
});
}
export function loadCanonicalUsers(db) {
if (hasTable(db, 'LocalUsersv2')) {
return loadLocalUsers(db);
}
if (hasTable(db, 'Users')) {
return loadUsersTableUsers(db);
}
throw new Error('No supported user table found. Expected LocalUsersv2 or Users.');
}
export function loadHomeScreenUsers(db) {
const users = loadCanonicalUsers(db);
const settingsRows = db
.prepare(
`SELECT us.UserId, us.Value
FROM UserSettings us
JOIN UserSettingsKeys usk ON us.UserSettingsKeyId = usk.UserSettingsKeyId
WHERE usk.Name = 'homescreensettings'`
)
.all();
const settingsMap = new Map(settingsRows.map((row) => [String(row.UserId), row.Value]));
const userIds = new Set(users.map((user) => String(user.id)));
let matchedUsers = 0;
let mismatchedUsers = 0;
let normalizedUsers = 0;
let missingSectionUserIds = 0;
const hydratedUsers = users.map((user) => {
const rawValue = settingsMap.get(String(user.id));
let sections = [];
try {
if (rawValue) sections = JSON.parse(rawValue).Sections || [];
} catch {
sections = [];
}
const actualUserIds = [...new Set(sections.map((section) => normalizeSectionUserId(section?.UserId)).filter(Boolean))];
const mismatchedSectionUserIds = user.embyGuid
? actualUserIds.filter((id) => id !== user.embyGuid)
: actualUserIds;
const missingIdsForUser = sections.filter((section) => !normalizeSectionUserId(section?.UserId)).length;
const normalizedSections = normalizeSectionsForUser(sections, user.embyGuid);
const sectionsWereNormalized =
user.embyGuid &&
JSON.stringify(sections) !== JSON.stringify(normalizedSections);
if (mismatchedSectionUserIds.length === 0) matchedUsers++;
else mismatchedUsers++;
if (sectionsWereNormalized) normalizedUsers++;
missingSectionUserIds += missingIdsForUser;
return {
id: user.id,
name: user.name,
guid: user.guid,
embyGuid: user.embyGuid,
sections: normalizedSections,
match: {
sourceTable: user.sourceTable,
settingsUserId: user.id,
expectedSectionUserId: user.embyGuid,
actualSectionUserIds: actualUserIds,
mismatchedSectionUserIds,
missingSectionUserIds: missingIdsForUser,
ok: mismatchedSectionUserIds.length === 0
}
};
});
const orphanedSettingsUserIds = settingsRows
.map((row) => String(row.UserId))
.filter((userId, index, all) => all.indexOf(userId) === index && !userIds.has(userId));
return {
users: hydratedUsers,
validation: {
userSource: users[0]?.sourceTable || null,
userCount: hydratedUsers.length,
settingsCount: settingsRows.length,
matchedUsers,
mismatchedUsers,
normalizedUsers,
missingSectionUserIds,
orphanedSettingsUserIds
}
};
}
export function loadUserLookup(db) {
return new Map(loadCanonicalUsers(db).map((user) => [String(user.id), user]));
}
+24
View File
@@ -0,0 +1,24 @@
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
/** @type {import('./$types').PageServerLoad} */
export async function load() {
const dataPath = resolve('static/db_export.json');
const raw = readFileSync(dataPath, 'utf-8');
const data = JSON.parse(raw);
const configPath = resolve('config.json');
let config = { embyUrl: '', apiKey: '', dbPath: '' };
if (existsSync(configPath)) {
try {
config = JSON.parse(readFileSync(configPath, 'utf-8'));
} catch { /* use defaults */ }
}
return {
users: data.users,
genres: data.genres,
enums: data.enums,
config
};
}
+678
View File
@@ -0,0 +1,678 @@
<script>
import SectionCard from '$lib/SectionCard.svelte';
import SyncPanel from '$lib/SyncPanel.svelte';
import SqlModal from '$lib/SqlModal.svelte';
import SettingsPanel from '$lib/SettingsPanel.svelte';
import { createEmptySection, generateSQL, getWatchlistLabelsForTarget, isWatchlistSection } from '$lib/constants.js';
/** @type {import('./$types').PageData} */
export let data;
let users = JSON.parse(JSON.stringify(data.users));
let originalUsers = JSON.parse(JSON.stringify(data.users));
let config = { ...data.config };
let selectedUserId = users.find((u) => u.sections?.length > 0)?.id || null;
let expandedIndex = -1;
let activeTab = 'edit'; // 'edit' | 'sync' | 'settings'
let showSqlModal = false;
let generatedSql = '';
let changeCount = 0;
let writeStatus = ''; // '' | 'writing' | 'ok' | 'error'
let writeMessage = '';
let dbValidation = null;
$: selectedUser = users.find((u) => u.id === selectedUserId);
$: sections = selectedUser?.sections || [];
$: dbConfigured = !!config.dbPath;
function moveSection(index, dir) {
const newIndex = index + dir;
if (newIndex < 0 || newIndex >= sections.length) return;
const arr = [...sections];
[arr[index], arr[newIndex]] = [arr[newIndex], arr[index]];
selectedUser.sections = arr;
expandedIndex = newIndex;
users = users;
changeCount++;
}
function removeSection(index) {
selectedUser.sections = sections.filter((_, i) => i !== index);
if (expandedIndex === index) expandedIndex = -1;
else if (expandedIndex > index) expandedIndex--;
users = users;
changeCount++;
}
function addSection() {
const embyGuid = selectedUser.embyGuid || '';
const newSection = createEmptySection(embyGuid);
selectedUser.sections = [...sections, newSection];
expandedIndex = sections.length - 1;
users = users;
changeCount++;
}
function handleSync(event) {
const { sourceUserId, targetUserIds, sections: syncSections, mode } = event.detail;
const source = users.find((u) => u.id === sourceUserId);
if (!source) return;
for (const targetId of targetUserIds) {
const target = users.find((u) => u.id === targetId);
if (!target) continue;
const cloned = JSON.parse(JSON.stringify(syncSections)).map((s) => {
s.UserId = target.embyGuid || '';
s.Id = crypto.randomUUID().replace(/-/g, '').slice(0, 32);
if (isWatchlistSection(s)) {
const labels = getWatchlistLabelsForTarget(s, target);
if (labels.Name) s.Name = labels.Name;
if (labels.CustomName) s.CustomName = labels.CustomName;
}
return s;
});
if (mode === 'replace') {
target.sections = cloned;
} else {
target.sections = [...(target.sections || []), ...cloned];
}
}
users = users;
changeCount += targetUserIds.length;
activeTab = 'edit';
}
function showSQL() {
generatedSql = generateSQL(users, originalUsers);
showSqlModal = true;
}
function resetAll() {
if (confirm('Reset all changes? This cannot be undone.')) {
users = JSON.parse(JSON.stringify(originalUsers));
changeCount = 0;
expandedIndex = -1;
writeStatus = '';
}
}
function onSectionChange() {
users = users;
changeCount++;
}
// Build the list of changed users for db-write
function buildChanges() {
const changes = [];
for (const user of users) {
if (!user.sections || user.sections.length === 0) continue;
const original = originalUsers.find((u) => u.id === user.id);
if (!original) continue;
const origJSON = JSON.stringify({ Sections: original.sections });
const newJSON = JSON.stringify({ Sections: user.sections });
if (origJSON !== newJSON) {
changes.push({ userId: user.id, sections: user.sections, name: user.name });
}
}
return changes;
}
async function writeToDb() {
const changes = buildChanges();
if (!changes.length) return;
const names = changes.map((c) => c.name).join(', ');
const confirmed = confirm(
`This will write changes for ${changes.length} user(s) directly to the database:\n\n${names}\n\nIMPORTANT: Emby must be stopped before continuing.\n\nProceed?`
);
if (!confirmed) return;
writeStatus = 'writing';
writeMessage = '';
try {
const res = await fetch('/api/db-write', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dbPath: config.dbPath, changes })
});
const body = await res.json();
if (!res.ok) throw new Error(body.message || body.error || res.statusText);
writeStatus = 'ok';
writeMessage = `Wrote ${body.count} user(s) to database.${body.normalizedSections ? ` Normalized ${body.normalizedSections} section UserId values.` : ''}`;
// Sync originalUsers so change counter resets
originalUsers = JSON.parse(JSON.stringify(users));
changeCount = 0;
} catch (e) {
writeStatus = 'error';
writeMessage = e.message;
}
}
// SettingsPanel event handlers
function onConfigSaved(e) {
config = { ...e.detail };
}
function onNamesRefreshed(e) {
const embyUsers = e.detail; // [{ embyGuid, name }]
const nameMap = Object.fromEntries(embyUsers.map((u) => [u.embyGuid, u.name]));
users = users.map((u) => ({
...u,
name: (u.embyGuid && nameMap[u.embyGuid]) ? nameMap[u.embyGuid] : u.name
}));
originalUsers = originalUsers.map((u) => ({
...u,
name: (u.embyGuid && nameMap[u.embyGuid]) ? nameMap[u.embyGuid] : u.name
}));
}
function onUsersLoaded(e) {
const payload = Array.isArray(e.detail) ? { users: e.detail, validation: null } : e.detail;
const freshUsers = payload.users || [];
users = JSON.parse(JSON.stringify(freshUsers));
originalUsers = JSON.parse(JSON.stringify(freshUsers));
dbValidation = payload.validation || null;
changeCount = 0;
expandedIndex = -1;
writeStatus = '';
selectedUserId = users.find((u) => u.sections?.length > 0)?.id || null;
}
</script>
<div class="app">
<header>
<div class="header-left">
<h1>Emby home screen editor</h1>
<span class="subtitle">{users.length} users · {users.reduce((n, u) => n + (u.sections?.length || 0), 0)} total sections</span>
</div>
<div class="header-actions">
{#if writeStatus === 'ok'}
<span class="write-status ok">{writeMessage}</span>
{:else if writeStatus === 'error'}
<span class="write-status error" title={writeMessage}>Write failed</span>
{/if}
{#if dbValidation && dbValidation.userSource}
<span class="db-status" title={`${dbValidation.matchedUsers} matched, ${dbValidation.mismatchedUsers} mismatched, ${dbValidation.normalizedUsers} normalized on load`}>
{dbValidation.userSource} linked
</span>
{/if}
{#if changeCount > 0}
<button class="btn ghost" on:click={resetAll}>Reset</button>
{/if}
<button class="btn ghost" on:click={showSQL} disabled={changeCount === 0}>
Generate SQL {changeCount > 0 ? `(${changeCount})` : ''}
</button>
{#if dbConfigured}
<button class="btn primary" on:click={writeToDb} disabled={changeCount === 0 || writeStatus === 'writing'}>
{writeStatus === 'writing' ? 'Writing…' : `Write to DB${changeCount > 0 ? ` (${changeCount})` : ''}`}
</button>
{/if}
<button
class="btn icon"
class:active={activeTab === 'settings'}
on:click={() => (activeTab = activeTab === 'settings' ? 'edit' : 'settings')}
title="Settings"
>⚙</button>
</div>
</header>
<div class="layout">
<!-- Left sidebar: user list + tabs -->
<aside class="sidebar">
<div class="tab-bar">
<button class="tab" class:active={activeTab === 'edit'} on:click={() => (activeTab = 'edit')}>Edit</button>
<button class="tab" class:active={activeTab === 'sync'} on:click={() => (activeTab = 'sync')}>Sync</button>
<button class="tab" class:active={activeTab === 'settings'} on:click={() => (activeTab = 'settings')}>Settings</button>
</div>
{#if activeTab === 'edit' || activeTab === 'sync'}
<div class="user-list">
{#each users.filter((u) => u.sections?.length > 0) as user}
<button
class="user-item"
class:active={selectedUserId === user.id}
on:click={() => {
selectedUserId = user.id;
expandedIndex = -1;
activeTab = 'edit';
}}
>
<span class="user-name">{user.name}</span>
<span class="user-count">{user.sections.length}</span>
</button>
{/each}
{#if users.some((u) => !u.sections || u.sections.length === 0)}
<div class="user-divider">No sections configured</div>
{#each users.filter((u) => !u.sections || u.sections.length === 0) as user}
<button
class="user-item dim"
class:active={selectedUserId === user.id}
on:click={() => {
selectedUserId = user.id;
expandedIndex = -1;
activeTab = 'edit';
}}
>
<span class="user-name">{user.name}</span>
<span class="user-count">0</span>
</button>
{/each}
{/if}
</div>
{/if}
{#if activeTab === 'settings'}
<div class="settings-container">
<SettingsPanel
{config}
on:configSaved={onConfigSaved}
on:namesRefreshed={onNamesRefreshed}
on:usersLoaded={onUsersLoaded}
/>
</div>
{/if}
</aside>
<!-- Main content -->
<main class="main">
{#if activeTab === 'edit' && selectedUser}
<div class="main-header">
<h2>{selectedUser.name}'s home screen</h2>
<button class="btn small accent" on:click={addSection}>+ Add section</button>
</div>
{#if sections.length === 0}
<div class="empty-state">
<p>No sections configured for this user.</p>
<button class="btn accent" on:click={addSection}>Add first section</button>
</div>
{:else}
<div class="section-list">
{#each sections as section, i (section.Id + '-' + i)}
<SectionCard
{section}
index={i}
total={sections.length}
genres={data.genres}
expanded={expandedIndex === i}
on:toggle={() => (expandedIndex = expandedIndex === i ? -1 : i)}
on:move={(e) => moveSection(i, e.detail)}
on:remove={() => removeSection(i)}
on:change={onSectionChange}
/>
{/each}
</div>
{/if}
{:else if activeTab === 'sync'}
<div class="sync-info">
<h2>Section sync</h2>
<p>
Use the panel on the left to copy home screen sections from one user to others.
You can replace their entire layout, append sections, or pick specific ones.
</p>
<p>After syncing, switch to the Edit tab to review changes per user.</p>
<SyncPanel {users} on:sync={handleSync} />
</div>
{:else if activeTab === 'settings'}
<div class="settings-info">
<h2>Settings</h2>
<p>Configure your Emby server connection and database path in the panel on the left.</p>
<div class="settings-cards">
<div class="info-card">
<div class="card-title">Emby API</div>
<div class="card-body">
Connect to your Emby server to fetch real user names. Without this, users
without a recognisable name pattern will show as "User 11", "User 14" etc.
</div>
</div>
<div class="info-card">
<div class="card-title">Load from DB</div>
<div class="card-body">
Read the live <code>users.db</code> directly instead of using the static
<code>db_export.json</code> snapshot. Stop Emby first to avoid a locked database.
</div>
</div>
<div class="info-card">
<div class="card-title">Write to DB</div>
<div class="card-body">
Apply your changes directly to the database — only the
<code>homescreensettings</code> rows for users you modified are touched.
Stop Emby first. The "Generate SQL" button is still available as a fallback.
</div>
</div>
</div>
</div>
{:else}
<div class="empty-state">
<p>Select a user from the sidebar to edit their home screen.</p>
</div>
{/if}
</main>
</div>
</div>
{#if showSqlModal}
<SqlModal sql={generatedSql} on:close={() => (showSqlModal = false)} />
{/if}
<style>
:global(*) {
box-sizing: border-box;
}
:global(body) {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
--bg: #0f1114;
--surface: #1a1d22;
--surface-hover: #22262d;
--border: #2a2e36;
--text: #e4e4e7;
--text-muted: #71717a;
--accent: #3b82f6;
--danger: #ef4444;
background: var(--bg);
color: var(--text);
}
.app {
height: 100vh;
display: flex;
flex-direction: column;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 24px;
border-bottom: 1px solid var(--border);
background: var(--surface);
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: baseline;
gap: 12px;
}
h1 {
font-size: 17px;
font-weight: 700;
margin: 0;
color: var(--text);
letter-spacing: -0.3px;
}
.subtitle {
font-size: 12px;
color: var(--text-muted);
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.write-status {
font-size: 12px;
padding: 4px 10px;
border-radius: 5px;
}
.db-status {
font-size: 12px;
padding: 4px 10px;
border-radius: 5px;
background: color-mix(in srgb, var(--accent) 12%, transparent);
color: #93c5fd;
}
.write-status.ok {
background: color-mix(in srgb, #22c55e 15%, transparent);
color: #86efac;
}
.write-status.error {
background: color-mix(in srgb, var(--danger) 15%, transparent);
color: #fca5a5;
cursor: help;
}
/* Buttons */
.btn {
all: unset;
cursor: pointer;
padding: 7px 16px;
border-radius: 7px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
transition: all 0.12s;
}
.btn.primary {
background: var(--accent);
color: #fff;
}
.btn.primary:hover:not(:disabled) {
opacity: 0.9;
}
.btn.primary:disabled {
opacity: 0.35;
cursor: default;
}
.btn.ghost {
color: var(--text-muted);
border: 1px solid var(--border);
}
.btn.ghost:hover:not(:disabled) {
background: var(--surface-hover);
color: var(--text);
}
.btn.ghost:disabled {
opacity: 0.35;
cursor: default;
}
.btn.accent {
background: var(--accent);
color: #fff;
}
.btn.accent:hover {
opacity: 0.9;
}
.btn.small {
padding: 5px 12px;
font-size: 12px;
}
.btn.icon {
padding: 6px 10px;
font-size: 16px;
color: var(--text-muted);
border: 1px solid transparent;
border-radius: 7px;
}
.btn.icon:hover {
background: var(--surface-hover);
color: var(--text);
}
.btn.icon.active {
color: var(--accent);
border-color: var(--accent);
}
/* Layout */
.layout {
display: flex;
flex: 1;
min-height: 0;
}
.sidebar {
width: 280px;
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
flex-shrink: 0;
background: var(--surface);
}
.main {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
}
/* Tabs */
.tab-bar {
display: flex;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.tab {
all: unset;
cursor: pointer;
flex: 1;
text-align: center;
padding: 10px;
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
border-bottom: 2px solid transparent;
transition: all 0.12s;
}
.tab:hover {
color: var(--text);
}
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* User list */
.user-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.user-item {
all: unset;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border-radius: 7px;
width: 100%;
box-sizing: border-box;
transition: background 0.1s;
}
.user-item:hover {
background: var(--surface-hover);
}
.user-item.active {
background: color-mix(in srgb, var(--accent) 15%, transparent);
}
.user-item.dim {
opacity: 0.5;
}
.user-name {
font-size: 13px;
font-weight: 500;
color: var(--text);
}
.user-count {
font-size: 11px;
color: var(--text-muted);
background: var(--bg);
padding: 2px 7px;
border-radius: 10px;
font-variant-numeric: tabular-nums;
}
.user-divider {
font-size: 11px;
color: var(--text-muted);
padding: 12px 12px 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
font-weight: 600;
}
/* Settings sidebar */
.settings-container {
flex: 1;
overflow-y: auto;
padding: 16px;
}
/* Main area */
.main-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
h2 {
font-size: 18px;
font-weight: 700;
margin: 0 0 12px;
color: var(--text);
letter-spacing: -0.3px;
}
.section-list {
max-width: 680px;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-muted);
}
.empty-state p {
margin-bottom: 16px;
}
/* Sync */
.sync-info {
max-width: 680px;
}
.sync-info > p {
color: var(--text-muted);
font-size: 14px;
line-height: 1.6;
margin-bottom: 8px;
}
/* Settings info panel */
.settings-info {
max-width: 600px;
}
.settings-info > p {
color: var(--text-muted);
font-size: 14px;
margin-bottom: 20px;
}
.settings-cards {
display: flex;
flex-direction: column;
gap: 12px;
}
.info-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 16px;
}
.card-title {
font-size: 13px;
font-weight: 700;
color: var(--text);
margin-bottom: 6px;
}
.card-body {
font-size: 13px;
color: var(--text-muted);
line-height: 1.5;
}
.card-body code {
font-size: 12px;
background: var(--bg);
padding: 1px 5px;
border-radius: 3px;
color: var(--text);
}
</style>
+29
View File
@@ -0,0 +1,29 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { resolve } from 'path';
import { json } from '@sveltejs/kit';
const CONFIG_PATH = resolve('config.json');
function loadConfig() {
if (!existsSync(CONFIG_PATH)) return { embyUrl: '', apiKey: '', dbPath: '' };
try {
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
} catch {
return { embyUrl: '', apiKey: '', dbPath: '' };
}
}
export async function GET() {
return json(loadConfig());
}
export async function POST({ request }) {
const body = await request.json();
const config = {
embyUrl: String(body.embyUrl || '').trim(),
apiKey: String(body.apiKey || '').trim(),
dbPath: String(body.dbPath || '').trim()
};
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
return json({ ok: true });
}
+21
View File
@@ -0,0 +1,21 @@
import { json, error } from '@sveltejs/kit';
import { existsSync } from 'fs';
import { DatabaseSync } from 'node:sqlite';
import { loadHomeScreenUsers } from '../../../lib/server/emby-user-db.js';
export async function POST({ request }) {
const { dbPath } = await request.json();
if (!dbPath) throw error(400, 'No dbPath provided');
if (!existsSync(dbPath)) throw error(404, `Database file not found: ${dbPath}`);
let db;
try {
db = new DatabaseSync(dbPath, { readOnly: true });
const result = loadHomeScreenUsers(db);
db.close();
return json(result);
} catch (err) {
if (db) try { db.close(); } catch { /* ignore */ }
throw error(500, err.message);
}
}
+72
View File
@@ -0,0 +1,72 @@
import { json, error } from '@sveltejs/kit';
import { existsSync } from 'fs';
import { DatabaseSync } from 'node:sqlite';
import { loadUserLookup, normalizeSectionsForUser } from '../../../lib/server/emby-user-db.js';
export async function POST({ request }) {
const { dbPath, changes } = await request.json();
if (!dbPath) throw error(400, 'No dbPath provided');
if (!existsSync(dbPath)) throw error(404, `Database file not found: ${dbPath}`);
if (!changes?.length) return json({ ok: true, count: 0 });
let db;
try {
db = new DatabaseSync(dbPath);
const userLookup = loadUserLookup(db);
const keyRow = db
.prepare("SELECT UserSettingsKeyId FROM UserSettingsKeys WHERE Name = 'homescreensettings'")
.get();
if (!keyRow) throw new Error("'homescreensettings' key not found in UserSettingsKeys table");
const keyId = keyRow.UserSettingsKeyId;
const checkStmt = db.prepare(
'SELECT 1 FROM UserSettings WHERE UserId = ? AND UserSettingsKeyId = ?'
);
const updateStmt = db.prepare(
'UPDATE UserSettings SET Value = ? WHERE UserId = ? AND UserSettingsKeyId = ?'
);
const insertStmt = db.prepare(
'INSERT INTO UserSettings (UserId, UserSettingsKeyId, Value) VALUES (?, ?, ?)'
);
let count = 0;
let normalizedSections = 0;
// node:sqlite transactions: use db.exec('BEGIN') / db.exec('COMMIT') manually
// or wrap in a function with db.transaction() if supported
db.exec('BEGIN');
try {
for (const { userId, sections } of changes) {
const user = userLookup.get(String(userId));
if (!user) {
throw new Error(`UserId ${userId} does not exist in ${dbPath}`);
}
const nextSections = normalizeSectionsForUser(sections, user.embyGuid);
if (JSON.stringify(nextSections) !== JSON.stringify(sections)) {
normalizedSections += nextSections.length;
}
const value = JSON.stringify({ Sections: nextSections });
const exists = checkStmt.get(userId, keyId);
if (exists) {
updateStmt.run(value, userId, keyId);
} else {
insertStmt.run(userId, keyId, value);
}
count++;
}
db.exec('COMMIT');
} catch (err) {
db.exec('ROLLBACK');
throw err;
}
db.close();
return json({ ok: true, count, normalizedSections });
} catch (err) {
if (db) try { db.close(); } catch { /* ignore */ }
throw error(500, err.message);
}
}
+42
View File
@@ -0,0 +1,42 @@
import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
import { json, error } from '@sveltejs/kit';
const CONFIG_PATH = resolve('config.json');
function loadConfig() {
if (!existsSync(CONFIG_PATH)) return {};
try {
return JSON.parse(readFileSync(CONFIG_PATH, 'utf-8'));
} catch {
return {};
}
}
export async function GET() {
const { embyUrl, apiKey } = loadConfig();
if (!embyUrl || !apiKey) {
throw error(400, 'Emby URL and API key not configured');
}
const base = embyUrl.replace(/\/+$/, '');
let res;
try {
res = await fetch(`${base}/Users?api_key=${encodeURIComponent(apiKey)}`);
} catch (e) {
throw error(502, `Could not reach Emby server: ${e.message}`);
}
if (!res.ok) {
throw error(res.status, `Emby API error: ${res.status} ${res.statusText}`);
}
const users = await res.json();
// Return embyGuid (no dashes, lowercase) -> name mapping
return json(
users.map((u) => ({
embyGuid: u.Id.replace(/-/g, '').toLowerCase(),
name: u.Name
}))
);
}
File diff suppressed because one or more lines are too long
+10
View File
@@ -0,0 +1,10 @@
import adapter from '@sveltejs/adapter-auto';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter()
}
};
export default config;
+6
View File
@@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});