2026-04-18 23:08:52 +12:00
|
|
|
<script>
|
|
|
|
|
import { createEventDispatcher } from 'svelte';
|
|
|
|
|
const dispatch = createEventDispatcher();
|
|
|
|
|
|
2026-04-25 22:57:08 +12:00
|
|
|
export let config = { embyUrl: '', apiKey: '', tmdbApiKey: '', dbPath: '' };
|
2026-04-18 23:08:52 +12:00
|
|
|
|
|
|
|
|
let localConfig = { ...config };
|
|
|
|
|
let status = '';
|
|
|
|
|
let statusType = ''; // 'ok' | 'error' | 'info'
|
|
|
|
|
let busy = false;
|
|
|
|
|
|
|
|
|
|
function setStatus(msg, type = 'info') {
|
|
|
|
|
status = msg;
|
|
|
|
|
statusType = type;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-25 22:57:08 +12:00
|
|
|
async function fetchEmbyUsers() {
|
|
|
|
|
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 payload = await res.json();
|
|
|
|
|
return Array.isArray(payload)
|
|
|
|
|
? { users: payload, source: 'live', lastSyncedAt: null, message: '' }
|
|
|
|
|
: payload;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-18 23:08:52 +12:00
|
|
|
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)
|
|
|
|
|
});
|
2026-04-25 22:57:08 +12:00
|
|
|
const payload = await fetchEmbyUsers();
|
|
|
|
|
if (payload.source === 'cache') {
|
|
|
|
|
setStatus(
|
|
|
|
|
`${payload.message} Loaded ${payload.users.length} cached users from ${payload.lastSyncedAt || 'the last successful sync'}.`,
|
|
|
|
|
'ok'
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
setStatus(`Connected — ${payload.users.length} users found and cached locally.`, 'ok');
|
2026-04-18 23:08:52 +12:00
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
setStatus(`Connection failed: ${e.message}`, 'error');
|
|
|
|
|
} finally {
|
|
|
|
|
busy = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function refreshNames() {
|
|
|
|
|
busy = true;
|
|
|
|
|
setStatus('Fetching user names from Emby…');
|
|
|
|
|
try {
|
2026-04-25 22:57:08 +12:00
|
|
|
const payload = await fetchEmbyUsers();
|
|
|
|
|
dispatch('namesRefreshed', payload);
|
|
|
|
|
if (payload.source === 'cache') {
|
|
|
|
|
setStatus(
|
|
|
|
|
`${payload.message} Refreshed ${payload.users.length} users from the local cache.`,
|
|
|
|
|
'ok'
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
setStatus(`Names refreshed — ${payload.users.length} users from Emby and saved locally.`, 'ok');
|
2026-04-18 23:08:52 +12:00
|
|
|
}
|
|
|
|
|
} 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>
|
|
|
|
|
|
2026-04-25 22:57:08 +12:00
|
|
|
<section>
|
|
|
|
|
<h4>Recommendations</h4>
|
|
|
|
|
<label>
|
|
|
|
|
<span>TMDB API key</span>
|
|
|
|
|
<input
|
|
|
|
|
type="password"
|
|
|
|
|
bind:value={localConfig.tmdbApiKey}
|
|
|
|
|
placeholder="Paste your TMDB v3 API key"
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
<p class="hint">
|
|
|
|
|
Stored locally in the app config so recommendation features can reuse it without re-entering
|
|
|
|
|
the key each time.
|
|
|
|
|
</p>
|
|
|
|
|
<div class="row">
|
|
|
|
|
<button class="btn ghost" on:click={saveConfig} disabled={busy}>Save TMDB key</button>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
2026-04-18 23:08:52 +12:00
|
|
|
<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 {
|
2026-04-25 22:57:08 +12:00
|
|
|
font-size: 18px;
|
2026-04-18 23:08:52 +12:00
|
|
|
font-weight: 700;
|
2026-04-25 22:57:08 +12:00
|
|
|
margin: 0 0 18px;
|
2026-04-18 23:08:52 +12:00
|
|
|
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 {
|
2026-04-25 22:57:08 +12:00
|
|
|
margin-bottom: 18px;
|
|
|
|
|
padding: 16px;
|
|
|
|
|
border: 1px solid var(--border);
|
|
|
|
|
border-radius: 18px;
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.16);
|
2026-04-18 23:08:52 +12:00
|
|
|
}
|
|
|
|
|
section:last-of-type {
|
2026-04-25 22:57:08 +12:00
|
|
|
margin-bottom: 12px;
|
2026-04-18 23:08:52 +12:00
|
|
|
}
|
|
|
|
|
label {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 4px;
|
|
|
|
|
margin-bottom: 10px;
|
|
|
|
|
}
|
|
|
|
|
label span {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: var(--text-muted);
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
}
|
|
|
|
|
input {
|
2026-04-25 22:57:08 +12:00
|
|
|
background: #0b0f14;
|
2026-04-18 23:08:52 +12:00
|
|
|
border: 1px solid var(--border);
|
2026-04-25 22:57:08 +12:00
|
|
|
border-radius: 12px;
|
|
|
|
|
padding: 10px 12px;
|
2026-04-18 23:08:52 +12:00
|
|
|
font-size: 13px;
|
|
|
|
|
color: var(--text);
|
|
|
|
|
font-family: inherit;
|
|
|
|
|
width: 100%;
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
}
|
|
|
|
|
input:focus {
|
|
|
|
|
outline: none;
|
2026-04-25 22:57:08 +12:00
|
|
|
border-color: var(--border-strong);
|
2026-04-18 23:08:52 +12:00
|
|
|
}
|
|
|
|
|
.hint {
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
color: var(--text-muted);
|
|
|
|
|
margin: 0 0 10px;
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
}
|
|
|
|
|
code {
|
|
|
|
|
font-size: 11px;
|
2026-04-25 22:57:08 +12:00
|
|
|
background: #0b0f14;
|
|
|
|
|
padding: 2px 5px;
|
|
|
|
|
border-radius: 6px;
|
2026-04-18 23:08:52 +12:00
|
|
|
}
|
|
|
|
|
.row {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
}
|
|
|
|
|
.btn {
|
|
|
|
|
all: unset;
|
|
|
|
|
cursor: pointer;
|
2026-04-25 22:57:08 +12:00
|
|
|
padding: 9px 14px;
|
|
|
|
|
border-radius: 12px;
|
2026-04-18 23:08:52 +12:00
|
|
|
font-size: 12px;
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
font-family: inherit;
|
|
|
|
|
transition: all 0.12s;
|
|
|
|
|
}
|
|
|
|
|
.btn.ghost {
|
2026-04-25 22:57:08 +12:00
|
|
|
color: #d9e7f8;
|
2026-04-18 23:08:52 +12:00
|
|
|
border: 1px solid var(--border);
|
2026-04-25 22:57:08 +12:00
|
|
|
background: var(--bg-secondary);
|
2026-04-18 23:08:52 +12:00
|
|
|
}
|
|
|
|
|
.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;
|
2026-04-25 22:57:08 +12:00
|
|
|
padding: 10px 12px;
|
|
|
|
|
border-radius: 14px;
|
2026-04-18 23:08:52 +12:00
|
|
|
font-size: 12px;
|
2026-04-25 22:57:08 +12:00
|
|
|
background: var(--surface);
|
2026-04-18 23:08:52 +12:00
|
|
|
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>
|