mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 14:05:02 -04:00
feat(webui): add intelligence workspace
This commit is contained in:
6 files changed
+381
-11
No files matched your search
@@ -188,6 +188,8 @@ public sealed class IntelligenceControllerTests : IAsyncLifetime
|
||||
SignalsJson = JsonSerializer.Serialize(new[] { new RecommendationSignal("similar", .9, "Shared listening context") }),
|
||||
IdentityJson = JsonSerializer.Serialize(new RecommendationTrackIdentity(
|
||||
MusicBrainzRecordingId: "11111111-1111-1111-1111-111111111111",
|
||||
Title: "Recommended song",
|
||||
Artist: "Recommended artist",
|
||||
LibraryTrackId: Guid.Parse("22222222-2222-2222-2222-222222222222"),
|
||||
BackendItemId: "backend-track-42")),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
@@ -224,6 +226,8 @@ public sealed class IntelligenceControllerTests : IAsyncLifetime
|
||||
var result = Assert.IsType<OkObjectResult>(await Controller().Get(Scope(), default)); var json = JsonSerializer.Serialize(result.Value);
|
||||
Assert.Contains("Shared listening context", json); Assert.Contains("Private preview", json); Assert.Contains("visualization", json);
|
||||
Assert.Contains("lastfm:fixture", json, StringComparison.Ordinal);
|
||||
Assert.Contains("Recommended song", json, StringComparison.Ordinal);
|
||||
Assert.Contains("\"latestRunState\":\"succeeded\"", json, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("TenantId", json, StringComparison.Ordinal);
|
||||
var feedback = Assert.IsType<OkObjectResult>(await Controller().SetFeedback(
|
||||
Guid.Parse("33333333-3333-3333-3333-333333333333"), new()
|
||||
|
||||
@@ -48,6 +48,16 @@ public sealed class IntelligenceController(
|
||||
.Where(item => candidateIds.Contains(item.CandidateId) && item.TenantId == scope.TenantId &&
|
||||
item.OwnerUserId == scope.OwnerUserId)
|
||||
.ToDictionaryAsync(item => item.CandidateId, cancellationToken);
|
||||
var candidateIdentities = candidates.ToDictionary(item => item.Id, item => ParseIdentity(item.IdentityJson));
|
||||
var libraryTrackIds = candidateIdentities.Values.Select(item => item?.LibraryTrackId).OfType<Guid>().ToArray();
|
||||
var canonicalIds = candidates.Select(item => item.CanonicalRecordingId).OfType<Guid>().ToArray();
|
||||
var localTracks = await db.LibraryTracks.AsNoTracking().Where(item =>
|
||||
item.TenantId == scope.TenantId && item.OwnerUserId == scope.OwnerUserId &&
|
||||
item.Protocol == scope.Protocol && item.BackendInstanceId == scope.BackendInstanceId &&
|
||||
item.LibraryScopeId == scope.LibraryScopeId &&
|
||||
(libraryTrackIds.Contains(item.Id) ||
|
||||
item.CanonicalRecordingId.HasValue && canonicalIds.Contains(item.CanonicalRecordingId.Value)))
|
||||
.ToListAsync(cancellationToken);
|
||||
var sets = await db.GeneratedSets.AsNoTracking().Where(item => item.TenantId == scope.TenantId &&
|
||||
item.OwnerUserId == scope.OwnerUserId && item.Protocol == scope.Protocol &&
|
||||
item.BackendInstanceId == scope.BackendInstanceId && item.LibraryScopeId == scope.LibraryScopeId)
|
||||
@@ -105,21 +115,36 @@ public sealed class IntelligenceController(
|
||||
{
|
||||
canRun = policy?.Enabled == true && enabledProviders.Any(id => readinessById.TryGetValue(id, out var item) && item.State == RecommendationProviderReadinessState.Ready),
|
||||
canGenerate = latestRun?.State == RecommendationRunState.Succeeded,
|
||||
latestRunId = latestRun?.Id
|
||||
latestRunId = latestRun?.Id,
|
||||
latestRunState = latestRun?.State.ToString().ToLowerInvariant()
|
||||
},
|
||||
candidates = candidates.Select(item => new
|
||||
candidates = candidates.Select(item =>
|
||||
{
|
||||
item.TrackKey,
|
||||
item.Score,
|
||||
item.Source,
|
||||
item.CanonicalRecordingId,
|
||||
item.ProviderAccountId,
|
||||
item.SourceRevision,
|
||||
explanations = ParseSignals(item.SignalsJson),
|
||||
exclusions = ParseArray(item.ExclusionsJson),
|
||||
feedback = feedback.TryGetValue(item.Id, out var value)
|
||||
var identity = candidateIdentities[item.Id];
|
||||
var local = localTracks.FirstOrDefault(track => track.Id == identity?.LibraryTrackId) ??
|
||||
localTracks.FirstOrDefault(track => track.CanonicalRecordingId == item.CanonicalRecordingId);
|
||||
return new
|
||||
{
|
||||
id = item.Id,
|
||||
item.TrackKey,
|
||||
title = identity?.Title ?? local?.Title,
|
||||
artist = identity?.Artist ?? local?.Artist,
|
||||
album = identity?.Album ?? local?.Album,
|
||||
artworkUrl = local?.CoverArtReference == null ? null :
|
||||
$"/api/admin/downloads/artwork/{Uri.EscapeDataString(local.BackendItemId)}",
|
||||
item.Score,
|
||||
item.Source,
|
||||
providerId = identity?.ProviderId ?? item.Source,
|
||||
item.CanonicalRecordingId,
|
||||
item.ProviderAccountId,
|
||||
item.SourceRevision,
|
||||
item.Revision,
|
||||
explanations = ParseSignals(item.SignalsJson),
|
||||
exclusions = ParseArray(item.ExclusionsJson),
|
||||
feedback = feedback.TryGetValue(item.Id, out var value)
|
||||
? new { value.Kind, value.ReasonCode, value.UpdatedAt, value.Revision }
|
||||
: null
|
||||
};
|
||||
}),
|
||||
generatedSets = sets.Select(item => new
|
||||
{
|
||||
|
||||
@@ -534,6 +534,65 @@ export type DownloadsResponse = {
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type IntelligenceScope = {
|
||||
protocol: string;
|
||||
backendInstanceId: string;
|
||||
libraryScopeId: string;
|
||||
};
|
||||
|
||||
export type IntelligenceState = {
|
||||
state: string;
|
||||
message?: string | null;
|
||||
scope: IntelligenceScope;
|
||||
policy?: {
|
||||
enabled: boolean;
|
||||
retentionDays: number;
|
||||
revision: number;
|
||||
} | null;
|
||||
availableSignalTypes: Array<{ id: string; label: string; enabled: boolean }>;
|
||||
providers: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
available: boolean;
|
||||
state: string;
|
||||
reasonCode?: string | null;
|
||||
}>;
|
||||
actions: {
|
||||
canRun: boolean;
|
||||
canGenerate: boolean;
|
||||
latestRunId?: string | null;
|
||||
latestRunState?: string | null;
|
||||
};
|
||||
candidates: Array<{
|
||||
id: string;
|
||||
trackKey: string;
|
||||
title?: string | null;
|
||||
artist?: string | null;
|
||||
album?: string | null;
|
||||
artworkUrl?: string | null;
|
||||
score: number;
|
||||
source: string;
|
||||
providerId: string;
|
||||
sourceRevision: string;
|
||||
revision: number;
|
||||
explanations: Array<{ code: string; weight: number; explanation: string }>;
|
||||
exclusions: string[];
|
||||
feedback?: { kind: string; reasonCode?: string | null; revision: number } | null;
|
||||
}>;
|
||||
generatedSets: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
trackCount: number;
|
||||
state: string;
|
||||
materialized: boolean;
|
||||
backendPlaylistId?: string | null;
|
||||
errorCode?: string | null;
|
||||
}>;
|
||||
visualization: Array<{ key: string; label: string; value: number }>;
|
||||
};
|
||||
|
||||
async function json<T>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(input, {
|
||||
cache: "no-store",
|
||||
@@ -567,6 +626,31 @@ export const onboarding = {
|
||||
reopen: () => json<OnboardingState>("/api/admin/onboarding/reopen", { method: "POST" }),
|
||||
};
|
||||
|
||||
const intelligenceBody = (value: object, method = "POST") => ({
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(value),
|
||||
});
|
||||
|
||||
export const intelligence = {
|
||||
get: (scope: IntelligenceScope) =>
|
||||
json<IntelligenceState>(`/api/admin/intelligence?${new URLSearchParams(scope)}`),
|
||||
savePolicy: (scope: IntelligenceScope, input: object) =>
|
||||
json<{ revision: number }>("/api/admin/intelligence/policy",
|
||||
intelligenceBody({ ...scope, ...input }, "PUT")),
|
||||
run: (scope: IntelligenceScope, seedTrackKeys: string[] = []) =>
|
||||
json<{ runId: string; jobId: string }>("/api/admin/intelligence/runs",
|
||||
intelligenceBody({ ...scope, seedTrackKeys, limit: 25, idempotencyKey: crypto.randomUUID() })),
|
||||
generate: (scope: IntelligenceScope, runId: string, name: string) =>
|
||||
json<{ id: string }>("/api/admin/intelligence/generated-sets",
|
||||
intelligenceBody({ ...scope, runId, name })),
|
||||
feedback: (scope: IntelligenceScope, candidateId: string, kind: string, expectedRevision: number) =>
|
||||
json<{ revision: number }>(`/api/admin/intelligence/candidates/${encodeURIComponent(candidateId)}/feedback`,
|
||||
intelligenceBody({ ...scope, kind, expectedRevision }, "PUT")),
|
||||
purge: (scope: IntelligenceScope) =>
|
||||
json<void>("/api/admin/intelligence/data", intelligenceBody(scope, "DELETE")),
|
||||
};
|
||||
|
||||
export const home = {
|
||||
schema: () =>
|
||||
json<UiSchema>("/api/admin/ui/schema"),
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog } from "bits-ui";
|
||||
import { intelligence, type IntelligenceScope, type IntelligenceState } from "$lib/api";
|
||||
|
||||
let protocol = $state("jellyfin");
|
||||
let backendInstanceId = $state("");
|
||||
let libraryScopeId = $state("");
|
||||
let data = $state<IntelligenceState | null>(null);
|
||||
let loading = $state(false);
|
||||
let action = $state("");
|
||||
let error = $state("");
|
||||
let purgeOpen = $state(false);
|
||||
let generatedName = $state("Your recommendations");
|
||||
let enabled = $state(false);
|
||||
let retentionDays = $state(30);
|
||||
let selectedSignals = $state<string[]>([]);
|
||||
let selectedProviders = $state<string[]>([]);
|
||||
|
||||
const scope = $derived<IntelligenceScope>({ protocol, backendInstanceId, libraryScopeId });
|
||||
const visibleCandidates = $derived(data?.candidates.filter((item) => !item.exclusions.length) ?? []);
|
||||
|
||||
function adopt(next: IntelligenceState) {
|
||||
data = next;
|
||||
enabled = Boolean(next.policy?.enabled);
|
||||
retentionDays = next.policy?.retentionDays ?? 30;
|
||||
selectedSignals = next.availableSignalTypes.filter((item) => item.enabled).map((item) => item.id);
|
||||
selectedProviders = next.providers.filter((item) => item.enabled).map((item) => item.id);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!backendInstanceId.trim() || !libraryScopeId.trim()) return;
|
||||
loading = true;
|
||||
error = "";
|
||||
try {
|
||||
adopt(await intelligence.get(scope));
|
||||
} catch (cause) {
|
||||
data = null;
|
||||
error = cause instanceof Error ? cause.message : "Intelligence could not be loaded.";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function perform(name: string, operation: () => Promise<unknown>) {
|
||||
if (action) return;
|
||||
action = name;
|
||||
error = "";
|
||||
try {
|
||||
await operation();
|
||||
adopt(await intelligence.get(scope));
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : "The action could not be completed.";
|
||||
} finally {
|
||||
action = "";
|
||||
}
|
||||
}
|
||||
|
||||
function toggle(values: string[], value: string, checked: boolean) {
|
||||
return checked ? [...new Set([...values, value])] : values.filter((item) => item !== value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="intelligence-view">
|
||||
<header class="route-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Private discovery</p>
|
||||
<h2>Listen deeper, without exposing your history.</h2>
|
||||
<p>Explainable recommendations and generated playlists stay scoped to one account and library.</p>
|
||||
</div>
|
||||
{#if data?.actions.canRun}
|
||||
<div class="heading-actions">
|
||||
{#if data.actions.latestRunState}<span class={`status-pill ${data.actions.latestRunState === "succeeded" ? "healthy" : "suggested"}`}>{data.actions.latestRunState}</span>{/if}
|
||||
<button class="button-primary" type="button" disabled={Boolean(action)}
|
||||
onclick={() => void perform("run", () => intelligence.run(scope))}>
|
||||
{action === "run" ? "Starting…" : "Refresh recommendations"}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
<form class="panel scope-card" onsubmit={(event) => { event.preventDefault(); void load(); }}>
|
||||
<label class="field"><span>Media server</span><select bind:value={protocol}><option value="jellyfin">Jellyfin</option><option value="subsonic">Subsonic</option></select></label>
|
||||
<label class="field"><span>Backend instance</span><input bind:value={backendInstanceId} maxlength="200" placeholder="main" required /></label>
|
||||
<label class="field"><span>Library scope</span><input bind:value={libraryScopeId} maxlength="300" placeholder="music" required /></label>
|
||||
<button class="button-secondary" type="submit" disabled={loading}>{loading ? "Loading…" : "Open library"}</button>
|
||||
</form>
|
||||
|
||||
{#if error}<p class="notice-error" role="alert">{error}</p>{/if}
|
||||
|
||||
{#if loading}
|
||||
<div class="intelligence-grid" aria-busy="true" aria-label="Loading Intelligence">
|
||||
{#each Array(3) as _}<div class="panel skeleton-panel"></div>{/each}
|
||||
</div>
|
||||
{:else if !data}
|
||||
<section class="panel empty-state">
|
||||
<span class="empty-orbit" aria-hidden="true">✦</span>
|
||||
<p class="eyebrow">Choose a library</p>
|
||||
<h2>Your recommendations begin with an exact scope.</h2>
|
||||
<p>Enter the backend instance and library IDs used by your media server.</p>
|
||||
</section>
|
||||
{:else if data.state === "unauthorized" || data.state === "error"}
|
||||
<section class="panel route-error" role="alert">
|
||||
<span aria-hidden="true">!</span><div><p class="eyebrow">{data.state}</p><h2>This library is unavailable.</h2><p>{data.message}</p></div>
|
||||
<button class="button-secondary" type="button" onclick={() => void load()}>Try again</button>
|
||||
</section>
|
||||
{:else}
|
||||
{#if data.state === "degraded"}
|
||||
<div class="degraded-banner" role="status"><span aria-hidden="true">!</span><p><strong>Some discovery sources need attention.</strong> Existing results remain available.</p></div>
|
||||
{/if}
|
||||
|
||||
<div class="intelligence-grid">
|
||||
<section class="panel recommendations">
|
||||
<header><div><p class="eyebrow">For you</p><h3>Recommendations</h3></div><span>{visibleCandidates.length} tracks</span></header>
|
||||
{#if visibleCandidates.length}
|
||||
<ol class="recommendation-list">
|
||||
{#each visibleCandidates as item}
|
||||
<li>
|
||||
<span class="track-art">
|
||||
{#if item.artworkUrl}<img src={item.artworkUrl} alt="" loading="lazy" />{:else}<span aria-hidden="true">♪</span>{/if}
|
||||
</span>
|
||||
<div class="track-copy">
|
||||
<strong>{item.title || item.trackKey}</strong>
|
||||
<small>{item.artist || item.providerId}{item.album ? ` · ${item.album}` : ""}</small>
|
||||
<details><summary>Why this track</summary><ul>{#each item.explanations as reason}<li>{reason.explanation}</li>{/each}</ul></details>
|
||||
</div>
|
||||
<div class="track-actions">
|
||||
<span class="score">{Math.round(item.score * 100)}%</span>
|
||||
<button type="button" disabled={Boolean(action)} onclick={() => void perform(`similar:${item.id}`, () => intelligence.run(scope, [item.trackKey]))}>Similar</button>
|
||||
<button type="button" aria-label={`Dismiss ${item.title || item.trackKey}`} disabled={Boolean(action)}
|
||||
onclick={() => void perform(`dismiss:${item.id}`, () => intelligence.feedback(scope, item.id, "dismiss", item.feedback?.revision ?? 0))}>×</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{:else}
|
||||
<div class="compact-empty"><strong>No recommendations yet</strong><p>Enable at least one ready source, then run a refresh.</p></div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<aside class="side-stack">
|
||||
<section class="panel profile-card">
|
||||
<p class="eyebrow">Recent listening</p><h3>Your profile</h3>
|
||||
{#if data.visualization.length}
|
||||
{#each data.visualization as item}
|
||||
<label><span>{item.label}</span><meter min="0" max="1" value={item.value}>{item.value}</meter></label>
|
||||
{/each}
|
||||
{:else}<p class="muted">No retained listening signals yet.</p>{/if}
|
||||
</section>
|
||||
|
||||
<section class="panel generated-card">
|
||||
<p class="eyebrow">Saved output</p><h3>Generated playlists</h3>
|
||||
{#each data.generatedSets as item}
|
||||
<div class="generated-row"><span><strong>{item.name}</strong><small>{item.trackCount} tracks</small></span><span class={`status-pill ${item.materialized ? "healthy" : "suggested"}`}>{item.state}</span></div>
|
||||
{:else}<p class="muted">No generated playlists yet.</p>{/each}
|
||||
{#if data.actions.canGenerate && data.actions.latestRunId}
|
||||
<form class="generate-form" onsubmit={(event) => { event.preventDefault(); void perform("generate", () => intelligence.generate(scope, data!.actions.latestRunId!, generatedName)); }}>
|
||||
<label class="field"><span>Playlist name</span><input bind:value={generatedName} maxlength="200" required /></label>
|
||||
<button class="button-primary" type="submit" disabled={Boolean(action)}>{action === "generate" ? "Creating…" : "Create playlist"}</button>
|
||||
</form>
|
||||
{/if}
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="panel privacy-card">
|
||||
<header><div><p class="eyebrow">Control</p><h3>Privacy and sources</h3></div><span class={`status-pill ${enabled ? "healthy" : "suggested"}`}>{enabled ? "Enabled" : "Off"}</span></header>
|
||||
<form onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void perform("policy", () => intelligence.savePolicy(scope, {
|
||||
enabled, retentionDays, allowedSignalTypes: selectedSignals,
|
||||
enabledProviders: selectedProviders, expectedRevision: data!.policy?.revision ?? 0,
|
||||
}));
|
||||
}}>
|
||||
<label class="toggle-line"><input type="checkbox" bind:checked={enabled} /><span><strong>Use my listening signals</strong><small>Nothing is retained until this is enabled.</small></span></label>
|
||||
<label class="field"><span>Retention</span><select bind:value={retentionDays}><option value={7}>7 days</option><option value={30}>30 days</option><option value={90}>90 days</option><option value={365}>1 year</option></select></label>
|
||||
<fieldset><legend>Signals</legend>{#each data.availableSignalTypes as item}<label><input type="checkbox" checked={selectedSignals.includes(item.id)} onchange={(event) => selectedSignals = toggle(selectedSignals, item.id, event.currentTarget.checked)} /> {item.label}</label>{/each}</fieldset>
|
||||
<fieldset><legend>Sources</legend>{#each data.providers as provider}<label class:unavailable={!provider.available}><input type="checkbox" disabled={!provider.available} checked={selectedProviders.includes(provider.id)} onchange={(event) => selectedProviders = toggle(selectedProviders, provider.id, event.currentTarget.checked)} /> <span><strong>{provider.label}</strong><small>{provider.description} · {provider.state}</small></span></label>{/each}</fieldset>
|
||||
<footer><button class="button-danger" type="button" onclick={() => purgeOpen = true}>Turn off and clear</button><button class="button-primary" type="submit" disabled={Boolean(action)}>{action === "policy" ? "Saving…" : "Save settings"}</button></footer>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<AlertDialog.Root bind:open={purgeOpen}>
|
||||
<AlertDialog.Portal><AlertDialog.Overlay class="dialog-overlay" /><AlertDialog.Content class="confirm-dialog">
|
||||
<AlertDialog.Title>Clear this library’s Intelligence data?</AlertDialog.Title>
|
||||
<AlertDialog.Description>Retained signals, profiles, recommendations, feedback, and generated sets for this exact scope will be removed.</AlertDialog.Description>
|
||||
<footer><AlertDialog.Cancel class="button-secondary">Keep my data</AlertDialog.Cancel><AlertDialog.Action class="button-danger" onclick={() => void perform("purge", () => intelligence.purge(scope))}>Turn off and clear</AlertDialog.Action></footer>
|
||||
</AlertDialog.Content></AlertDialog.Portal>
|
||||
</AlertDialog.Root>
|
||||
|
||||
<style>
|
||||
.intelligence-view{display:grid;gap:1.25rem}.route-heading{display:flex;align-items:end;justify-content:space-between;gap:1rem}.route-heading h2{margin:.25rem 0;font-family:var(--font-display);font-size:clamp(1.5rem,3vw,2.2rem)}.route-heading p:last-child,.muted{color:var(--color-ink-muted)}.heading-actions{display:flex;align-items:center;gap:.75rem}.scope-card{display:grid;grid-template-columns:repeat(3,minmax(0,1fr)) auto;align-items:end;gap:1rem;padding:1rem}.field select{min-height:3rem;border:1px solid var(--color-edge);border-radius:.8rem;background:var(--color-panel-raised);color:var(--color-ink);padding:0 .9rem}.intelligence-grid{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(18rem,.8fr);gap:1rem}.recommendations,.profile-card,.generated-card,.privacy-card{padding:1.15rem}.recommendations>header,.privacy-card>header{display:flex;align-items:center;justify-content:space-between}.recommendations h3,.profile-card h3,.generated-card h3,.privacy-card h3{margin:.2rem 0 1rem}.recommendation-list{display:grid;margin:0;padding:0;list-style:none}.recommendation-list>li{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.85rem;align-items:center;border-top:1px solid var(--color-edge);padding:.9rem 0}.track-art{display:grid;width:3rem;height:3rem;place-items:center;overflow:hidden;border-radius:.7rem;background:var(--color-panel-raised);color:var(--color-signal)}.track-art img{width:100%;height:100%;object-fit:cover}.track-copy{min-width:0}.track-copy>strong,.track-copy>small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.track-copy small,summary{color:var(--color-ink-muted);font-size:.75rem}.track-copy details{margin-top:.35rem}.track-copy ul{margin:.4rem 0 0;padding-left:1.1rem;color:var(--color-ink-muted);font-size:.78rem}.track-actions{display:flex;align-items:center;gap:.35rem}.track-actions button{border:1px solid var(--color-edge);border-radius:.55rem;background:transparent;color:var(--color-ink-muted);padding:.35rem .5rem;cursor:pointer}.score{color:var(--color-signal);font-size:.75rem;font-weight:800}.side-stack{display:grid;align-content:start;gap:1rem}.profile-card label,.generated-row{display:flex;align-items:center;justify-content:space-between;gap:1rem;border-top:1px solid var(--color-edge);padding:.7rem 0}.profile-card meter{width:55%;accent-color:var(--color-signal)}.generated-row span:first-child strong,.generated-row span:first-child small{display:block}.generated-row small{color:var(--color-ink-muted)}.generate-form{display:grid;gap:.75rem;margin-top:1rem}.privacy-card{grid-column:1/-1}.privacy-card form{display:grid;grid-template-columns:minmax(14rem,.6fr) 1fr 1fr;gap:1rem}.toggle-line{display:flex;gap:.75rem}.toggle-line span>*{display:block}.toggle-line small,fieldset small{color:var(--color-ink-muted)}fieldset{display:grid;align-content:start;gap:.55rem;border:0;margin:0;padding:0}fieldset legend{margin-bottom:.55rem;font-weight:750}fieldset label{display:flex;gap:.5rem}.unavailable{opacity:.5}.privacy-card footer{grid-column:1/-1;display:flex;justify-content:space-between;gap:.75rem;border-top:1px solid var(--color-edge);padding-top:1rem}
|
||||
@media(max-width:900px){.scope-card{grid-template-columns:1fr 1fr}.intelligence-grid{grid-template-columns:1fr}.privacy-card{grid-column:auto}.privacy-card form{grid-template-columns:1fr 1fr}}
|
||||
@media(max-width:620px){.route-heading{align-items:stretch;flex-direction:column}.scope-card,.privacy-card form{grid-template-columns:1fr}.recommendation-list>li{grid-template-columns:auto minmax(0,1fr)}.track-actions{grid-column:2;flex-wrap:wrap}.privacy-card footer{grid-column:auto;flex-direction:column-reverse}.privacy-card footer>*{width:100%}}
|
||||
</style>
|
||||
@@ -8,6 +8,7 @@
|
||||
const destinations = [
|
||||
{ href: "#/", label: "Home", icon: "⌂" },
|
||||
{ href: "#/library/playlists", prefix: "/library/", label: "Library", icon: "♫" },
|
||||
{ href: "#/intelligence", label: "Intelligence", icon: "✦" },
|
||||
{ href: "#/sources", label: "Sources", icon: "◎" },
|
||||
{ href: "#/activity", label: "Activity", icon: "↗" },
|
||||
{ href: "#/settings", label: "Settings", icon: "⚙" },
|
||||
@@ -107,6 +108,7 @@
|
||||
return import("$lib/components/DownloadsView.svelte");
|
||||
}
|
||||
if (path === "/activity") return import("$lib/components/EventLogView.svelte");
|
||||
if (path === "/intelligence") return import("$lib/components/IntelligenceView.svelte");
|
||||
if (path === "/sources") return import("$lib/components/SourcesView.svelte");
|
||||
if (path.startsWith("/settings")) return import("$lib/components/SettingsView.svelte");
|
||||
}
|
||||
|
||||
@@ -163,6 +163,30 @@ const responses: Record<string, unknown> = {
|
||||
"/api/admin/extensions/registries": [],
|
||||
"/api/admin/extensions/store": { items: [], errors: [] },
|
||||
"/api/admin/extensions/logs?limit=100": [],
|
||||
"/api/admin/intelligence": {
|
||||
state: "configured",
|
||||
scope: { protocol: "jellyfin", backendInstanceId: "main", libraryScopeId: "music" },
|
||||
policy: { enabled: true, retentionDays: 30, revision: 1 },
|
||||
availableSignalTypes: [{ id: "play", label: "Play", enabled: true }],
|
||||
providers: [{
|
||||
id: "lumen-audio", label: "Lumen Audio", description: "Private similarity source.",
|
||||
enabled: true, available: true, state: "ready",
|
||||
}],
|
||||
actions: {
|
||||
canRun: true, canGenerate: true, latestRunId: "run-1", latestRunState: "succeeded",
|
||||
},
|
||||
candidates: [{
|
||||
id: "candidate-1", trackKey: "track-1", title: "Future Song", artist: "Artist",
|
||||
album: "Album", score: .91, source: "lumen-audio", providerId: "lumen-audio",
|
||||
sourceRevision: "fixture:1", revision: 1, explanations: [{
|
||||
code: "similar", weight: .9, explanation: "Similar to recent listening.",
|
||||
}], exclusions: [], feedback: null,
|
||||
}],
|
||||
generatedSets: [{
|
||||
id: "set-1", name: "Morning discovery", trackCount: 25, state: "succeeded", materialized: true,
|
||||
}],
|
||||
visualization: [{ key: "plays", label: "Plays", value: .7 }],
|
||||
},
|
||||
};
|
||||
|
||||
async function mockApi(page: Page, options: { delay?: string; fail?: string[] } = {}) {
|
||||
@@ -261,6 +285,16 @@ async function mockApi(page: Page, options: { delay?: string; fail?: string[] }
|
||||
body = { success: true, alreadyApplied: false };
|
||||
if (url.pathname === "/api/admin/config/migration/reset")
|
||||
body = {};
|
||||
if (url.pathname === "/api/admin/intelligence/runs")
|
||||
body = { runId: "run-2", jobId: "job-2" };
|
||||
if (url.pathname === "/api/admin/intelligence/generated-sets")
|
||||
body = { id: "set-2" };
|
||||
if (url.pathname.includes("/api/admin/intelligence/candidates/") && url.pathname.endsWith("/feedback"))
|
||||
body = { revision: 1 };
|
||||
if (url.pathname === "/api/admin/intelligence/policy")
|
||||
body = { revision: 2 };
|
||||
if (url.pathname === "/api/admin/intelligence/data")
|
||||
body = {};
|
||||
await route.fulfill({
|
||||
status: body === undefined ? 404 : 200,
|
||||
contentType: "application/json",
|
||||
@@ -276,6 +310,7 @@ const routes = [
|
||||
["#/library/cached", "Library"],
|
||||
["#/library/kept", "Library"],
|
||||
["#/activity", "Activity"],
|
||||
["#/intelligence", "Intelligence"],
|
||||
["#/sources", "Sources"],
|
||||
["#/settings/general", "Settings"],
|
||||
] as const;
|
||||
@@ -339,6 +374,30 @@ for (const viewport of viewports) {
|
||||
await expect(dialog.getByRole("button", { name: "Purge cache" })).toBeInViewport();
|
||||
});
|
||||
|
||||
test("Intelligence results remain usable", async ({ page, context }) => {
|
||||
await mockApi(page, { delay: "/api/admin/intelligence" });
|
||||
await page.goto("#/intelligence");
|
||||
await page.getByLabel("Backend instance").fill("main");
|
||||
await page.getByLabel("Library scope").fill("music");
|
||||
await page.getByRole("button", { name: "Open library" }).click();
|
||||
await expect(page.getByLabel("Loading Intelligence")).toBeVisible();
|
||||
await expect(page.getByText("Future Song")).toBeVisible();
|
||||
await expect(page.getByText("Morning discovery")).toBeVisible();
|
||||
await expect(page.getByText("Private similarity source. · ready")).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Refresh recommendations" })).toBeInViewport();
|
||||
await expect.poll(() => page.evaluate(() =>
|
||||
document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
||||
|
||||
const errorPage = await context.newPage();
|
||||
await mockApi(errorPage, { fail: ["/api/admin/intelligence"] });
|
||||
await errorPage.goto("#/intelligence");
|
||||
await errorPage.getByLabel("Backend instance").fill("main");
|
||||
await errorPage.getByLabel("Library scope").fill("music");
|
||||
await errorPage.getByRole("button", { name: "Open library" }).click();
|
||||
await expect(errorPage.getByRole("alert")).toContainText("Fixture unavailable");
|
||||
await expect(errorPage.getByRole("button", { name: "Open library" })).toBeInViewport();
|
||||
});
|
||||
|
||||
test("Source dialogs remain usable", async ({ page }) => {
|
||||
await mockApi(page);
|
||||
await page.goto("#/sources");
|
||||
|
||||
Reference in new issue
Block a user