v1
This commit is contained in:
+22
@@ -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
|
||||
Generated
+1464
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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]));
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,10 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
});
|
||||
Reference in New Issue
Block a user