This commit is contained in:
2026-04-18 23:08:52 +12:00
commit e68a8b1622
20 changed files with 4207 additions and 0 deletions
+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
}))
);
}