Files
yellowjacket/frontend/src/components/smart-playlist-editor/smart-playlist-editor.ts
T
yonluandClaude Opus 5 185eb1b125
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Canceled after 0s
CI / e2e (pull_request) Canceled after 0s
feat(smartplaylist): let a rule set match any rule, not only all of them
The conditions were joined with " AND " and nothing else, so a smart
playlist could only ever narrow: "jazz released after 1960" was
expressible and "jazz or blues" was not, which is most of what anyone
reaches for a second rule to say.

`RuleSet.Match` is "all" or "any", and an empty match is "all" — which
is what every playlist saved before the field existed carries, so an
upgrade cannot silently widen one. ParseRuleSet rejects anything else
rather than falling through to AND, since a playlist quietly returning
the wrong tracks is worse than one that refuses to be saved.

Under OR each condition is parenthesised and under AND it is not: AND
is the tighter operator, so an OR-join has to protect a condition
carrying a top-level AND of its own — `days_since_played less_than` is
two predicates belonging to one rule.

The editor shows the choice as a sentence with the control in the
middle, and hides it while there is one rule: with nothing to combine,
all and any are the same query.

Closes #35

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 08:06:16 -04:00

1039 lines
35 KiB
TypeScript

import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import * as library from '@go/library/models.js';
import { PreviewSmartPlaylist } from '@go/playlist/service.js';
import { libraryStore } from '@store/library-store';
import { describeError } from '@utils/describe-error';
import { designTokens } from '../../styles/tokens.css';
import '@components/combobox/combobox.ts';
// ── Field / Operator constants ──────────────────────────────────────
/** All fields matching the backend `fieldMap` keys. */
const FIELDS: string[] = [
'title',
'artist',
'album',
'genre',
'year',
'release_year',
'composer',
'file_type',
'duration',
'sample_rate',
'bit_depth',
'channels',
'bitrate',
'file_size',
'library',
'track_number',
'disc_number',
'play_count',
'days_since_played',
];
const NUMERIC_FIELDS = new Set([
'year',
'release_year',
'duration',
'sample_rate',
'bit_depth',
'channels',
'bitrate',
'file_size',
'library',
'track_number',
'disc_number',
'play_count',
'days_since_played',
]);
const TEXT_OPERATORS = [
'is',
'is_not',
'contains',
'does_not_contain',
'starts_with',
'ends_with',
'is_any_of',
];
const NUMERIC_OPERATORS = [
'is',
'is_not',
'greater_than',
'less_than',
'between',
];
const SORT_FIELDS = [
'title',
'artist',
'album',
'year',
'release_year',
'duration',
'play_count',
'random',
];
// ── Helpers ─────────────────────────────────────────────────────────
function getOperatorsForField(field: string): string[] {
return NUMERIC_FIELDS.has(field) ? NUMERIC_OPERATORS : TEXT_OPERATORS;
}
/**
* Human-readable labels for operator values.
* `is_not` → "is not", `does_not_contain` → "does not contain", etc.
*/
function formatOperatorLabel(op: string): string {
return op.replace(/_/g, ' ');
}
/** Returns autocomplete suggestions for a given field from libraryStore. */
function getAutocompleteOptions(field: string): string[] {
switch (field) {
case 'artist':
return libraryStore.getCachedArtists()?.map((a) => a.Name) ?? [];
case 'genre':
return libraryStore.getCachedGenres()?.map((g) => g.Name) ?? [];
case 'album':
return libraryStore.getCachedAlbums()?.map((a) => a.Name) ?? [];
case 'title': {
const tracks = libraryStore.getCachedTracks();
if (!tracks) return [];
return [...new Set(tracks.map((t) => t.TrackName).filter(Boolean))];
}
case 'composer': {
const tracks = libraryStore.getCachedTracks();
if (!tracks) return [];
return [...new Set(tracks.map((t) => t.Composer).filter(Boolean))];
}
case 'file_type': {
const tracks = libraryStore.getCachedTracks();
if (!tracks) return [];
return [...new Set(tracks.map((t) => t.FileType).filter(Boolean))];
}
case 'year':
case 'release_year': {
// Both year fields draw suggestions from the set of years
// present in the library. The cached Track only carries the
// display (original) year, so it seeds both datalists — the
// list is just a hint, and the real filter runs server-side.
const tracks = libraryStore.getCachedTracks();
if (!tracks) return [];
return [
...new Set(
tracks
.map((t) => t.Year)
.filter((y) => y > 0)
.map(String),
),
].sort();
}
default:
return [];
}
}
/**
* Overrides for fields whose title-cased name would be ambiguous. The
* two year fields in particular need to disambiguate the album's
* original release from the specific (possibly reissue) release owned.
*/
const FIELD_LABEL_OVERRIDES: Record<string, string> = {
year: 'Year (Original Release)',
release_year: 'Year (This Release)',
};
/** Format a field name for display: `file_type` → "File Type". */
function formatFieldLabel(field: string): string {
const override = FIELD_LABEL_OVERRIDES[field];
if (override) return override;
return field
.split('_')
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
// ── Rule row type ───────────────────────────────────────────────────
interface RuleRow {
field: string;
operator: string;
value: string;
/** Second value for `between` operator (max). */
value2: string;
}
function emptyRule(): RuleRow {
return { field: '', operator: '', value: '', value2: '' };
}
// ── Component ───────────────────────────────────────────────────────
/**
* `<smart-playlist-editor>` — Row-based rule builder with live preview.
*
* Accepts an initial `rules` JSON attribute (matching the backend RuleSet
* schema) and emits `rules-changed` CustomEvent whenever the user edits
* any row, limit, or sort control. A live preview panel calls
* `PreviewSmartPlaylist` with 300ms debounce and displays matching tracks.
*/
@customElement('smart-playlist-editor')
export class SmartPlaylistEditor extends LitElement {
// ── Public property ─────────────────────────────────────────────
/** Initial rules JSON (attribute). Parsed in connectedCallback. */
@property({ type: String })
rules = '';
// ── Internal state ──────────────────────────────────────────────
@state() private ruleRows: RuleRow[] = [emptyRule()];
/** Whether every rule must hold or any one of them. Mirrors the
* backend's `match`; 'all' is the default and the only thing a
* playlist saved before this existed can have meant. */
@state() private matchType: 'all' | 'any' = 'all';
@state() private limit = 0;
@state() private sortField = 'random';
@state() private sortDir = '';
@state() private previewTracks: library.Track[] = [];
@state() private previewLoading = false;
@state() private previewError = '';
private previewTimer: ReturnType<typeof setTimeout> | null = null;
// ── Styles ──────────────────────────────────────────────────────
static override styles = [
designTokens,
css`
:host {
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
}
/* ── Rule rows ────────────────────────── */
.rule-rows {
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 0 8px;
flex-shrink: 0;
}
.match-row {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--yj-text-sm);
color: var(--yj-text-secondary, #b3b3b3);
flex-wrap: wrap;
}
.match-select {
min-width: 72px;
}
.rule-row {
display: grid;
grid-template-columns: 160px 140px 1fr 28px;
gap: 6px;
align-items: stretch;
}
.rule-row.between-row {
grid-template-columns: 160px 140px 1fr 1fr 28px;
}
/* ── Form controls ────────────────────── */
select,
input[type='number'] {
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
color: var(--yj-text-primary, #fff);
border: 1px solid
var(--yj-border-subtle, rgba(255, 255, 255, 0.1));
border-radius: 4px;
padding: 4px 8px;
font-size: var(--yj-text-md);
font-family: inherit;
width: 100%;
box-sizing: border-box;
}
select:focus,
input[type='number']:focus {
outline: none;
border-color: var(--yj-accent, #ffd43b);
}
input[type='number'] {
-moz-appearance: textfield;
}
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* ── Remove button ────────────────────── */
.remove-btn {
display: flex;
align-items: center;
justify-content: center;
align-self: center;
width: 24px;
height: 24px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--yj-text-secondary, #b3b3b3);
cursor: pointer;
font-size: 14px;
padding: 0;
transition: color 0.15s ease, background-color 0.15s ease;
}
.remove-btn:hover {
color: var(--yj-error-text, #ff8787);
background: rgba(255, 107, 107, 0.1);
}
.remove-btn.hidden {
visibility: hidden;
}
/* ── Add rule button ──────────────────── */
.add-rule-btn {
background: none;
border: 1px dashed
var(--yj-border-subtle, rgba(255, 255, 255, 0.15));
border-radius: 4px;
color: var(--yj-text-secondary, #b3b3b3);
padding: 4px 12px;
font-size: var(--yj-text-sm);
font-family: inherit;
cursor: pointer;
transition: border-color 0.15s ease, color 0.15s ease;
align-self: flex-start;
}
.add-rule-btn:hover {
border-color: var(--yj-accent, #ffd43b);
color: var(--yj-accent-text, #ffd43b);
}
/* ── Options row (limit, sort) ────────── */
.options-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 0 4px;
border-top: 1px solid
var(--yj-border-subtle, rgba(255, 255, 255, 0.06));
margin-top: 4px;
flex-wrap: wrap;
flex-shrink: 0;
}
.option-group {
display: flex;
align-items: center;
gap: 6px;
}
.option-label {
font-size: var(--yj-text-sm);
color: var(--yj-text-secondary, #b3b3b3);
white-space: nowrap;
}
.limit-input {
width: 64px;
}
.sort-select {
min-width: 90px;
}
.sort-dir-btn {
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
border: 1px solid
var(--yj-border-subtle, rgba(255, 255, 255, 0.1));
border-radius: 4px;
color: var(--yj-text-primary, #fff);
padding: 3px 8px;
font-size: var(--yj-text-sm);
font-family: inherit;
cursor: pointer;
min-width: 40px;
text-align: center;
transition: border-color 0.15s ease;
}
.sort-dir-btn:hover {
border-color: var(--yj-accent, #ffd43b);
}
/* ── Preview section ──────────────────── */
.preview-section {
border-top: 1px solid
var(--yj-border-subtle, rgba(255, 255, 255, 0.06));
margin-top: 8px;
padding-top: 10px;
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.preview-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.preview-title {
font-size: var(--yj-text-sm);
font-weight: 600;
color: var(--yj-text-secondary, #b3b3b3);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.preview-count {
font-size: var(--yj-text-sm);
color: var(--yj-text-secondary, #b3b3b3);
}
.preview-loading {
font-size: var(--yj-text-sm);
color: var(--yj-text-secondary, #b3b3b3);
padding: 8px 0;
}
.preview-error {
font-size: var(--yj-text-sm);
color: var(--yj-error-text, #ff8787);
padding: 6px 0;
}
.preview-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
min-height: 0;
}
.preview-track {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
padding: 3px 0;
font-size: var(--yj-text-sm);
color: var(--yj-text-primary, #fff);
border-bottom: 1px solid
var(--yj-border-subtle, rgba(255, 255, 255, 0.03));
}
.preview-track:last-child {
border-bottom: none;
}
.preview-track span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-track .artist,
.preview-track .album {
color: var(--yj-text-secondary, #b3b3b3);
}
.preview-empty {
font-size: var(--yj-text-sm);
color: var(--yj-text-tertiary, #666);
padding: 8px 0;
}
`,
];
// ── Lifecycle ───────────────────────────────────────────────────
override connectedCallback() {
super.connectedCallback();
this.parseInitialRules();
}
override disconnectedCallback() {
super.disconnectedCallback();
if (this.previewTimer !== null) {
clearTimeout(this.previewTimer);
this.previewTimer = null;
}
}
// ── Parse initial rules ─────────────────────────────────────────
private parseInitialRules() {
if (!this.rules) {
this.ruleRows = [emptyRule()];
return;
}
try {
const parsed = JSON.parse(this.rules);
const rows: RuleRow[] = (parsed.rules ?? []).map(
(r: { field?: string; operator?: string; value?: string }) => {
const field = r.field ?? '';
const operator = r.operator ?? '';
let value = r.value ?? '';
let value2 = '';
// Deserialize `is_any_of` JSON array back to comma string
if (operator === 'is_any_of' && value.startsWith('[')) {
try {
const arr = JSON.parse(value) as string[];
value = arr.join(', ');
} catch {
// keep raw value
}
}
// Deserialize `between` "min,max" into two fields
if (operator === 'between' && value.includes(',')) {
const parts = value.split(',');
value = parts[0]?.trim() ?? '';
value2 = parts[1]?.trim() ?? '';
}
return { field, operator, value, value2 };
},
);
this.ruleRows = rows.length > 0 ? rows : [emptyRule()];
// A playlist saved before this field existed has no match
// and means "all" — the backend reads an empty match the
// same way, so an upgrade cannot widen anyone's playlist.
this.matchType = parsed.match === 'any' ? 'any' : 'all';
this.limit = parsed.limit ?? 0;
this.sortField = parsed.sort_field || 'random';
this.sortDir = parsed.sort_dir ?? '';
} catch {
this.ruleRows = [emptyRule()];
}
// Trigger initial preview if rules are complete.
this.schedulePreview();
}
// ── Build JSON from current state ───────────────────────────────
private buildRulesJSON(): string {
const rules = this.ruleRows.map((row) => {
let value = row.value;
// Serialize is_any_of comma-separated values to JSON array
if (row.operator === 'is_any_of' && value) {
const parts = value
.split(',')
.map((v) => v.trim())
.filter(Boolean);
value = JSON.stringify(parts);
}
// Serialize between as "min,max"
if (row.operator === 'between') {
value = `${row.value},${row.value2}`;
}
return {
field: row.field,
operator: row.operator,
value,
};
});
return JSON.stringify({
rules,
match: this.matchType,
limit: this.limit || 0,
sort_field: this.sortField || '',
sort_dir: this.sortDir || '',
});
}
// ── Rule mutation methods ───────────────────────────────────────
private updateField(index: number, newField: string) {
const row = this.ruleRows[index];
if (!row) return;
const wasNumeric = NUMERIC_FIELDS.has(row.field);
const isNumeric = NUMERIC_FIELDS.has(newField);
row.field = newField;
// Reset operator when field type changes (text↔numeric)
if (wasNumeric !== isNumeric || !row.operator) {
const ops = getOperatorsForField(newField);
row.operator = ops[0] ?? '';
}
// Reset value when field changes to avoid stale autocomplete data
row.value = '';
row.value2 = '';
this.ruleRows = [...this.ruleRows];
this.onRulesChanged();
}
private updateOperator(index: number, newOp: string) {
const row = this.ruleRows[index];
if (!row) return;
row.operator = newOp;
// Clear value2 if no longer between
if (newOp !== 'between') {
row.value2 = '';
}
this.ruleRows = [...this.ruleRows];
this.onRulesChanged();
}
private updateValue(index: number, newValue: string) {
const row = this.ruleRows[index];
if (!row) return;
row.value = newValue;
this.ruleRows = [...this.ruleRows];
this.onRulesChanged();
}
private updateValue2(index: number, newValue: string) {
const row = this.ruleRows[index];
if (!row) return;
row.value2 = newValue;
this.ruleRows = [...this.ruleRows];
this.onRulesChanged();
}
private addRule() {
this.ruleRows = [...this.ruleRows, emptyRule()];
}
private removeRule(index: number) {
if (this.ruleRows.length <= 1) return;
this.ruleRows = this.ruleRows.filter((_, i) => i !== index);
this.onRulesChanged();
}
private updateLimit(value: string) {
this.limit = Math.max(0, parseInt(value, 10) || 0);
this.onRulesChanged();
}
private updateMatchType(value: string) {
this.matchType = value === 'any' ? 'any' : 'all';
this.onRulesChanged();
}
private updateSortField(value: string) {
this.sortField = value;
if (!value) this.sortDir = '';
this.onRulesChanged();
}
private toggleSortDir() {
if (!this.sortDir) {
this.sortDir = 'ASC';
} else if (this.sortDir === 'ASC') {
this.sortDir = 'DESC';
} else {
this.sortDir = '';
}
this.onRulesChanged();
}
// ── Change notification ─────────────────────────────────────────
private onRulesChanged() {
const json = this.buildRulesJSON();
this.dispatchEvent(
new CustomEvent('rules-changed', {
bubbles: true,
composed: true,
detail: { json },
}),
);
this.schedulePreview();
}
// ── Live preview ────────────────────────────────────────────────
private schedulePreview() {
if (this.previewTimer !== null) {
clearTimeout(this.previewTimer);
}
this.previewTimer = setTimeout(() => {
this.previewTimer = null;
void this.runPreview();
}, 300);
}
/**
* Monotonic request version. Debouncing only coalesces keystrokes
* *within* its window; a query slower than 300 ms overlaps the next
* one and whichever answers last used to win (errors.M8). This is
* `explore-view`'s guard, checked on all three paths.
*/
private previewVersion = 0;
private async runPreview() {
// Skip preview if any rule is incomplete
const incomplete = this.ruleRows.some(
(r) =>
!r.field ||
!r.value ||
(r.operator === 'between' && !r.value2),
);
if (incomplete) {
this.previewTracks = [];
this.previewError = '';
return;
}
const json = this.buildRulesJSON();
const version = ++this.previewVersion;
this.previewLoading = true;
this.previewError = '';
try {
const tracks = await PreviewSmartPlaylist(json);
if (version !== this.previewVersion) return;
this.previewTracks = tracks ?? [];
} catch (error) {
if (version !== this.previewVersion) return;
console.error('Smart playlist preview failed:', error);
this.previewError = describeError(
error,
'The preview could not be built for these rules.',
);
this.previewTracks = [];
} finally {
if (version === this.previewVersion) {
this.previewLoading = false;
}
}
}
// ── Render ──────────────────────────────────────────────────────
override render() {
return html`
<div class="rule-rows">
${this.renderMatchType()}
${this.ruleRows.map((row, index) =>
this.renderRuleRow(row, index),
)}
<button class="add-rule-btn" @click=${this.addRule}>
+ Add Rule
</button>
</div>
${this.renderSortOptions()} ${this.renderPreview()}
`;
}
/**
* Whether every rule has to hold, or any one of them.
*
* It is a sentence with a control in the middle rather than a
* labelled field, because the two readings differ by one word and
* that word is the whole of the setting — "Match **all** of the
* following rules" says what the list below it means in a way a
* select labelled "Match" beside a list does not.
*
* Hidden while there is one rule: with nothing to combine, all and
* any are the same query, and a control whose two settings cannot
* differ is a question the user has no way to answer wrongly and
* no reason to answer at all.
*/
private renderMatchType() {
if (this.ruleRows.length < 2) return nothing;
return html`
<div class="match-row">
<span>Match</span>
<select
class="match-select"
aria-label="Match all or any of the following rules"
@change=${(e: Event) =>
this.updateMatchType(
(e.target as HTMLSelectElement).value,
)}
>
<option value="all" ?selected=${this.matchType === 'all'}>
all
</option>
<option value="any" ?selected=${this.matchType === 'any'}>
any
</option>
</select>
<span>of the following rules</span>
</div>
`;
}
private renderRuleRow(row: RuleRow, index: number) {
const isBetween = row.operator === 'between';
const operators = row.field ? getOperatorsForField(row.field) : [];
const isNumeric = NUMERIC_FIELDS.has(row.field);
const isAnyOf = row.operator === 'is_any_of';
return html`
<div class="rule-row ${isBetween ? 'between-row' : ''}">
<!-- Field combobox -->
<yj-combobox
.options=${FIELDS}
.value=${row.field}
placeholder="Select field…"
@combobox-change=${(e: CustomEvent) =>
this.updateField(index, e.detail.value)}
></yj-combobox>
<!-- Operator select -->
<select
@change=${(e: Event) =>
this.updateOperator(
index,
(e.target as HTMLSelectElement).value,
)}
?disabled=${!row.field}
>
${!row.field
? html`<option value="">—</option>`
: nothing}
${operators.map(
(op) => html`
<option
value=${op}
?selected=${op === row.operator}
>
${formatOperatorLabel(op)}
</option>
`,
)}
</select>
<!-- Value input -->
${isNumeric && !isBetween
? html`
<input
type="number"
.value=${row.value}
placeholder="Value"
?disabled=${!row.field}
@input=${(e: Event) =>
this.updateValue(
index,
(e.target as HTMLInputElement).value,
)}
/>
`
: isBetween
? html`
<input
type="number"
.value=${row.value}
placeholder="Min"
@input=${(e: Event) =>
this.updateValue(
index,
(e.target as HTMLInputElement).value,
)}
/>
<input
type="number"
.value=${row.value2}
placeholder="Max"
@input=${(e: Event) =>
this.updateValue2(
index,
(e.target as HTMLInputElement).value,
)}
/>
`
: html`
<yj-combobox
.options=${getAutocompleteOptions(row.field)}
.value=${row.value}
placeholder=${isAnyOf
? 'Comma-separated values'
: 'Value'}
?disabled=${!row.field}
@combobox-change=${(e: CustomEvent) =>
this.updateValue(
index,
e.detail.value,
)}
></yj-combobox>
`}
<!-- Remove button -->
<button
class="remove-btn ${this.ruleRows.length <= 1 ? 'hidden' : ''}"
@click=${() => this.removeRule(index)}
title="Remove rule"
aria-label="Remove rule"
>
</button>
</div>
`;
}
private renderSortOptions() {
const sortDirLabel = !this.sortDir
? '—'
: this.sortDir === 'ASC'
? '↑'
: '↓';
return html`
<div class="options-row">
<div class="option-group">
<span class="option-label">Limit</span>
<input
type="number"
class="limit-input"
min="0"
.value=${String(this.limit || '')}
placeholder="∞"
@input=${(e: Event) =>
this.updateLimit(
(e.target as HTMLInputElement).value,
)}
/>
</div>
<div class="option-group">
<span class="option-label">Sort by</span>
<select
class="sort-select"
@change=${(e: Event) =>
this.updateSortField(
(e.target as HTMLSelectElement).value,
)}
>
${SORT_FIELDS.map(
(f) => html`
<option
value=${f}
?selected=${f === this.sortField}
>
${formatFieldLabel(f)}
</option>
`,
)}
</select>
${this.sortField && this.sortField !== 'random'
? html`
<button
class="sort-dir-btn"
@click=${this.toggleSortDir}
title=${this.sortDir === 'ASC'
? 'Ascending'
: this.sortDir === 'DESC'
? 'Descending'
: 'No direction'}
>
${sortDirLabel}
</button>
`
: nothing}
</div>
</div>
`;
}
private renderPreview() {
return html`
<div class="preview-section">
<div class="preview-header">
<span class="preview-title">Preview</span>
${this.previewTracks.length > 0 && !this.previewLoading
? html`<span class="preview-count"
>${this.previewTracks.length} tracks</span
>`
: nothing}
</div>
${this.previewLoading
? html`<div class="preview-loading">
Evaluating rules…
</div>`
: this.previewError
? html`<div class="preview-error">
${this.previewError}
</div>`
: this.previewTracks.length > 0
? html`
<div class="preview-list">
${this.previewTracks.map(
(t) => html`
<div class="preview-track">
<span class="title"
>${t.TrackName}</span
>
<span class="artist"
>${t.ArtistName}</span
>
<span class="album"
>${t.Album}</span
>
</div>
`,
)}
</div>
`
: html`<div class="preview-empty">
Complete all rule fields to see a
preview.
</div>`}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'smart-playlist-editor': SmartPlaylistEditor;
}
}