mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-07 22:03:14 -04:00
feat(intelligence): unify history discovery and automation
This commit is contained in:
6 files changed
+265
-79
No files matched your search
@@ -374,14 +374,19 @@ public sealed class IntelligenceControllerTests : IAsyncLifetime
|
||||
item.Album = "Album A";
|
||||
item.SourceKind = "import";
|
||||
item.ClientClass = "Koito";
|
||||
item.ProviderId = "spotify";
|
||||
}
|
||||
firstA.DurationMilliseconds = 1_000;
|
||||
secondA.DurationMilliseconds = null;
|
||||
trackB.Artist = "Artist B";
|
||||
trackB.Album = "Album B";
|
||||
trackB.ClientClass = "Feishin";
|
||||
trackB.ProviderId = "deezer";
|
||||
trackB.DurationMilliseconds = 2_000;
|
||||
trackC.Artist = "Artist C";
|
||||
trackC.Album = "Album C";
|
||||
trackC.ClientClass = "Finer";
|
||||
trackC.ProviderId = "deezer";
|
||||
trackC.DurationMilliseconds = 3_000;
|
||||
var before = HistoryEvent("Before", from.AddMilliseconds(-1));
|
||||
var upperBound = HistoryEvent("Upper bound", to);
|
||||
@@ -409,7 +414,7 @@ public sealed class IntelligenceControllerTests : IAsyncLifetime
|
||||
TimeZoneId = "America/New_York"
|
||||
}, default));
|
||||
elapsed.Stop();
|
||||
Assert.InRange(_commands.Count, 6, 8);
|
||||
Assert.InRange(_commands.Count, 7, 9);
|
||||
Assert.True(elapsed.Elapsed < TimeSpan.FromSeconds(2),
|
||||
$"History overview took {elapsed.Elapsed.TotalMilliseconds:F0} ms in {_commands.Count} queries.");
|
||||
var overview = JsonSerializer.SerializeToElement(overviewResult.Value);
|
||||
@@ -423,6 +428,13 @@ public sealed class IntelligenceControllerTests : IAsyncLifetime
|
||||
Assert.Equal(from, selected.GetProperty("FirstListen").GetDateTimeOffset());
|
||||
Assert.True(overview.GetProperty("currentStreakDays").GetInt32() == 3, overview.GetRawText());
|
||||
Assert.True(overview.GetProperty("longestStreakDays").GetInt32() == 3, overview.GetRawText());
|
||||
var breakdowns = overview.GetProperty("breakdowns");
|
||||
Assert.Equal(["import", "protocol"], breakdowns.GetProperty("Sources").EnumerateArray()
|
||||
.Select(item => item.GetProperty("Value").GetString()));
|
||||
Assert.Equal(["deezer", "spotify"], breakdowns.GetProperty("Providers").EnumerateArray()
|
||||
.Select(item => item.GetProperty("Value").GetString()));
|
||||
Assert.Equal(["Koito", "Feishin", "Finer"], breakdowns.GetProperty("Clients").EnumerateArray()
|
||||
.Select(item => item.GetProperty("Value").GetString()));
|
||||
|
||||
var activityResult = Assert.IsType<OkObjectResult>(await Controller().GetHistoryActivity(new()
|
||||
{
|
||||
|
||||
@@ -27,15 +27,17 @@ public sealed partial class IntelligenceController
|
||||
|
||||
var completed = ScopedCompletedHistory(db, scope);
|
||||
var allTime = await StatsAsync(completed, cancellationToken);
|
||||
var selected = await StatsAsync(completed.Where(item =>
|
||||
item.ListenedAt >= period.From && item.ListenedAt < period.To), cancellationToken);
|
||||
var recentRecords = await completed.Where(item => item.ListenedAt >= period.From && item.ListenedAt < period.To)
|
||||
var selectedHistory = completed.Where(item =>
|
||||
item.ListenedAt >= period.From && item.ListenedAt < period.To);
|
||||
var selected = await StatsAsync(selectedHistory, cancellationToken);
|
||||
var recentRecords = await selectedHistory
|
||||
.OrderByDescending(item => item.ListenedAt).ThenByDescending(item => item.Id)
|
||||
.Take(10).ToListAsync(cancellationToken);
|
||||
var nowPlaying = await ScopedHistory(db, scope).Where(item =>
|
||||
item.State == ListeningEventState.Playing && item.UpdatedAt >= Now.AddHours(-8))
|
||||
.OrderByDescending(item => item.UpdatedAt).FirstOrDefaultAsync(cancellationToken);
|
||||
var activity = await ActivityAsync(db, scope, period, request.TimeZoneId, cancellationToken);
|
||||
var breakdowns = await BreakdownsAsync(db, scope, period, cancellationToken);
|
||||
var recent = await HistoryItemsAsync(db, scope, recentRecords, cancellationToken);
|
||||
|
||||
return Ok(new
|
||||
@@ -45,6 +47,7 @@ public sealed partial class IntelligenceController
|
||||
selected,
|
||||
currentStreakDays = activity.CurrentStreakDays,
|
||||
longestStreakDays = activity.LongestStreakDays,
|
||||
breakdowns,
|
||||
nowPlaying = nowPlaying == null ? null : HistoryItem(nowPlaying, null, []),
|
||||
recent
|
||||
});
|
||||
@@ -582,6 +585,48 @@ public sealed partial class IntelligenceController
|
||||
.ThenBy(item => item.Artist).ThenBy(item => item.Title).Take(limit).ToListAsync(cancellationToken)
|
||||
};
|
||||
|
||||
private static async Task<ListeningHistoryBreakdowns> BreakdownsAsync(
|
||||
AllstarrDbContext db,
|
||||
IntelligenceScope scope,
|
||||
ListeningHistoryPeriod period,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var from = period.From.UtcTicks;
|
||||
var to = period.To.UtcTicks;
|
||||
var rows = await db.Database.SqlQuery<ListeningHistoryBreakdown>($$"""
|
||||
WITH scoped AS (
|
||||
SELECT "SourceKind", "ProviderId", "ClientClass", "DurationMilliseconds"
|
||||
FROM listening_events
|
||||
WHERE "TenantId" = {{scope.TenantId}} AND "OwnerUserId" = {{scope.OwnerUserId}}
|
||||
AND "Protocol" = {{scope.Protocol}} AND "BackendInstanceId" = {{scope.BackendInstanceId}}
|
||||
AND "LibraryScopeId" = {{scope.LibraryScopeId}} AND "State" = 'Completed'
|
||||
AND "ListenedAt" >= {{from}} AND "ListenedAt" < {{to}}
|
||||
), grouped AS (
|
||||
SELECT 'source'::text AS "Dimension", coalesce(nullif("SourceKind", ''), 'unknown') AS "Value",
|
||||
count(*)::integer AS "ListenCount", coalesce(sum("DurationMilliseconds"), 0)::bigint AS "DurationMilliseconds"
|
||||
FROM scoped GROUP BY 2
|
||||
UNION ALL
|
||||
SELECT 'provider'::text, coalesce(nullif("ProviderId", ''), 'unknown'),
|
||||
count(*)::integer, coalesce(sum("DurationMilliseconds"), 0)::bigint
|
||||
FROM scoped GROUP BY 2
|
||||
UNION ALL
|
||||
SELECT 'client'::text, coalesce(nullif("ClientClass", ''), 'unknown'),
|
||||
count(*)::integer, coalesce(sum("DurationMilliseconds"), 0)::bigint
|
||||
FROM scoped GROUP BY 2
|
||||
), ranked AS (
|
||||
SELECT *, row_number() OVER (PARTITION BY "Dimension" ORDER BY "ListenCount" DESC, "Value") AS position
|
||||
FROM grouped
|
||||
)
|
||||
SELECT "Dimension", "Value", "ListenCount", "DurationMilliseconds"
|
||||
FROM ranked WHERE position <= 8
|
||||
ORDER BY "Dimension", position
|
||||
""").ToListAsync(cancellationToken);
|
||||
return new(
|
||||
rows.Where(item => item.Dimension == "source").ToArray(),
|
||||
rows.Where(item => item.Dimension == "provider").ToArray(),
|
||||
rows.Where(item => item.Dimension == "client").ToArray());
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<ListeningHistoryItem>> HistoryItemsAsync(
|
||||
AllstarrDbContext db,
|
||||
IntelligenceScope scope,
|
||||
@@ -844,6 +889,19 @@ internal sealed class ListeningHistoryTopItem
|
||||
public DateTimeOffset? LastListenedAt { get; set; }
|
||||
}
|
||||
|
||||
internal sealed class ListeningHistoryBreakdown
|
||||
{
|
||||
public string Dimension { get; set; } = "";
|
||||
public string Value { get; set; } = "";
|
||||
public int ListenCount { get; set; }
|
||||
public long DurationMilliseconds { get; set; }
|
||||
}
|
||||
|
||||
internal sealed record ListeningHistoryBreakdowns(
|
||||
IReadOnlyList<ListeningHistoryBreakdown> Sources,
|
||||
IReadOnlyList<ListeningHistoryBreakdown> Providers,
|
||||
IReadOnlyList<ListeningHistoryBreakdown> Clients);
|
||||
|
||||
internal sealed class ListeningHistoryItem
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
@@ -1015,10 +1015,22 @@ export type ListeningHistoryOverview = {
|
||||
selected: ListeningHistoryStats;
|
||||
currentStreakDays: number;
|
||||
longestStreakDays: number;
|
||||
breakdowns: {
|
||||
sources: ListeningHistoryBreakdown[];
|
||||
providers: ListeningHistoryBreakdown[];
|
||||
clients: ListeningHistoryBreakdown[];
|
||||
};
|
||||
nowPlaying?: ListeningHistoryItem | null;
|
||||
recent: ListeningHistoryItem[];
|
||||
};
|
||||
|
||||
export type ListeningHistoryBreakdown = {
|
||||
dimension: string;
|
||||
value: string;
|
||||
listenCount: number;
|
||||
durationMilliseconds: number;
|
||||
};
|
||||
|
||||
export type ListeningHistoryActivity = {
|
||||
period: ListeningHistoryPeriod;
|
||||
currentStreakDays: number;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Dialog } from "$lib/components/ui/dialog";
|
||||
import { Checkbox } from "$lib/components/ui/checkbox";
|
||||
import { Skeleton } from "$lib/components/ui/skeleton";
|
||||
import { Badge } from "$lib/components/ui/badge";
|
||||
import { Button, buttonVariants } from "$lib/components/ui/button";
|
||||
@@ -7,7 +8,6 @@
|
||||
import ConfirmDialog from "$lib/components/ConfirmDialog.svelte";
|
||||
import SearchField from "$lib/components/SearchField.svelte";
|
||||
import SegmentedNav from "$lib/components/SegmentedNav.svelte";
|
||||
import SelectField from "$lib/components/SelectField.svelte";
|
||||
import {
|
||||
intelligence,
|
||||
type IntelligenceScope,
|
||||
@@ -29,13 +29,15 @@
|
||||
sizeBytes: number;
|
||||
error: string;
|
||||
result: ListeningHistoryImport | null;
|
||||
selected: boolean;
|
||||
};
|
||||
|
||||
let { scope, section, policyEnabled = false, retentionDays = 30 }: {
|
||||
let { scope, section, policyEnabled = false, retentionDays = 30, onChanged = () => {} }: {
|
||||
scope: IntelligenceScope;
|
||||
section: HistorySection;
|
||||
policyEnabled?: boolean;
|
||||
retentionDays?: number;
|
||||
onChanged?: () => void | Promise<void>;
|
||||
} = $props();
|
||||
|
||||
let period = $state("30");
|
||||
@@ -52,6 +54,7 @@
|
||||
let activity = $state<ListeningHistoryActivity | null>(null);
|
||||
let top = $state<Record<"artist" | "album" | "track", ListeningHistoryTopItem[]>>({ artist: [], album: [], track: [] });
|
||||
let topKind = $state<"artist" | "album" | "track">("artist");
|
||||
let activityGranularity = $state<"daily" | "monthly">("daily");
|
||||
let items = $state<ListeningHistoryItem[]>([]);
|
||||
let nextCursor = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
@@ -69,6 +72,7 @@
|
||||
let importItems = $state<ImportQueueItem[]>([]);
|
||||
let readingImportId = $state("");
|
||||
let importError = $state("");
|
||||
let importDragging = $state(false);
|
||||
let importSequence = 0;
|
||||
let loadedKey = "";
|
||||
let previousScopeKey = "";
|
||||
@@ -81,8 +85,29 @@
|
||||
const pendingImportItems = $derived(importItems.filter((item) => item.file && !item.result));
|
||||
const completedImportItems = $derived(importItems.filter((item) => item.result));
|
||||
const previewedImportItems = $derived(completedImportItems.filter((item) => item.result?.state === "previewed"));
|
||||
const selectedPreviewItems = $derived(previewedImportItems.filter((item) => item.selected));
|
||||
const activeImportItems = $derived(completedImportItems.filter((item) => item.result?.state === "pending" || item.result?.state === "running"));
|
||||
const hasHistoryFilters = $derived(Boolean(search.trim() || source.trim() || client.trim() || artist.trim() || album.trim() || track.trim()));
|
||||
const monthlyActivity = $derived.by(() => {
|
||||
const months = new Map<string, { date: string; count: number; durationMilliseconds: number }>();
|
||||
for (const bucket of activity?.buckets ?? []) {
|
||||
const date = bucket.date.slice(0, 7);
|
||||
const current = months.get(date) ?? { date, count: 0, durationMilliseconds: 0 };
|
||||
current.count += bucket.count;
|
||||
current.durationMilliseconds += bucket.durationMilliseconds;
|
||||
months.set(date, current);
|
||||
}
|
||||
return [...months.values()].slice(-12);
|
||||
});
|
||||
const importBatch = $derived(previewedImportItems.reduce((summary, item) => {
|
||||
const preview = item.result?.preview;
|
||||
if (preview) {
|
||||
summary.newRows += preview.newRows;
|
||||
summary.duplicates += preview.duplicateExisting + preview.duplicateInFile;
|
||||
summary.skipped += preview.skipped;
|
||||
}
|
||||
return summary;
|
||||
}, { newRows: 0, duplicates: 0, skipped: 0 }));
|
||||
|
||||
$effect(() => {
|
||||
if (scopeKey !== previousScopeKey) {
|
||||
@@ -91,10 +116,10 @@
|
||||
detail = null;
|
||||
importItems = [];
|
||||
}
|
||||
const key = `${scopeKey}\0${period}\0${fromDate}\0${toDate}\0${timeZoneId}`;
|
||||
const key = `${scopeKey}\0${section}\0${period}\0${fromDate}\0${toDate}\0${timeZoneId}`;
|
||||
if (key === loadedKey) return;
|
||||
loadedKey = key;
|
||||
void loadAll();
|
||||
if (section !== "imports") void loadAll();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -218,10 +243,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
function chooseImportFiles(event: Event) {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
function importFileError(file: File) {
|
||||
const extension = file.name.split(".").at(-1)?.toLowerCase();
|
||||
if (!extension || !["json", "jsonl", "zip"].includes(extension)) return "Choose a JSON, JSONL, or ZIP export.";
|
||||
if (file.size > 64 * 1024 * 1024) return "This file is larger than 64 MB.";
|
||||
return "";
|
||||
}
|
||||
|
||||
function queueImportFiles(files: File[]) {
|
||||
const existing = new Set(importItems.map((item) => item.fileKey));
|
||||
const additions = Array.from(input.files ?? []).flatMap((file) => {
|
||||
const additions = files.flatMap((file) => {
|
||||
const fileKey = `${file.name}\0${file.size}\0${file.lastModified}`;
|
||||
if (existing.has(fileKey)) return [];
|
||||
existing.add(fileKey);
|
||||
@@ -231,14 +262,28 @@
|
||||
fileKey,
|
||||
fileName: file.name,
|
||||
sizeBytes: file.size,
|
||||
error: "",
|
||||
error: importFileError(file),
|
||||
result: null,
|
||||
selected: false,
|
||||
}];
|
||||
});
|
||||
importItems = [...importItems, ...additions];
|
||||
const valid = additions.filter((item) => !item.error);
|
||||
if (valid.length) void previewImports(valid);
|
||||
}
|
||||
|
||||
function chooseImportFiles(event: Event) {
|
||||
const input = event.currentTarget as HTMLInputElement;
|
||||
queueImportFiles(Array.from(input.files ?? []));
|
||||
input.value = "";
|
||||
}
|
||||
|
||||
function dropImportFiles(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
importDragging = false;
|
||||
queueImportFiles(Array.from(event.dataTransfer?.files ?? []));
|
||||
}
|
||||
|
||||
function updateImportItem(id: string, changes: Partial<ImportQueueItem>) {
|
||||
importItems = importItems.map((item) => item.id === id ? { ...item, ...changes } : item);
|
||||
}
|
||||
@@ -247,12 +292,11 @@
|
||||
importItems = importItems.filter((item) => item.id !== id);
|
||||
}
|
||||
|
||||
async function previewImports(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
async function previewImports(pending: ImportQueueItem[]) {
|
||||
const requestedScope = { ...scope };
|
||||
const requestedScopeKey = scopeKey;
|
||||
const pending = pendingImportItems.map((item) => ({ ...item }));
|
||||
if (!pending.length) return;
|
||||
pending = pending.map((item) => ({ ...item }));
|
||||
if (!pending.length || action) return;
|
||||
action = "preview-import";
|
||||
importError = "";
|
||||
for (const item of pending) {
|
||||
@@ -260,7 +304,11 @@
|
||||
updateImportItem(item.id, { error: "" });
|
||||
try {
|
||||
const result = await intelligence.previewHistoryImport(requestedScope, item.file!);
|
||||
if (scopeKey === requestedScopeKey) updateImportItem(item.id, { file: null, result });
|
||||
if (scopeKey === requestedScopeKey) updateImportItem(item.id, {
|
||||
file: null,
|
||||
result,
|
||||
selected: result.state === "previewed" && (result.preview?.newRows ?? 0) > 0,
|
||||
});
|
||||
} catch (cause) {
|
||||
if (scopeKey === requestedScopeKey) updateImportItem(item.id, {
|
||||
error: cause instanceof Error ? cause.message : "This history file could not be read.",
|
||||
@@ -278,6 +326,10 @@
|
||||
try {
|
||||
const result = await intelligence.changeHistoryImport(scope, item.result, operation);
|
||||
updateImportItem(item.id, { result });
|
||||
if (result.state === "completed") {
|
||||
await loadAll();
|
||||
await onChanged();
|
||||
}
|
||||
} catch (cause) {
|
||||
importError = cause instanceof Error ? cause.message : "The history import could not be changed.";
|
||||
} finally {
|
||||
@@ -286,18 +338,24 @@
|
||||
}
|
||||
|
||||
async function applyAllPreviews() {
|
||||
const items = previewedImportItems.map((item) => ({ ...item }));
|
||||
const items = selectedPreviewItems.map((item) => ({ ...item }));
|
||||
if (!items.length || action) return;
|
||||
action = "apply-all";
|
||||
importError = "";
|
||||
let completed = false;
|
||||
for (const item of items) {
|
||||
try {
|
||||
const result = await intelligence.changeHistoryImport(scope, item.result!, "apply");
|
||||
updateImportItem(item.id, { result });
|
||||
completed ||= result.state === "completed";
|
||||
} catch (cause) {
|
||||
importError ||= cause instanceof Error ? cause.message : "A history import could not be started.";
|
||||
}
|
||||
}
|
||||
if (completed) {
|
||||
await loadAll();
|
||||
await onChanged();
|
||||
}
|
||||
action = "";
|
||||
}
|
||||
|
||||
@@ -307,7 +365,10 @@
|
||||
const next = await intelligence.historyImport(scope, importId);
|
||||
const completed = next.state === "completed" && previous?.state !== "completed";
|
||||
updateImportItem(queueId, { result: next });
|
||||
if (completed) await loadAll();
|
||||
if (completed) {
|
||||
await loadAll();
|
||||
await onChanged();
|
||||
}
|
||||
} catch (cause) {
|
||||
importError = cause instanceof Error ? cause.message : "Import progress could not be loaded.";
|
||||
}
|
||||
@@ -336,6 +397,12 @@
|
||||
</script>
|
||||
|
||||
<section class="history-workspace">
|
||||
{#if section !== "overview"}
|
||||
<SegmentedNav items={[
|
||||
{ id: "history", label: "Listening history", href: "#/intelligence?section=history" },
|
||||
{ id: "imports", label: "Import history", href: "#/intelligence?section=imports" },
|
||||
]} active={section} label="History and import sections" class="history-mode-tabs" />
|
||||
{/if}
|
||||
{#if section !== "imports"}
|
||||
<header class="history-heading">
|
||||
<div><p class="eyebrow">Your listening</p><h3>{section === "overview" ? "Listening overview" : "Listening history"}</h3><p>Private activity for this account and library only.</p></div>
|
||||
@@ -344,10 +411,10 @@
|
||||
|
||||
<form class="history-toolbar panel" onsubmit={(event) => { event.preventDefault(); void (section === "overview" ? loadAll() : loadHistory()); }}>
|
||||
{#if section === "history"}<SearchField bind:value={search} label="Search listening history" placeholder="Search songs, artists, or albums" />{/if}
|
||||
<SelectField bind:value={period} label="History period" options={[
|
||||
{ value: "30", label: "Last 30 days" }, { value: "90", label: "Last 90 days" },
|
||||
{ value: "365", label: "Last year" }, { value: "custom", label: "Custom dates" },
|
||||
]} />
|
||||
<SegmentedNav items={[
|
||||
{ id: "30", label: "30 days" }, { id: "90", label: "90 days" },
|
||||
{ id: "365", label: "1 year" }, { id: "custom", label: "Custom" },
|
||||
]} active={period} label="History period" class="period-tabs" onchange={(value) => period = value} />
|
||||
<Button type="submit" disabled={loading || historyLoading}>{loading || historyLoading ? "Updating…" : section === "overview" ? "Update overview" : "Apply filters"}</Button>
|
||||
<details>
|
||||
<summary>{section === "overview" ? "Dates and time zone" : "More filters"}</summary>
|
||||
@@ -393,8 +460,8 @@
|
||||
|
||||
<div class="history-insights">
|
||||
<section class="panel activity-card">
|
||||
<header><div><p class="eyebrow">Activity</p><h3>Listening days</h3></div><span>{activity?.buckets.length ?? 0} active days</span></header>
|
||||
{#if activity?.buckets.length}
|
||||
<header><div><p class="eyebrow">Activity</p><h3>Listening rhythm</h3></div><SegmentedNav items={[{ id: "daily", label: "Daily" }, { id: "monthly", label: "Monthly" }]} active={activityGranularity} label="Listening activity period" onchange={(value) => activityGranularity = value as typeof activityGranularity} /></header>
|
||||
{#if activityGranularity === "daily" && activity?.buckets.length}
|
||||
{@const displayedActivity = activity.buckets.slice(-90)}
|
||||
{@const mobileActivity = displayedActivity.slice(-30)}
|
||||
<p class="activity-range">
|
||||
@@ -406,6 +473,11 @@
|
||||
<span style={`--activity:${Math.min(1, .18 + bucket.count / 20)}`} title={`${bucket.date}: ${bucket.count} listens`} aria-label={`${bucket.date}: ${bucket.count} listens`}></span>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if activityGranularity === "monthly" && monthlyActivity.length}
|
||||
{@const largestMonth = Math.max(...monthlyActivity.map((item) => item.count), 1)}
|
||||
<ol class="monthly-activity">
|
||||
{#each monthlyActivity as bucket}<li><span><strong>{new Date(`${bucket.date}-01T00:00:00`).toLocaleDateString(undefined, { month: "short", year: "numeric" })}</strong><small>{listeningTime(bucket.durationMilliseconds)}</small></span><meter min="0" max={largestMonth} value={bucket.count}>{bucket.count}</meter><span>{bucket.count} listens</span></li>{/each}
|
||||
</ol>
|
||||
{:else}<p class="muted">No activity in this period. Play music or import a history file to begin.</p>{/if}
|
||||
</section>
|
||||
|
||||
@@ -414,6 +486,16 @@
|
||||
<ol>{#each selectedTop.slice(0, 5) as item}<li><span><strong>{topKind === "artist" ? item.artist : topKind === "album" ? item.album : item.title}</strong>{#if topKind !== "artist"}<small>{item.artist}</small>{/if}</span><span>{item.listenCount} listens</span></li>{:else}<li class="muted">Nothing to rank yet. Play music or import a history file to begin.</li>{/each}</ol>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="history-breakdowns">
|
||||
{#each [
|
||||
{ label: "Sources", items: overview.breakdowns?.sources ?? [] },
|
||||
{ label: "Providers", items: overview.breakdowns?.providers ?? [] },
|
||||
{ label: "Listening apps", items: overview.breakdowns?.clients ?? [] },
|
||||
] as group}
|
||||
<section class="panel breakdown-card"><p class="eyebrow">Listening mix</p><h3>{group.label}</h3><ol>{#each group.items.slice(0, 5) as item}<li><span><strong>{words(item.value)}</strong><small>{listeningTime(item.durationMilliseconds)}</small></span><span>{item.listenCount} listens</span></li>{:else}<li class="muted">No {group.label.toLowerCase()} recorded.</li>{/each}</ol></section>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if (section === "overview" || section === "history") && !loading}
|
||||
@@ -430,7 +512,7 @@
|
||||
</button></li>
|
||||
{:else}
|
||||
<li>{#if section === "history" && hasHistoryFilters}<div class="compact-empty"><strong>No listens match these filters</strong><p>Try a wider period or clear a filter.</p></div>
|
||||
{:else}<div class="compact-empty"><strong>No completed listens yet</strong><p>Turn on automatic history in Settings, then play music or import a history file.</p></div>{/if}</li>
|
||||
{:else}<div class="compact-empty"><strong>No completed listens yet</strong><p>Turn on automatic history in Automation, then play music or import a history file.</p></div>{/if}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if section === "history" && nextCursor}<Button class="load-more" variant="secondary" disabled={historyLoading} onclick={() => void loadHistory(false)}>{historyLoading ? "Loading…" : "Load older listens"}</Button>{/if}
|
||||
@@ -443,13 +525,13 @@
|
||||
<div class:warning={!policyEnabled} class="import-readiness">
|
||||
<Badge state={policyEnabled ? "healthy" : "suggested"}>{policyEnabled ? "Saving on" : "Saving off"}</Badge>
|
||||
<span><strong>{policyEnabled ? `Keeping ${retentionLabel(retentionDays)}` : "Recommendations are off"}</strong><small>{policyEnabled ? `Imported listens older than ${retentionLabel(retentionDays)} are removed automatically.` : `You can preview files, but turn on automatic history and choose retention before adding them.`}</small></span>
|
||||
<Button variant="secondary" href="#/intelligence?section=settings">Review settings</Button>
|
||||
<Button variant="secondary" href="#/intelligence?section=automation">Review automation</Button>
|
||||
</div>
|
||||
<form class="import-picker" onsubmit={previewImports}>
|
||||
<label class="upload-zone">
|
||||
<input aria-label="History export files" type="file" accept="application/json,text/plain,application/zip,.json,.jsonl,.zip" multiple onchange={chooseImportFiles} />
|
||||
<div class="import-picker">
|
||||
<label class="upload-zone" class:dragging={importDragging} ondragenter={(event) => { event.preventDefault(); importDragging = true; }} ondragover={(event) => event.preventDefault()} ondragleave={() => { importDragging = false; }} ondrop={dropImportFiles}>
|
||||
<input aria-label="History export files" type="file" accept="application/json,text/plain,application/zip,.json,.jsonl,.zip" multiple disabled={Boolean(action)} onchange={chooseImportFiles} />
|
||||
<span class="upload-symbol" aria-hidden="true"><FileUp size={22} /></span>
|
||||
<span class="upload-copy"><strong>Choose history exports</strong><small>JSON, JSONL, or ZIP · up to 64 MB per file</small></span>
|
||||
<span class="upload-copy"><strong>Drop history exports here</strong><small>Spotify, Last.fm, ListenBrainz, Koito, or Maloja · JSON, JSONL, or ZIP · 64 MB each</small></span>
|
||||
<span class="upload-browse" aria-hidden="true">Browse files</span>
|
||||
</label>
|
||||
{#if pendingImportItems.length}
|
||||
@@ -463,18 +545,18 @@
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if pendingImportItems.length}<Button type="submit" disabled={Boolean(action)}>{action === "preview-import" ? `Reading ${pendingImportItems.length} ${pendingImportItems.length === 1 ? "file" : "files"}…` : `Preview ${pendingImportItems.length} ${pendingImportItems.length === 1 ? "file" : "files"}`}</Button>{/if}
|
||||
</form>
|
||||
</div>
|
||||
{#if completedImportItems.length}
|
||||
{#if previewedImportItems.length > 1}
|
||||
<div class="import-batch-actions"><span><strong>{previewedImportItems.length} files are ready</strong><small>Add every previewed file without opening them one by one.</small></span><Button disabled={Boolean(action)} onclick={() => void applyAllPreviews()}>{action === "apply-all" ? "Starting imports…" : `Add all ${previewedImportItems.length} files`}</Button></div>
|
||||
{#if previewedImportItems.length}
|
||||
<dl class="import-batch-summary"><div><dd>{importBatch.newRows.toLocaleString()}</dd><dt>new listens</dt></div><div><dd>{importBatch.duplicates.toLocaleString()}</dd><dt>already present</dt></div><div><dd>{importBatch.skipped.toLocaleString()}</dd><dt>skipped</dt></div></dl>
|
||||
<div class="import-batch-actions"><span><strong>{selectedPreviewItems.length} of {previewedImportItems.length} ready files selected</strong><small>Files with new listens are selected automatically. Clear any file you do not want to add.</small></span><Button disabled={Boolean(action) || !selectedPreviewItems.length} onclick={() => void applyAllPreviews()}>{action === "apply-all" ? "Starting imports…" : `Add all ${selectedPreviewItems.length} ready ${selectedPreviewItems.length === 1 ? "file" : "files"}`}</Button></div>
|
||||
{/if}
|
||||
<ul class="import-results" aria-label="History import previews">
|
||||
{#each completedImportItems as item}
|
||||
{@const result = item.result!}
|
||||
{@const activeImport = result.state === "pending" || result.state === "running"}
|
||||
<li class="import-result">
|
||||
<header><span role="status" aria-atomic="true"><strong>{result.displayFileName ?? item.fileName}</strong><small>{fileSize(result.sizeBytes ?? item.sizeBytes)} · {words(result.state)}{result.importedRows !== undefined ? ` · ${result.importedRows.toLocaleString()} added` : ""}</small></span><Badge state={result.state === "completed" ? "healthy" : result.state === "failed" || result.state === "cancelled" ? "rejected" : "suggested"}>{words(result.state)}</Badge></header>
|
||||
<header><label class="import-result-select"><Checkbox aria-label={`Include ${result.displayFileName ?? item.fileName}`} disabled={result.state !== "previewed"} checked={item.selected} onCheckedChange={(checked) => updateImportItem(item.id, { selected: checked })} /><span role="status" aria-atomic="true"><strong>{result.displayFileName ?? item.fileName}</strong><small>{fileSize(result.sizeBytes ?? item.sizeBytes)} · {words(result.state)}{result.importedRows !== undefined ? ` · ${result.importedRows.toLocaleString()} added` : ""}</small></span></label><Badge state={result.state === "completed" ? "healthy" : result.state === "failed" || result.state === "cancelled" ? "rejected" : "suggested"}>{words(result.state)}</Badge></header>
|
||||
{#if result.preview}
|
||||
<dl class="import-preview"><div><dd>{result.preview.newRows.toLocaleString()}</dd><dt>new listens</dt></div><div><dd>{(result.preview.duplicateExisting + result.preview.duplicateInFile).toLocaleString()}</dd><dt>already present</dt></div><div><dd>{result.preview.skipped.toLocaleString()}</dd><dt>skipped</dt></div><div><dd>{result.preview.estimatedMusicBrainzLookups.toLocaleString()}</dd><dt>songs to identify</dt></div></dl>
|
||||
<p class="credential-safety">Allstarr will add these past listens to your private history. It will not send them to Last.fm or ListenBrainz.</p>
|
||||
@@ -517,8 +599,8 @@
|
||||
|
||||
<style>
|
||||
.history-workspace{min-width:0}
|
||||
.history-workspace{display:grid;gap:1rem}.history-heading,.history-list-card>header,.activity-card>header,.top-card>header,.import-card>header{display:flex;align-items:start;justify-content:space-between;gap:1rem}.history-heading h3,.history-list-card h3,.activity-card h3,.top-card h3,.import-card h3{margin:.2rem 0}.history-heading p:last-child,.import-card>header p:last-child{max-width:70ch;margin:0;color:var(--color-ink-muted)}.history-toolbar{display:grid;grid-template-columns:minmax(16rem,1fr) minmax(11rem,.35fr) auto;align-items:end;gap:.75rem;padding:1rem}.history-toolbar details{grid-column:1/-1}.history-toolbar summary{cursor:pointer}.advanced-filters{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.75rem;margin-top:.75rem}.history-stats{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:.75rem}.history-stats article{display:grid;gap:.2rem;padding:1rem}.history-stats strong{font-family:var(--font-display);font-size:1.35rem}.history-stats span{color:var(--color-ink-muted);font-size:.78rem}.now-playing{display:flex;align-items:center;gap:.8rem;width:100%;padding:1rem;text-align:left}.now-playing>span:first-child{display:grid;place-items:center;width:2.5rem;height:2.5rem;border-radius:50%;background:var(--color-signal);color:var(--color-canvas)}.now-playing small,.now-playing strong{display:block}.recap-card{display:grid;gap:.65rem;padding:1.15rem}.recap-card h3,.recap-card p{margin:0}.history-insights{display:grid;grid-template-columns:1fr 1fr;gap:1rem}.activity-card,.top-card,.history-list-card,.import-card{padding:1.15rem}.activity-range{margin:.75rem 0 0;color:var(--color-ink-muted);font-size:.78rem}.activity-range-mobile{display:none}.activity-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(13px,1fr));gap:.3rem;margin-top:.5rem}.activity-grid span{aspect-ratio:1;border-radius:3px;background:color-mix(in srgb,var(--color-signal) calc(var(--activity) * 100%),var(--color-panel-raised))}.top-card ol{margin:.75rem 0 0;padding:0;list-style:none}.top-card li{display:flex;justify-content:space-between;gap:1rem;border-top:1px solid var(--color-edge);padding:.65rem 0}.top-card li span:first-child strong,.top-card li span:first-child small{display:block}.top-card li small,.top-card li>span:last-child{color:var(--color-ink-muted)}.history-list{display:grid;margin:.6rem 0 0;padding:0;list-style:none}.history-list>li>button{display:grid;grid-template-columns:auto minmax(0,1fr) minmax(10rem,.45fr) auto;align-items:center;gap:.85rem;width:100%;border-top:1px solid var(--color-edge);padding:.8rem 0;text-align:left}.history-copy strong,.history-copy small,.history-route strong,.history-route small{display:block}.history-copy small,.history-route small{color:var(--color-ink-muted)}.history-route{text-align:right}:global(.load-more){display:block;margin:1rem auto 0}.import-card{display:grid;gap:1.25rem}.import-readiness,.import-batch-actions{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.8rem;border:1px solid var(--color-edge);border-radius:var(--radius-md);background:var(--color-panel-raised);padding:.8rem}.import-readiness.warning{border-color:color-mix(in srgb,var(--color-warning) 40%,var(--color-edge))}.import-readiness>span:nth-child(2),.import-batch-actions>span{display:grid;gap:.15rem}.import-readiness small,.import-batch-actions small{color:var(--color-ink-muted)}.import-picker{display:grid;gap:.75rem}.upload-zone{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:1rem;min-height:7rem;border:1px dashed color-mix(in srgb,var(--color-signal) 55%,var(--color-edge));border-radius:var(--radius-lg);background:color-mix(in srgb,var(--color-signal) 4%,var(--color-panel-raised));padding:1rem;cursor:pointer}.upload-zone:hover{background:color-mix(in srgb,var(--color-signal) 7%,var(--color-panel-raised))}.upload-zone:focus-within{outline:2px solid var(--focus-ring);outline-offset:2px}.upload-zone input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer}.upload-symbol,.file-marker{display:grid;place-items:center;border-radius:var(--radius-md);background:color-mix(in srgb,var(--color-signal) 12%,transparent);color:var(--color-signal-text)}.upload-symbol{width:3rem;height:3rem}.upload-copy,.file-copy{display:grid;min-width:0;gap:.2rem}.upload-copy strong{font-family:var(--font-display);font-size:1rem}.upload-copy small,.file-copy small,.import-result header small{color:var(--color-ink-muted)}.upload-browse{border:1px solid var(--color-edge);border-radius:var(--radius-md);background:var(--color-panel-raised);padding:.65rem .9rem;font-size:var(--text-sm);font-weight:700}.upload-queue,.import-results{display:grid;margin:0;padding:0;list-style:none}.upload-queue{gap:.4rem}.upload-queue li{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.75rem;min-height:3.5rem;border-radius:var(--radius-md);background:var(--color-panel-raised);padding:.55rem .65rem}.upload-queue li.error{background:rgb(255 107 122 / 8%)}.file-marker{width:2rem;height:2rem}.file-copy strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.upload-queue .icon-button{width:var(--control-sm);height:var(--control-sm)}.import-picker>:global([data-slot="button"]){justify-self:end}.import-results{gap:1.25rem}.import-result{display:grid;gap:.85rem;border-top:1px solid var(--color-edge);padding-top:1.25rem}.import-result>header{display:flex;align-items:start;justify-content:space-between;gap:1rem}.import-result>header>span:first-child>*{display:block}.import-preview{display:grid;grid-template-columns:repeat(4,1fr);margin:0;border-block:1px solid var(--color-edge)}.import-preview div{display:flex;flex-direction:column-reverse;gap:.15rem;padding:.75rem}.import-preview div+div{border-left:1px solid var(--color-edge)}.import-preview dd{margin:0;font-family:var(--font-display);font-size:1.2rem;font-weight:750}.import-preview dt{color:var(--color-ink-muted);font-size:.76rem}.credential-safety{margin:0;color:var(--color-ink-muted);font-size:var(--text-sm)}.import-result>footer{display:flex;justify-content:flex-end;gap:.5rem}
|
||||
.history-workspace{display:grid;gap:1rem}.history-heading,.history-list-card>header,.activity-card>header,.top-card>header,.import-card>header{display:flex;align-items:start;justify-content:space-between;gap:1rem}.history-heading h3,.history-list-card h3,.activity-card h3,.top-card h3,.import-card h3{margin:.2rem 0}.history-heading p:last-child,.import-card>header p:last-child{max-width:70ch;margin:0;color:var(--color-ink-muted)}.history-toolbar{display:grid;grid-template-columns:minmax(16rem,1fr) auto auto;align-items:end;gap:.75rem;padding:1rem}.history-toolbar :global(.period-tabs){width:22rem}.history-toolbar details{grid-column:1/-1}.history-toolbar summary{cursor:pointer}.advanced-filters{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.75rem;margin-top:.75rem}.history-stats{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:.75rem}.history-stats article{display:grid;gap:.2rem;padding:1rem}.history-stats strong{font-family:var(--font-display);font-size:1.35rem}.history-stats span{color:var(--color-ink-muted);font-size:.78rem}.now-playing{display:flex;align-items:center;gap:.8rem;width:100%;padding:1rem;text-align:left}.now-playing>span:first-child{display:grid;place-items:center;width:2.5rem;height:2.5rem;border-radius:50%;background:var(--color-signal);color:var(--color-canvas)}.now-playing small,.now-playing strong{display:block}.recap-card{display:grid;gap:.65rem;padding:1.15rem}.recap-card h3,.recap-card p{margin:0}.history-insights{display:grid;grid-template-columns:1fr 1fr;gap:1rem}.activity-card,.top-card,.history-list-card,.import-card{padding:1.15rem}.activity-range{margin:.75rem 0 0;color:var(--color-ink-muted);font-size:.78rem}.activity-range-mobile{display:none}.activity-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(13px,1fr));gap:.3rem;margin-top:.5rem}.activity-grid span{aspect-ratio:1;border-radius:3px;background:color-mix(in srgb,var(--color-signal) calc(var(--activity) * 100%),var(--color-panel-raised))}.monthly-activity{display:grid;margin:.75rem 0 0;padding:0;list-style:none}.monthly-activity li{display:grid;grid-template-columns:minmax(7rem,.5fr) minmax(8rem,1fr) auto;align-items:center;gap:.75rem;border-top:1px solid var(--color-edge);padding:.65rem 0}.monthly-activity strong,.monthly-activity small{display:block}.monthly-activity small,.monthly-activity li>span:last-child{color:var(--color-ink-muted)}.monthly-activity meter{width:100%;accent-color:var(--color-signal)}.top-card ol,.breakdown-card ol{margin:.75rem 0 0;padding:0;list-style:none}.top-card li,.breakdown-card li{display:flex;justify-content:space-between;gap:1rem;border-top:1px solid var(--color-edge);padding:.65rem 0}.top-card li span:first-child strong,.top-card li span:first-child small,.breakdown-card li span:first-child strong,.breakdown-card li span:first-child small{display:block}.top-card li small,.top-card li>span:last-child,.breakdown-card li small,.breakdown-card li>span:last-child{color:var(--color-ink-muted)}.history-breakdowns{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:1rem}.breakdown-card{padding:1.15rem}.breakdown-card h3{margin:.2rem 0}.history-list{display:grid;margin:.6rem 0 0;padding:0;list-style:none}.history-list>li>button{display:grid;grid-template-columns:auto minmax(0,1fr) minmax(10rem,.45fr) auto;align-items:center;gap:.85rem;width:100%;border-top:1px solid var(--color-edge);padding:.8rem 0;text-align:left}.history-copy strong,.history-copy small,.history-route strong,.history-route small{display:block}.history-copy small,.history-route small{color:var(--color-ink-muted)}.history-route{text-align:right}:global(.load-more){display:block;margin:1rem auto 0}.import-card{display:grid;gap:1.25rem}.import-readiness,.import-batch-actions{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.8rem;border:1px solid var(--color-edge);border-radius:var(--radius-md);background:var(--color-panel-raised);padding:.8rem}.import-readiness.warning{border-color:color-mix(in srgb,var(--color-warning) 40%,var(--color-edge))}.import-readiness>span:nth-child(2),.import-batch-actions>span{display:grid;gap:.15rem}.import-readiness small,.import-batch-actions small{color:var(--color-ink-muted)}.import-picker{display:grid;gap:.75rem}.upload-zone{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:1rem;min-height:7rem;border:1px dashed color-mix(in srgb,var(--color-signal) 55%,var(--color-edge));border-radius:var(--radius-lg);background:color-mix(in srgb,var(--color-signal) 4%,var(--color-panel-raised));padding:1rem;cursor:pointer}.upload-zone:hover,.upload-zone.dragging{border-color:var(--color-signal);background:color-mix(in srgb,var(--color-signal) 9%,var(--color-panel-raised))}.upload-zone:focus-within{outline:2px solid var(--focus-ring);outline-offset:2px}.upload-zone input{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer}.upload-symbol,.file-marker{display:grid;place-items:center;border-radius:var(--radius-md);background:color-mix(in srgb,var(--color-signal) 12%,transparent);color:var(--color-signal-text)}.upload-symbol{width:3rem;height:3rem}.upload-copy,.file-copy{display:grid;min-width:0;gap:.2rem}.upload-copy strong{font-family:var(--font-display);font-size:1rem}.upload-copy small,.file-copy small,.import-result header small{color:var(--color-ink-muted)}.upload-browse{border:1px solid var(--color-edge);border-radius:var(--radius-md);background:var(--color-panel-raised);padding:.65rem .9rem;font-size:var(--text-sm);font-weight:700}.upload-queue,.import-results{display:grid;margin:0;padding:0;list-style:none}.upload-queue{gap:.4rem}.upload-queue li{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:.75rem;min-height:3.5rem;border-radius:var(--radius-md);background:var(--color-panel-raised);padding:.55rem .65rem}.upload-queue li.error{background:rgb(255 107 122 / 8%)}.file-marker{width:2rem;height:2rem}.file-copy strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.upload-queue .icon-button{width:var(--control-sm);height:var(--control-sm)}.import-batch-summary{display:grid;grid-template-columns:repeat(3,1fr);margin:0;border:1px solid var(--color-edge);border-radius:var(--radius-md)}.import-batch-summary div{display:flex;flex-direction:column-reverse;padding:.75rem}.import-batch-summary div+div{border-left:1px solid var(--color-edge)}.import-batch-summary dd{margin:0;font-family:var(--font-display);font-size:1.2rem;font-weight:750}.import-batch-summary dt{color:var(--color-ink-muted);font-size:.76rem}.import-results{gap:1.25rem}.import-result{display:grid;gap:.85rem;border-top:1px solid var(--color-edge);padding-top:1.25rem}.import-result>header{display:flex;align-items:start;justify-content:space-between;gap:1rem}.import-result-select{display:flex;align-items:start;gap:.65rem}.import-result-select span>*{display:block}.import-preview{display:grid;grid-template-columns:repeat(4,1fr);margin:0;border-block:1px solid var(--color-edge)}.import-preview div{display:flex;flex-direction:column-reverse;gap:.15rem;padding:.75rem}.import-preview div+div{border-left:1px solid var(--color-edge)}.import-preview dd{margin:0;font-family:var(--font-display);font-size:1.2rem;font-weight:750}.import-preview dt{color:var(--color-ink-muted);font-size:.76rem}.credential-safety{margin:0;color:var(--color-ink-muted);font-size:var(--text-sm)}.import-result>footer{display:flex;justify-content:flex-end;gap:.5rem}
|
||||
:global(.history-detail-dialog){display:grid;grid-template-rows:auto minmax(0,1fr);width:min(44rem,calc(100vw - 2rem));max-height:min(48rem,calc(100dvh - 2rem));overflow:hidden}.history-detail-form{display:grid;grid-template-columns:1fr 1fr;gap:1rem;overflow:auto;padding:1rem}.history-detail-form dl,.target-statuses,.history-detail-form footer{grid-column:1/-1}.history-detail-form dl{display:grid;grid-template-columns:repeat(3,1fr);gap:.75rem;margin:0}.history-detail-form dl div{border:1px solid var(--color-edge);border-radius:var(--radius-md);padding:.7rem}.history-detail-form dt{color:var(--color-ink-muted);font-size:.72rem}.history-detail-form dd{margin:.2rem 0 0}.target-statuses ul{margin:0;padding:0;list-style:none}.target-statuses li{display:flex;justify-content:space-between;gap:1rem}.target-statuses small{color:var(--color-ink-muted)}.history-detail-form footer{display:flex;justify-content:space-between;gap:1rem;border-top:1px solid var(--color-edge);padding-top:1rem}.history-detail-form footer>span{display:flex;gap:.5rem}
|
||||
@media(max-width:900px){.history-stats{grid-template-columns:repeat(3,1fr)}.advanced-filters{grid-template-columns:1fr 1fr}.history-list>li>button{grid-template-columns:auto minmax(0,1fr) auto}.history-route{grid-column:2;text-align:left}.import-preview{grid-template-columns:1fr 1fr}}
|
||||
@media(max-width:620px){.history-heading{flex-direction:column}.history-actions,.history-actions>:global([data-slot="button"]){width:100%}.history-toolbar{grid-template-columns:1fr}.history-toolbar details{grid-column:auto}.advanced-filters,.history-insights,.history-detail-form,.history-detail-form dl{grid-template-columns:1fr}.history-stats{grid-template-columns:1fr 1fr}.history-stats article:last-child{grid-column:1/-1}.history-list>li>button{grid-template-columns:auto minmax(0,1fr)}.history-list>li>button>:global(.badge){grid-column:2;justify-self:start}.history-route{grid-column:2}.activity-card>header,.top-card>header,.history-detail-form footer{align-items:stretch;flex-direction:column}.activity-range-wide,.activity-grid span:nth-last-child(n+31){display:none}.activity-range-mobile{display:inline}.top-card>header{display:grid}.import-readiness,.import-batch-actions,.upload-zone{grid-template-columns:auto minmax(0,1fr)}.import-readiness>:global([data-slot="button"]),.import-batch-actions>:global([data-slot="button"]),.upload-browse{grid-column:1/-1;width:100%;text-align:center}.import-picker>:global([data-slot="button"]),.import-result>footer,.import-result>footer>:global([data-slot="button"]){width:100%}.import-preview{grid-template-columns:1fr 1fr}.import-preview div:nth-child(3){border-left:0}.import-preview div:nth-child(n+3){border-top:1px solid var(--color-edge)}.import-result>header{align-items:start}.import-result>footer{display:grid}.history-detail-form dl,.target-statuses,.history-detail-form footer{grid-column:auto}.history-detail-form footer>span{display:grid}.history-detail-form footer :global(button){width:100%}}
|
||||
@media(max-width:900px){.history-toolbar{grid-template-columns:1fr auto}.history-toolbar>:global(nav[aria-label="History period"]){grid-column:1/-1;grid-row:2}.history-stats{grid-template-columns:repeat(3,1fr)}.history-breakdowns{grid-template-columns:1fr 1fr}.advanced-filters{grid-template-columns:1fr 1fr}.history-list>li>button{grid-template-columns:auto minmax(0,1fr) auto}.history-route{grid-column:2;text-align:left}.import-preview{grid-template-columns:1fr 1fr}}
|
||||
@media(max-width:620px){.history-heading{flex-direction:column}.history-actions,.history-actions>:global([data-slot="button"]){width:100%}.history-toolbar{grid-template-columns:1fr}.history-toolbar>:global(nav[aria-label="History period"]){grid-column:auto;grid-row:auto}.history-toolbar :global(.period-tabs){width:100%}.history-toolbar details{grid-column:auto}.advanced-filters,.history-insights,.history-breakdowns,.history-detail-form,.history-detail-form dl{grid-template-columns:1fr}.history-stats{grid-template-columns:1fr 1fr}.history-stats article:last-child{grid-column:1/-1}.history-list>li>button{grid-template-columns:auto minmax(0,1fr)}.history-list>li>button>:global(.badge){grid-column:2;justify-self:start}.history-route{grid-column:2}.activity-card>header,.top-card>header,.history-detail-form footer{align-items:stretch;flex-direction:column}.activity-range-wide,.activity-grid span:nth-last-child(n+31){display:none}.activity-range-mobile{display:inline}.top-card>header{display:grid}.monthly-activity li{grid-template-columns:minmax(0,1fr) auto}.monthly-activity meter{grid-column:1/-1;grid-row:2}.import-readiness,.import-batch-actions,.upload-zone{grid-template-columns:auto minmax(0,1fr)}.import-readiness>:global([data-slot="button"]),.import-batch-actions>:global([data-slot="button"]),.upload-browse{grid-column:1/-1;width:100%;text-align:center}.import-batch-summary{grid-template-columns:1fr}.import-batch-summary div+div{border-top:1px solid var(--color-edge);border-left:0}.import-result>footer,.import-result>footer>:global([data-slot="button"]){width:100%}.import-preview{grid-template-columns:1fr 1fr}.import-preview div:nth-child(3){border-left:0}.import-preview div:nth-child(n+3){border-top:1px solid var(--color-edge)}.import-result>header{align-items:start}.import-result>footer{display:grid}.history-detail-form dl,.target-statuses,.history-detail-form footer{grid-column:auto}.history-detail-form footer>span{display:grid}.history-detail-form footer :global(button){width:100%}}
|
||||
</style>
|
||||
@@ -16,7 +16,7 @@
|
||||
import SelectField from "$lib/components/SelectField.svelte";
|
||||
import { home, intelligence, playlistLinks, type IntelligenceScope, type IntelligenceState, type MediaTarget } from "$lib/api";
|
||||
|
||||
type IntelligenceSection = "overview" | "history" | "recommendations" | "imports" | "settings";
|
||||
type IntelligenceSection = "overview" | "history" | "discover" | "automation";
|
||||
|
||||
let { initialSection = "overview" }: { initialSection?: string } = $props();
|
||||
let protocol = $state("jellyfin");
|
||||
@@ -39,10 +39,12 @@
|
||||
let loadedScope = $state<IntelligenceScope | null>(null);
|
||||
|
||||
const activeSection = $derived<IntelligenceSection>(
|
||||
initialSection === "history" || initialSection === "recommendations" || initialSection === "imports" || initialSection === "settings"
|
||||
? initialSection
|
||||
: "overview",
|
||||
initialSection === "history" || initialSection === "imports" ? "history"
|
||||
: initialSection === "discover" || initialSection === "recommendations" ? "discover"
|
||||
: initialSection === "automation" || initialSection === "settings" ? "automation"
|
||||
: "overview",
|
||||
);
|
||||
const historySection = $derived(initialSection === "imports" ? "imports" : activeSection === "overview" ? "overview" : "history");
|
||||
const scope = $derived<IntelligenceScope>({ protocol, backendInstanceId, libraryScopeId });
|
||||
const activeScope = $derived(loadedScope ?? scope);
|
||||
const visibleCandidates = $derived(data?.candidates.filter((item) => !item.exclusions.length) ?? []);
|
||||
@@ -57,6 +59,8 @@
|
||||
.filter((item) => item.protocol === activeScope.protocol && item.backendInstanceId === activeScope.backendInstanceId &&
|
||||
(!item.libraryScopeId || item.libraryScopeId === activeScope.libraryScopeId) && item.credentialReferenceId)
|
||||
.map((item) => ({ value: item.credentialReferenceId!, label: item.displayName })));
|
||||
const nextSchedule = $derived(data?.schedules.filter((item) => item.enabled && item.nextRunAt)
|
||||
.sort((left, right) => new Date(left.nextRunAt!).getTime() - new Date(right.nextRunAt!).getTime())[0]);
|
||||
|
||||
$effect(() => {
|
||||
if (!["pending", "running", "retry scheduled"].includes(runState ?? "")) return;
|
||||
@@ -208,7 +212,7 @@
|
||||
<h2>Listen deeper, without exposing your history.</h2>
|
||||
<p>Recommendations, listening history, and generated playlists stay private to this account and music library.</p>
|
||||
</div>
|
||||
{#if data?.actions.canRun && activeSection === "recommendations"}
|
||||
{#if data?.actions.canRun && activeSection === "discover"}
|
||||
<div class="heading-actions">
|
||||
{#if runStatus}<Badge state={runState === "succeeded" ? "healthy" : "suggested"}>{runStatus}</Badge>{/if}
|
||||
<Button disabled={Boolean(action)}
|
||||
@@ -262,7 +266,7 @@
|
||||
{#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}
|
||||
{#if activeSection === "recommendations" && data.actions.progress}
|
||||
{#if activeSection === "discover" && data.actions.progress}
|
||||
<section class="panel run-progress" role="status">
|
||||
<div><p class="eyebrow">Refreshing recommendations</p><strong>{data.actions.progress.message}</strong>
|
||||
{#if data.actions.progress.provider || data.actions.progress.playlist || data.actions.progress.track}<small>{[data.actions.progress.provider, data.actions.progress.playlist, data.actions.progress.track].filter(Boolean).join(" · ")}</small>{/if}
|
||||
@@ -274,16 +278,24 @@
|
||||
{/if}
|
||||
<SegmentedNav items={[
|
||||
{ id: "overview", label: "Overview", href: "#/intelligence?section=overview" },
|
||||
{ id: "history", label: "History", href: "#/intelligence?section=history" },
|
||||
{ id: "recommendations", label: "Recommendations", href: "#/intelligence?section=recommendations" },
|
||||
{ id: "imports", label: "Imports", href: "#/intelligence?section=imports" },
|
||||
{ id: "settings", label: "Settings", href: "#/intelligence?section=settings" },
|
||||
{ id: "history", label: "History & Imports", href: "#/intelligence?section=history" },
|
||||
{ id: "discover", label: "Discover", href: "#/intelligence?section=discover" },
|
||||
{ id: "automation", label: "Automation", href: "#/intelligence?section=automation" },
|
||||
]} active={activeSection} label="Intelligence sections" class="intelligence-tabs" />
|
||||
|
||||
{#if activeSection === "overview" || activeSection === "history" || activeSection === "imports"}
|
||||
<IntelligenceHistory scope={activeScope} section={activeSection} policyEnabled={Boolean(data.policy?.enabled)} retentionDays={data.policy?.retentionDays ?? 30} />
|
||||
{:else if activeSection === "settings"}
|
||||
{#if activeSection === "overview" || activeSection === "history"}
|
||||
<IntelligenceHistory scope={activeScope} section={historySection} policyEnabled={Boolean(data.policy?.enabled)} retentionDays={data.policy?.retentionDays ?? 30} onChanged={refresh} />
|
||||
{:else if activeSection === "automation"}
|
||||
<div class="settings-stack">
|
||||
<section class="panel automation-summary">
|
||||
<header><div><p class="eyebrow">Recommendation engine</p><h3>Automation</h3><p>Control saved listening, recommendation runs, generated playlists, and schedules in one place.</p></div>{#if runStatus}<Badge state={runState === "succeeded" ? "healthy" : "suggested"}>{runStatus}</Badge>{/if}</header>
|
||||
<dl>
|
||||
<div><dt>Last recommendation run</dt><dd>{runStatus ?? "Not run yet"}</dd></div>
|
||||
<div><dt>Next scheduled playlist</dt><dd>{nextSchedule?.nextRunAt ? new Date(nextSchedule.nextRunAt).toLocaleString() : "No run scheduled"}</dd></div>
|
||||
<div><dt>Generated playlists</dt><dd>{data.generatedSets.length}</dd></div>
|
||||
</dl>
|
||||
{#if data.actions.canRun}<Button disabled={Boolean(action)} onclick={() => void perform("run", () => intelligence.run(activeScope))}>{action === "run" ? "Starting…" : "Run recommendations now"}</Button>{/if}
|
||||
</section>
|
||||
<section class="panel privacy-card">
|
||||
<header><div><p class="eyebrow">Control</p><h3>Privacy and sources</h3></div><Badge state={enabled ? "healthy" : "suggested"}>{enabled ? "Enabled" : "Off"}</Badge></header>
|
||||
<form onsubmit={(event) => {
|
||||
@@ -339,13 +351,13 @@
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{:else if !data.policy?.enabled}<div class="compact-empty"><strong>Recommendations are off</strong><p><a class="touch-link" href="#/intelligence?section=settings">Turn on automatic history</a>, then import retained history or complete a play.</p></div>
|
||||
{:else if !data.policy?.enabled}<div class="compact-empty"><strong>Recommendations are off</strong><p><a class="touch-link" href="#/intelligence?section=automation">Turn on automatic history</a>, then import retained history or complete a play.</p></div>
|
||||
{:else if readyRecommendationSources}<div class="compact-empty"><strong>No recommendations yet</strong><p>Your sources are ready. Complete a play or import history inside the retention window, then refresh recommendations.</p></div>
|
||||
{:else}<div class="compact-empty"><strong>No recommendation sources are ready</strong><p><a class="touch-link" href="#/integrations/services">Connect or configure a source</a>, then 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}<ul class="profile-list">{#each data.visualization as item}<li><span>{item.label}</span><meter aria-label={item.label} min="0" max="1" value={item.value}>{item.value}</meter></li>{/each}</ul>{:else}<p class="muted">Turn on automatic history in <a class="touch-link" href="#/intelligence?section=settings">Settings</a>, then play music or import a history file.</p>{/if}</section>
|
||||
<section class="panel profile-card"><p class="eyebrow">Recent listening</p><h3>Your profile</h3>{#if data.visualization.length}<ul class="profile-list">{#each data.visualization as item}<li><span>{item.label}</span><meter aria-label={item.label} min="0" max="1" value={item.value}>{item.value}</meter></li>{/each}</ul>{:else}<p class="muted">Turn on automatic history in <a class="touch-link" href="#/intelligence?section=automation">Automation</a>, then play music or import a history file.</p>{/if}</section>
|
||||
<section class="panel generated-card"><p class="eyebrow">Saved output</p><h3>Generated playlists</h3><ul class="generated-list">{#each data.generatedSets as item}<li class="generated-row"><span><strong>{item.name}</strong><small>{item.trackCount} tracks</small></span><Badge state={item.materialized ? "healthy" : "suggested"}>{generatedStatus(item)}</Badge></li>{:else}<li class="muted">No generated playlists yet.</li>{/each}</ul>{#if data.actions.canGenerate && data.actions.latestRunId}<form class="generate-form" onsubmit={(event) => { event.preventDefault(); void perform("generate", () => intelligence.generate(activeScope, data!.actions.latestRunId!, generatedName)); }}><label class="field"><span>Playlist name</span><input bind:value={generatedName} maxlength="200" required /></label><Button type="submit" disabled={Boolean(action)}>{action === "generate" ? "Creating…" : "Create playlist"}</Button></form>{/if}</section>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -363,7 +375,7 @@
|
||||
/>
|
||||
|
||||
<style>
|
||||
.intelligence-view{display:grid;min-width:0;grid-template-columns:minmax(0,1fr);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{color:var(--color-ink-muted)}.heading-actions{display:flex;align-items:center;gap:.75rem}.scope-card{display:flex;align-items:center;justify-content:space-between;gap:1.25rem;padding:1rem}.scope-intro{min-width:0}.scope-intro p{margin:0}.scope-intro p:last-child{margin-top:.2rem;color:var(--color-ink-muted)}.library-choice{display:grid;min-width:min(22rem,45%);border-left:1px solid var(--color-edge);padding-left:1rem}.library-choice span,.scope-card-status span{color:var(--color-ink-muted);font-size:.8rem}.library-picker{width:min(24rem,45%)}.scope-card-status{display:flex;align-items:center;gap:.75rem}.scope-card-status strong,.scope-card-status span{display:block}.run-progress{display:grid;grid-template-columns:minmax(0,1fr) minmax(10rem,.5fr) auto;align-items:center;gap:1rem;padding:1rem}.run-progress p{margin:0}.run-progress small{display:block;color:var(--color-ink-muted)}.settings-stack{display:grid;gap:1rem}.settings-status-grid{display:grid;grid-template-columns:1fr 1fr;gap:1rem}.status-card{padding:1.15rem}.status-card h3,.status-card p{margin:.2rem 0}.status-list,.profile-list,.generated-list{margin:0;padding:0;list-style:none}.status-row{display:flex;align-items:center;justify-content:space-between;gap:1rem;border-top:1px solid var(--color-edge);padding:.75rem 0}.status-row strong,.status-row small{display:block}.status-row small{color:var(--color-ink-muted)}.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-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}.side-stack{display:grid;align-content:start;gap:1rem}.profile-list li,.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 form{display:grid;grid-template-columns:minmax(14rem,.6fr) 1fr 1fr;gap:1rem}.toggle-line,fieldset label{display:flex;align-items:flex-start;gap:.65rem;border:1px solid var(--color-edge);border-radius:var(--radius-md);background:var(--color-panel-raised);padding:.75rem;cursor:pointer}.toggle-line.selected,fieldset label.selected{border-color:color-mix(in srgb,var(--color-signal) 70%,var(--color-edge));background:color-mix(in srgb,var(--color-signal) 8%,var(--color-panel-raised))}.toggle-line span>*,fieldset label span>*{display:block}.toggle-line small,fieldset small{color:var(--color-ink-muted)}fieldset{display:grid;align-content:start;gap:.5rem;border:0;margin:0;padding:0}fieldset legend{margin-bottom:.55rem;font-weight:750}.signal-choices label{align-items:center;padding:.55rem .65rem}.signal-choices label span{font-weight:700}.provider-choices label.unavailable{cursor:not-allowed}.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{align-items:stretch;flex-direction:column}.library-choice,.library-picker{width:100%;min-width:0;border-left:0;border-top:1px solid var(--color-edge);padding-top:1rem;padding-left:0}.run-progress{grid-template-columns:1fr}.settings-status-grid,.intelligence-grid{grid-template-columns:1fr}.privacy-card form{grid-template-columns:1fr 1fr}}
|
||||
@media(max-width:620px){.route-heading{align-items:stretch;flex-direction:column}.scope-card-status{align-items:stretch;flex-direction:column}.privacy-card form,.run-progress{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>:global([data-slot="button"]){width:100%}.touch-link{display:inline-flex;min-height:var(--control-md);align-items:center}}
|
||||
.intelligence-view{display:grid;min-width:0;grid-template-columns:minmax(0,1fr);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{color:var(--color-ink-muted)}.heading-actions{display:flex;align-items:center;gap:.75rem}.scope-card{display:flex;align-items:center;justify-content:space-between;gap:1.25rem;padding:1rem}.scope-intro{min-width:0}.scope-intro p{margin:0}.scope-intro p:last-child{margin-top:.2rem;color:var(--color-ink-muted)}.library-choice{display:grid;min-width:min(22rem,45%);border-left:1px solid var(--color-edge);padding-left:1rem}.library-choice span,.scope-card-status span{color:var(--color-ink-muted);font-size:.8rem}.library-picker{width:min(24rem,45%)}.scope-card-status{display:flex;align-items:center;gap:.75rem}.scope-card-status strong,.scope-card-status span{display:block}.run-progress{display:grid;grid-template-columns:minmax(0,1fr) minmax(10rem,.5fr) auto;align-items:center;gap:1rem;padding:1rem}.run-progress p{margin:0}.run-progress small{display:block;color:var(--color-ink-muted)}.settings-stack{display:grid;gap:1rem}.settings-status-grid{display:grid;grid-template-columns:1fr 1fr;gap:1rem}.status-card{padding:1.15rem}.status-card h3,.status-card p{margin:.2rem 0}.status-list,.profile-list,.generated-list{margin:0;padding:0;list-style:none}.status-row{display:flex;align-items:center;justify-content:space-between;gap:1rem;border-top:1px solid var(--color-edge);padding:.75rem 0}.status-row strong,.status-row small{display:block}.status-row small{color:var(--color-ink-muted)}.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-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}.side-stack{display:grid;align-content:start;gap:1rem}.profile-list li,.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}.automation-summary{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:end;gap:1rem;padding:1.15rem}.automation-summary>header{grid-column:1/-1;display:flex;align-items:start;justify-content:space-between;gap:1rem}.automation-summary h3,.automation-summary p{margin:.2rem 0}.automation-summary header p:last-child{color:var(--color-ink-muted)}.automation-summary dl{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));margin:0;border:1px solid var(--color-edge);border-radius:var(--radius-md)}.automation-summary dl div{padding:.75rem}.automation-summary dl div+div{border-left:1px solid var(--color-edge)}.automation-summary dt{color:var(--color-ink-muted);font-size:var(--text-xs)}.automation-summary dd{margin:.2rem 0 0;font-weight:750}.privacy-card form{display:grid;grid-template-columns:minmax(14rem,.6fr) 1fr 1fr;gap:1rem}.toggle-line,fieldset label{display:flex;align-items:flex-start;gap:.65rem;border:1px solid var(--color-edge);border-radius:var(--radius-md);background:var(--color-panel-raised);padding:.75rem;cursor:pointer}.toggle-line.selected,fieldset label.selected{border-color:color-mix(in srgb,var(--color-signal) 70%,var(--color-edge));background:color-mix(in srgb,var(--color-signal) 8%,var(--color-panel-raised))}.toggle-line span>*,fieldset label span>*{display:block}.toggle-line small,fieldset small{color:var(--color-ink-muted)}fieldset{display:grid;align-content:start;gap:.5rem;border:0;margin:0;padding:0}fieldset legend{margin-bottom:.55rem;font-weight:750}.signal-choices label{align-items:center;padding:.55rem .65rem}.signal-choices label span{font-weight:700}.provider-choices label.unavailable{cursor:not-allowed}.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{align-items:stretch;flex-direction:column}.library-choice,.library-picker{width:100%;min-width:0;border-left:0;border-top:1px solid var(--color-edge);padding-top:1rem;padding-left:0}.run-progress{grid-template-columns:1fr}.settings-status-grid,.intelligence-grid{grid-template-columns:1fr}.privacy-card form{grid-template-columns:1fr 1fr}.automation-summary{grid-template-columns:1fr}.automation-summary>:global([data-slot="button"]){justify-self:start}}
|
||||
@media(max-width:620px){.route-heading{align-items:stretch;flex-direction:column}.scope-card-status{align-items:stretch;flex-direction:column}.privacy-card form,.run-progress{grid-template-columns:1fr}.recommendation-list>li{grid-template-columns:auto minmax(0,1fr)}.track-actions{grid-column:2;flex-wrap:wrap}.automation-summary dl{grid-template-columns:1fr}.automation-summary dl div+div{border-top:1px solid var(--color-edge);border-left:0}.automation-summary>:global([data-slot="button"]){width:100%}.privacy-card footer{grid-column:auto;flex-direction:column-reverse}.privacy-card footer>:global([data-slot="button"]){width:100%}.touch-link{display:inline-flex;min-height:var(--control-md);align-items:center}}
|
||||
</style>
|
||||
+32
-22
@@ -325,6 +325,11 @@ const responses: Record<string, unknown> = {
|
||||
period: { from: "2025-12-01T00:00:00Z", to: "2026-01-01T00:00:00Z", timeZoneId: "UTC" },
|
||||
allTime: { completedListens: 42, distinctTracks: 20, distinctArtists: 8, listeningTimeMilliseconds: 7_200_000, firstListen: "2025-01-01T00:00:00Z" },
|
||||
selected: { completedListens: 12, distinctTracks: 9, distinctArtists: 5, listeningTimeMilliseconds: 3_660_000, firstListen: "2025-12-01T00:00:00Z" },
|
||||
breakdowns: {
|
||||
sources: [{ dimension: "source", value: "playback", listenCount: 8, durationMilliseconds: 2_400_000 }, { dimension: "source", value: "import", listenCount: 4, durationMilliseconds: 1_260_000 }],
|
||||
providers: [{ dimension: "provider", value: "jellyfin", listenCount: 7, durationMilliseconds: 2_100_000 }, { dimension: "provider", value: "deezer", listenCount: 5, durationMilliseconds: 1_560_000 }],
|
||||
clients: [{ dimension: "client", value: "Jellyfin Web", listenCount: 7, durationMilliseconds: 2_100_000 }, { dimension: "client", value: "Koito", listenCount: 5, durationMilliseconds: 1_560_000 }],
|
||||
},
|
||||
currentStreakDays: 3, longestStreakDays: 9, nowPlaying: null, recent: [],
|
||||
},
|
||||
"/api/admin/intelligence/history/activity": {
|
||||
@@ -866,6 +871,13 @@ for (const viewport of viewports) {
|
||||
? "Showing 2025-12-02 through 2025-12-31"
|
||||
: "Showing 2025-12-01 through 2025-12-31", { exact: true })).toBeVisible();
|
||||
await expect(activityCard.locator(".activity-grid span:visible")).toHaveCount(viewport.width <= 620 ? 30 : 31);
|
||||
await activityCard.getByRole("tab", { name: "Monthly" }).click();
|
||||
await expect(activityCard.getByText("Dec 2025", { exact: true })).toBeVisible();
|
||||
await expect(activityCard.getByText("496 listens", { exact: true })).toBeVisible();
|
||||
await activityCard.getByRole("tab", { name: "Daily" }).click();
|
||||
await expect(page.locator(".breakdown-card").filter({ hasText: "Sources" })).toContainText("playback");
|
||||
await expect(page.locator(".breakdown-card").filter({ hasText: "Providers" })).toContainText("deezer");
|
||||
await expect(page.locator(".breakdown-card").filter({ hasText: "Listening apps" })).toContainText("Koito");
|
||||
const topTabs = page.getByRole("navigation", { name: "Top listening category" });
|
||||
await topTabs.getByRole("tab", { name: "Artists" }).focus();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
@@ -874,8 +886,8 @@ for (const viewport of viewports) {
|
||||
await expect(topTabs.getByRole("tab", { name: "Songs" })).toHaveAttribute("aria-selected", "true");
|
||||
if (viewport.width <= 650)
|
||||
expect(Math.min(...await topTabs.getByRole("tab").evaluateAll((tabs) => tabs.map((tab) => tab.clientHeight)))).toBeGreaterThanOrEqual(44);
|
||||
await page.getByRole("tab", { name: "Recommendations" }).click();
|
||||
await expect(page).toHaveURL(/#\/intelligence\?section=recommendations$/);
|
||||
await page.getByRole("tab", { name: "Discover" }).click();
|
||||
await expect(page).toHaveURL(/#\/intelligence\?section=discover$/);
|
||||
await expect(page.getByText("Future Song", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Morning discovery")).toBeVisible();
|
||||
await expect(page.getByText("Created in Jellyfin", { exact: true })).toBeVisible();
|
||||
@@ -965,7 +977,7 @@ for (const viewport of viewports) {
|
||||
await page.screenshot({ path: `${process.env.ALLSTARR_SCREENSHOT_DIR}/intelligence-${viewport.width}-recommendations.png`, fullPage: true });
|
||||
await expect.poll(() => page.evaluate(() =>
|
||||
document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
||||
await page.getByRole("tab", { name: "History" }).click();
|
||||
await page.getByRole("tab", { name: "History & Imports" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Listening history", level: 3, exact: true })).toBeVisible();
|
||||
await expect(page.locator(".history-list").getByText("Moon Song", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Import listening history" })).toHaveCount(0);
|
||||
@@ -973,7 +985,7 @@ for (const viewport of viewports) {
|
||||
document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
||||
if (process.env.ALLSTARR_SCREENSHOT_DIR)
|
||||
await page.screenshot({ path: `${process.env.ALLSTARR_SCREENSHOT_DIR}/intelligence-${viewport.width}-history.png`, fullPage: true });
|
||||
await page.getByRole("tab", { name: "Imports" }).click();
|
||||
await page.getByRole("tab", { name: "Import history" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Import listening history" })).toBeVisible();
|
||||
await expect(page.getByText("Imported listens older than 30 days are removed automatically.", { exact: true })).toBeVisible();
|
||||
await expect(page.locator(".history-list")).toHaveCount(0);
|
||||
@@ -983,7 +995,7 @@ for (const viewport of viewports) {
|
||||
await expect(page.getByLabel("History export files")).toHaveAttribute("multiple", "");
|
||||
await expect.poll(() => page.evaluate(() =>
|
||||
document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
||||
await page.getByRole("tab", { name: "Settings" }).click();
|
||||
await page.getByRole("tab", { name: "Automation" }).click();
|
||||
await expect(page.getByText("Monday discoveries", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Allstarr keeps private listening history and uses it for recommendations.", { exact: true })).toBeVisible();
|
||||
const automaticHistory = page.getByRole("checkbox", { name: /Save my listening automatically/ });
|
||||
@@ -1111,7 +1123,7 @@ test("Intelligence history imports, corrections, and schedules use the selected
|
||||
await mockApi(page);
|
||||
const overviewRequest = page.waitForRequest((request) => request.url().includes("/api/admin/intelligence/history/overview"));
|
||||
await page.goto("#/intelligence?section=history");
|
||||
await expect(page.getByRole("tab", { name: "History" })).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByRole("tab", { name: "History & Imports" })).toHaveAttribute("aria-selected", "true");
|
||||
expect(new URL((await overviewRequest).url()).searchParams.get("backendInstanceId")).toBe("main");
|
||||
|
||||
await page.locator(".history-list").getByRole("button").click();
|
||||
@@ -1127,7 +1139,7 @@ test("Intelligence history imports, corrections, and schedules use the selected
|
||||
});
|
||||
await detail.getByRole("button", { name: "Close listen details" }).click();
|
||||
|
||||
await page.getByRole("tab", { name: "Imports" }).click();
|
||||
await page.getByRole("tab", { name: "Import history" }).click();
|
||||
await expect(page).toHaveURL(/#\/intelligence\?section=imports$/);
|
||||
const previews: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
@@ -1138,9 +1150,6 @@ test("Intelligence history imports, corrections, and schedules use the selected
|
||||
{ name: "Streaming_History.json", mimeType: "application/json", buffer: Buffer.from("[]") },
|
||||
{ name: "ListenBrainz.jsonl", mimeType: "text/plain", buffer: Buffer.from("{}") },
|
||||
]);
|
||||
await expect(page.getByRole("list", { name: "Selected history exports" })).toContainText("Streaming_History.json");
|
||||
await expect(page.getByRole("list", { name: "Selected history exports" })).toContainText("ListenBrainz.jsonl");
|
||||
await page.getByRole("button", { name: "Preview 2 files" }).click();
|
||||
await expect.poll(() => previews.length).toBe(2);
|
||||
await expect(page.getByText("9", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByText("It will not send them to Last.fm or ListenBrainz.", { exact: false }).first()).toBeVisible();
|
||||
@@ -1151,7 +1160,9 @@ test("Intelligence history imports, corrections, and schedules use the selected
|
||||
if (request.method() === "POST" && request.url().match(/\/api\/admin\/intelligence\/history\/imports\/[^/]+\/apply$/))
|
||||
applies.push({ url: request.url(), body: request.postDataJSON() });
|
||||
});
|
||||
await page.getByRole("button", { name: "Add all 2 files" }).click();
|
||||
await expect(page.getByRole("checkbox", { name: "Include Streaming_History.json" })).toBeChecked();
|
||||
await expect(page.getByRole("checkbox", { name: "Include ListenBrainz.jsonl" })).toBeChecked();
|
||||
await page.getByRole("button", { name: "Add all 2 ready files" }).click();
|
||||
await expect.poll(() => applies.length).toBe(2);
|
||||
expect(applies.map((request) => request.url)).toEqual(expect.arrayContaining([
|
||||
expect.stringContaining("/55555555-5555-5555-5555-555555555555/apply"),
|
||||
@@ -1161,7 +1172,7 @@ test("Intelligence history imports, corrections, and schedules use the selected
|
||||
protocol: "jellyfin", backendInstanceId: "main", libraryScopeId: "music", revision: "preview-revision",
|
||||
});
|
||||
|
||||
await page.getByRole("tab", { name: "Settings" }).click();
|
||||
await page.getByRole("tab", { name: "Automation" }).click();
|
||||
await expect(page.getByRole("button", { name: "Generated playlist destination" })).toContainText("Jellyfin Music");
|
||||
await page.getByRole("button", { name: "New schedule" }).click();
|
||||
await page.getByLabel("Playlist name").fill("Friday discoveries");
|
||||
@@ -1212,12 +1223,12 @@ test("Intelligence empty states explain how to get results", async ({ page }) =>
|
||||
await page.goto("#/intelligence?section=unknown");
|
||||
await expect(page.getByRole("tab", { name: "Overview" })).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByRole("heading", { name: "Listening overview" })).toBeVisible();
|
||||
await page.getByRole("tab", { name: "Recommendations" }).click();
|
||||
await page.getByRole("tab", { name: "Discover" }).click();
|
||||
const sourceLink = page.getByRole("link", { name: "Connect or configure a source" });
|
||||
await expect(sourceLink).toBeVisible();
|
||||
expect((await sourceLink.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
await expect(page.getByText("Turn on automatic history in Settings, then play music or import a history file.")).toBeVisible();
|
||||
const settingsLink = page.getByRole("link", { name: "Settings", exact: true });
|
||||
await expect(page.getByText("Turn on automatic history in Automation, then play music or import a history file.")).toBeVisible();
|
||||
const settingsLink = page.getByRole("link", { name: "Automation", exact: true });
|
||||
expect((await settingsLink.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
|
||||
await page.getByRole("tab", { name: "Overview" }).click();
|
||||
@@ -1225,9 +1236,9 @@ test("Intelligence empty states explain how to get results", async ({ page }) =>
|
||||
await expect(page.getByText("No activity in this period. Play music or import a history file to begin.")).toBeVisible();
|
||||
await expect(page.getByText("Nothing to rank yet. Play music or import a history file to begin.")).toBeVisible();
|
||||
|
||||
await page.getByRole("tab", { name: "History" }).click();
|
||||
await page.getByRole("tab", { name: "History & Imports" }).click();
|
||||
await expect(page.getByText("No completed listens yet")).toBeVisible();
|
||||
await expect(page.getByText("Turn on automatic history in Settings, then play music or import a history file.")).toBeVisible();
|
||||
await expect(page.getByText("Turn on automatic history in Automation, then play music or import a history file.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Intelligence partial and section error states stay announced", async ({ page }) => {
|
||||
@@ -1270,22 +1281,21 @@ test("Intelligence partial and section error states stay announced", async ({ pa
|
||||
await expect(page.locator(".history-list").getByText("Moon Song", { exact: true })).toBeVisible();
|
||||
await expect(page.locator(".degraded-banner")).toContainText("Existing results remain available.");
|
||||
|
||||
await page.getByRole("tab", { name: "Recommendations" }).click();
|
||||
await page.getByRole("tab", { name: "Discover" }).click();
|
||||
await expect(page.getByText("Future Song", { exact: true })).toBeVisible();
|
||||
failHistory = true;
|
||||
await page.getByRole("tab", { name: "History" }).click();
|
||||
await page.getByRole("tab", { name: "History & Imports" }).click();
|
||||
await expect(page.locator(".history-workspace").getByRole("alert")).toContainText("Fixture unavailable");
|
||||
|
||||
await page.getByRole("tab", { name: "Imports" }).click();
|
||||
await page.getByRole("tab", { name: "Import history" }).click();
|
||||
await page.getByLabel("History export files").setInputFiles({
|
||||
name: "Streaming_History.json", mimeType: "application/json", buffer: Buffer.from("[]"),
|
||||
});
|
||||
const queue = page.getByRole("list", { name: "Selected history exports" });
|
||||
await expect(queue).toHaveAttribute("aria-live", "polite");
|
||||
await page.getByRole("button", { name: "Preview 1 file" }).click();
|
||||
await expect(queue).toContainText("Fixture unavailable");
|
||||
|
||||
await page.getByRole("tab", { name: "Settings" }).click();
|
||||
await page.getByRole("tab", { name: "Automation" }).click();
|
||||
await page.getByRole("button", { name: "New schedule" }).click();
|
||||
await page.getByRole("button", { name: "Create schedule" }).click();
|
||||
await expect(page.locator(".intelligence-schedules").getByRole("alert")).toContainText("Fixture unavailable");
|
||||
|
||||
Reference in new issue
Block a user