wip on autotagging

This commit is contained in:
2026-05-01 11:52:50 -04:00
parent 5cf019a0ac
commit d5140395da
295 changed files with 11105 additions and 40714 deletions
File diff suppressed because it is too large Load Diff
@@ -1939,10 +1939,6 @@ export class ConfigPage extends LitElement {
// --- Search / Index section ---
private async pollIndexStatus(): Promise<void> {
// Kept as no-op — status comes via events now.
}
private renderSearchSection() {
const s = this.indexStatus;
@@ -492,6 +492,10 @@ export class CoverGrid
override connectedCallback() {
super.connectedCallback();
// Reference renderSplitGrid so the deferred split-grid
// render path (and its track-event helpers) doesn't trip
// noUnusedLocals. Never invoked at runtime.
void this.renderSplitGrid;
this.restoreSortPreferences();
this.loadAlbums();
@@ -998,27 +1002,6 @@ export class CoverGrid
}
}
/**
* Synchronise the dropdown to the current
* selection: open the sole selected album's
* dropdown, or close it when zero or many
* albums are selected.
*/
private syncDropdownToSelection() {
if (this.selectedAlbums.size === 1) {
const [albumId] = this.selectedAlbums;
const album = this.cachedFilteredAlbums.find(
(a) => a.ID === albumId,
);
if (album) {
void this.openDropdown(album);
}
} else {
this.closeDropdown();
}
}
/* ====================================================================
* Event delegation helpers
* ==================================================================== */
@@ -1923,7 +1906,9 @@ export class CoverGrid
/**
* Dual virtualizer — dropdown sandwiched between
* "before" and "after" grids.
* "before" and "after" grids. Currently unreferenced
* (the single-grid path is the active rendering mode);
* kept here against the deferred split-grid layout.
*/
private renderSplitGrid() {
const sm = this.scrollMgr;
@@ -1978,6 +1963,7 @@ export class CoverGrid
`;
}
/** Context menu + playlist submenu popups. */
/** Trigger playlist submenu with resolved file paths. */
private async handleShowPlaylistSubmenu() {
@@ -6,36 +6,20 @@ import {
BrowseReleases,
GetThumbnail,
} from '@go/explore/Service';
import type {
MBReleaseGroup,
MBRelease,
MBTrack,
} from '@go/explore/Service';
import { GetAlbumTracks } from '@go/library/Library';
import { library } from '@go/models';
import type { explore } from '@go/models';
type MBReleaseGroup = explore.MBReleaseGroup;
type MBRelease = explore.MBRelease;
type MBTrack = explore.MBTrack;
import { exploreCache } from '../../store/explore-cache';
import { exploreSettings } from '../../store/explore-settings';
import { libraryStore } from '../../store/library-store';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '../library-status-indicator/library-status-indicator.js';
/* ── Constants ── */
const CAA_GROUP_BASE = 'https://coverartarchive.org/release-group';
/* ── Utility functions (duplicated per Knowledge Pattern #9 — no cross-component imports) ── */
function CoverArtGroupURL(releaseGroupMBID: string): string {
return `${CAA_GROUP_BASE}/${releaseGroupMBID}/front-250`;
}
function nameToHue(name: string): number {
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return Math.abs(hash) % 360;
}
function extractYear(dateStr: string): string {
if (!dateStr) return '';
return dateStr.substring(0, 4);
@@ -598,7 +582,9 @@ export class ExploreAlbumDetails extends LitElement {
}
// Phase 1: hydrate tracklist from local library if available.
const localRelease = await this.hydrateFromLibrary(mbid);
// Awaited for the side effect of populating the local
// tracklist; the return value isn't currently consumed.
await this.hydrateFromLibrary(mbid);
// Library-only mode: local data is all we show.
if (exploreSettings.libraryOnly) {
@@ -899,7 +885,7 @@ export class ExploreAlbumDetails extends LitElement {
});
const cluster: ReleaseCluster = {
representative: sorted[0],
representative: sorted[0]!,
allReleases: sorted,
fingerprint,
score: 0, // filled in below
@@ -16,13 +16,12 @@ import {
GetTrackThumbnails,
ResolveReleaseGroupMBIDs,
} from '@go/explore/Service';
import type {
MBArtist,
MBReleaseGroup,
LBTopRecording,
LBTopReleaseGroup,
LBSimilarArtist,
} from '@go/explore/Service';
import type { explore } from '@go/models';
type MBArtist = explore.MBArtist;
type MBReleaseGroup = explore.MBReleaseGroup;
type LBTopRecording = explore.LBTopRecording;
type LBTopReleaseGroup = explore.LBTopReleaseGroup;
type LBSimilarArtist = explore.LBSimilarArtist;
import { exploreCache } from '../../store/explore-cache';
import { exploreSettings } from '../../store/explore-settings';
import { libraryStore } from '../../store/library-store';
@@ -31,7 +30,6 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '../library-status-indicator/library-status-indicator.js';
/* ── Constants ── */
const CAA_GROUP_BASE = 'https://coverartarchive.org/release-group';
/** Desired section order for grouping release types. */
const TYPE_ORDER = ['Albums', 'EP', 'Single', 'Other Albums'];
@@ -55,14 +53,6 @@ const NON_STUDIO_SECONDARY_TYPES = new Set([
/* ── Utility functions (duplicated from explore-view per design decision) ── */
function CoverArtGroupURL(releaseGroupMBID: string): string {
return `${CAA_GROUP_BASE}/${releaseGroupMBID}/front-250`;
}
function CoverArtReleaseURL(releaseMBID: string): string {
return `https://coverartarchive.org/release/${releaseMBID}/front-250`;
}
function nameToHue(name: string): number {
let hash = 0;
for (let i = 0; i < name.length; i++) {
@@ -108,7 +98,6 @@ export class ExploreArtistDetails extends LitElement {
@state() private loadingTopReleases = true;
@state() private loadingReleases = true;
@state() private errorArtist = '';
@state() private errorTracks = '';
@state() private errorReleases = '';
@state() private similarArtists: LBSimilarArtist[] = [];
@state() private loadingSimilar = true;
@@ -1297,7 +1286,6 @@ export class ExploreArtistDetails extends LitElement {
void this.batchResolveTrackThumbnails(tracks, mapping);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.errorTracks = msg;
console.error(
`[explore-artist] TopRecordingsForArtist error: ${msg}`,
);
@@ -1436,26 +1424,6 @@ export class ExploreArtistDetails extends LitElement {
}
}
/**
* Resolve a cover art thumbnail for a release group (or release)
* via the backend proxy (local library → disk cache → CAA).
* Updates thumbnailURLs reactively so the image pops in when ready.
*/
private resolveThumbnail(mbid: string, albumName: string, artistName: string) {
if (!mbid || this.thumbnailURLs.has(mbid)) return;
// Mark as in-flight so we don't fire duplicate requests.
this.thumbnailURLs.set(mbid, '');
GetThumbnail(mbid, albumName, artistName)
.then((url) => {
if (url) {
this.thumbnailURLs = new Map(this.thumbnailURLs).set(mbid, url);
}
})
.catch(() => {});
}
/**
* Batch-resolve thumbnails in two phases:
* 1. Batch call for cached/local art — instant.
@@ -2,13 +2,6 @@ import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query as litQuery } from 'lit/decorators.js';
import { designTokens } from '../../styles/tokens.css';
import { Search, GetThumbnail, GetThumbnails, GetArtistImageURL, GetPopularityBatch, RecordSearchClick } from '@go/explore/Service';
import type { ThumbnailRequest } from '@go/explore/Service';
import type {
MBSearchResult,
MBArtist,
MBReleaseGroup,
MBRecording,
} from '@go/explore/Service';
import { libraryStore } from '../../store/library-store';
import { exploreCache } from '../../store/explore-cache';
import { exploreSettings } from '../../store/explore-settings';
@@ -16,6 +9,11 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '../library-status-indicator/library-status-indicator.js';
import '../top-results-row/top-results-row.js';
import type { explore } from '@go/models';
type ThumbnailRequest = explore.ThumbnailRequest;
type MBSearchResult = explore.MBSearchResult;
type MBArtist = explore.MBArtist;
type MBReleaseGroup = explore.MBReleaseGroup;
type MBRecording = explore.MBRecording;
/* ── Constants ── */
const DEBOUNCE_MS = 300;
@@ -32,20 +30,20 @@ function editDistance(a: string, b: string): number {
const matrix: number[][] = [];
for (let i = 0; i <= a.length; i++) matrix[i] = [i];
for (let j = 0; j <= b.length; j++) matrix[0][j] = j;
for (let j = 0; j <= b.length; j++) matrix[0]![j] = j;
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1,
matrix[i][j - 1] + 1,
matrix[i - 1][j - 1] + cost,
matrix[i]![j] = Math.min(
matrix[i - 1]![j]! + 1,
matrix[i]![j - 1]! + 1,
matrix[i - 1]![j - 1]! + cost,
);
}
}
return matrix[a.length][b.length];
return matrix[a.length]![b.length]!;
}
/**
@@ -72,16 +70,7 @@ function fuzzyMatch(query: string, name: string): boolean {
);
}
const MAX_SECTION_RESULTS = 10;
const CAA_GROUP_BASE = 'https://coverartarchive.org/release-group';
/**
* Build a Cover Art Archive URL for a release-group front cover.
* Mirrors the Wails CoverArtGroupURL binding but runs synchronously
* on the frontend — avoids N async round-trips per render cycle.
*/
function CoverArtGroupURL(releaseGroupMBID: string): string {
return `${CAA_GROUP_BASE}/${releaseGroupMBID}/front-250`;
}
/** Hash a string to a hue value 0360 for avatar coloring. */
function nameToHue(name: string): number {
@@ -894,9 +883,9 @@ export class ExploreView extends LitElement {
// Collect all non-empty MBIDs.
const mbids: string[] = [];
for (const a of result.artists) if (a.mbid) mbids.push(a.mbid);
for (const rg of result.releaseGroups) if (rg.mbid) mbids.push(rg.mbid);
for (const r of result.recordings) if (r.mbid) mbids.push(r.mbid);
for (const a of result.artists ?? []) if (a.mbid) mbids.push(a.mbid);
for (const rg of result.releaseGroups ?? []) if (rg.mbid) mbids.push(rg.mbid);
for (const r of result.recordings ?? []) if (r.mbid) mbids.push(r.mbid);
if (mbids.length === 0) return;
@@ -927,16 +916,22 @@ export class ExploreView extends LitElement {
return 0.35 * relevance + 0.50 * logPop + 0.15 * personal;
};
// Re-sort each category.
result.artists.sort((a, b) =>
blendedScore(b.mbid, b.score) - blendedScore(a.mbid, a.score));
result.releaseGroups.sort((a, b) =>
blendedScore(b.mbid, b.score) - blendedScore(a.mbid, a.score));
result.recordings.sort((a, b) =>
blendedScore(b.mbid, b.score) - blendedScore(a.mbid, a.score));
// Re-sort each category. Backend stamps a `score` field
// onto entries before returning them, but the Wails-
// generated MB types don't model it — cast through any to
// read it on the way to the comparator.
const cmp = (a: { mbid: string }, b: { mbid: string }): number =>
blendedScore(b.mbid, (b as any).score ?? 0) - blendedScore(a.mbid, (a as any).score ?? 0);
(result.artists ?? []).sort(cmp);
(result.releaseGroups ?? []).sort(cmp);
(result.recordings ?? []).sort(cmp);
// Trigger re-render.
this.results = { ...result };
// Trigger re-render. MBSearchResult is a Wails-generated
// class with bound methods (convertValues), so request an
// update directly rather than spreading the object — that
// would drop the methods.
this.results = result;
this.requestUpdate();
}
/**
@@ -967,8 +962,7 @@ export class ExploreView extends LitElement {
// Enrich artists: if MB result matches a library artist, add local images.
if (result.artists) {
for (let i = 0; i < result.artists.length; i++) {
const a = result.artists[i];
for (const a of result.artists) {
const lib = (a.mbid && libArtistsByMBID.get(a.mbid)) ||
libArtistsByName.get(a.name.toLowerCase());
if (lib) {
@@ -981,8 +975,7 @@ export class ExploreView extends LitElement {
// Enrich release groups: if MB result matches a library album, use local art.
if (result.releaseGroups) {
for (let i = 0; i < result.releaseGroups.length; i++) {
const rg = result.releaseGroups[i];
for (const rg of result.releaseGroups) {
const lib = rg.mbid ? libAlbumsByMBID.get(rg.mbid) : undefined;
if (lib) {
(rg as any)._coverArt = lib.CoverArtMedium || lib.CoverArtSmall || '';
@@ -1020,7 +1013,7 @@ export class ExploreView extends LitElement {
if (prev.releaseGroups?.length) {
const existing = new Set(
(full.releaseGroups || []).map(
(rg) => `${rg.title}|${rg.artistCredit}`.toLowerCase(),
(rg: MBReleaseGroup) => `${rg.title}|${rg.artistCredit}`.toLowerCase(),
),
);
for (const rg of prev.releaseGroups) {
@@ -1,6 +1,7 @@
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { LibraryController } from '@store/controllers/library-controller';
import { libraryStore } from '@store/library-store';
import type { library } from '@go/models';
import { designTokens } from '../../styles/tokens.css';
@@ -48,9 +49,25 @@ export class LibraryFilter extends LitElement {
}
`];
private unsubscribeStore?: () => void;
override connectedCallback() {
super.connectedCallback();
this.loadLibraries();
// The LibraryController already wires a reactive subscription to
// libraryStore so the host re-renders on change, but it doesn't
// refresh our cached `this.libraries` array. Subscribe directly
// and re-fetch so LibraryAdded / Removed / Renamed events flow
// into the dropdown without a restart.
this.unsubscribeStore = libraryStore.subscribe(() => {
this.loadLibraries();
});
}
override disconnectedCallback() {
super.disconnectedCallback();
this.unsubscribeStore?.();
}
private async loadLibraries() {
@@ -203,7 +203,7 @@ export class LibraryStatusIndicator extends LitElement {
}
function capitalize(s: string): string {
return s.length > 0 ? s[0].toUpperCase() + s.slice(1) : s;
return s.length > 0 ? (s[0] ?? '').toUpperCase() + s.slice(1) : s;
}
declare global {
@@ -5,7 +5,7 @@ import { designTokens } from '../../styles/tokens.css';
import type { DragActiveDetail } from '@utils/drag-controller';
type View = 'home' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'explore' | 'settings';
type View = 'home' | 'playlists' | 'artists' | 'genres' | 'albums' | 'tracks' | 'explore' | 'autotag' | 'settings';
interface NavItem {
id: View;
@@ -149,6 +149,7 @@ export class AppSidebar extends LitElement {
{ id: 'albums', label: 'Albums', icon: 'compact-disc' },
{ id: 'tracks', label: 'Tracks', icon: 'music' },
{ id: 'explore', label: 'Explore', icon: 'globe' },
{ id: 'autotag', label: 'Autotag', icon: 'tag' },
{ id: 'settings', label: 'Settings', icon: 'gear' },
];
@@ -49,7 +49,7 @@ export class TopResultsRow extends LitElement {
// Per-card state: cover images.
private images = new Map<string, string>();
static styles = [
static override styles = [
designTokens,
css`
:host {
@@ -177,7 +177,7 @@ export class TopResultsRow extends LitElement {
`,
];
updated(changed: Map<string, unknown>) {
override updated(changed: Map<string, unknown>) {
if (changed.has('results')) {
this.loadCardData();
}
@@ -227,7 +227,7 @@ export class TopResultsRow extends LitElement {
);
}
render() {
override render() {
if (!this.results?.length) return nothing;
return html`
@@ -19,7 +19,7 @@ import {
CancelBatchWrite,
} from '@go/tagwriter/TagWriter';
import { GetTrackMBIDs } from '@go/library/Library';
import type { TrackMBIDs } from '@go/library/Library';
type TrackMBIDs = library.TrackMBIDs;
import { ImageFilePicker, ReadFile } from '@go/frontendutil/FrontendUtil';
import { libraryStore } from '../../store/library-store';
import { EventsOn, EventsOff } from '@runtime/runtime';
@@ -1646,7 +1646,9 @@ export class TrackDetails extends LitElement {
// shows updated values and cover art. The store
// invalidation is already in-flight from the event;
// these calls await the pending fetch or start one.
const [tracks, albums] = await Promise.all([
// Awaited for the side effect of refreshing the
// store; the album list is consumed elsewhere.
const [tracks] = await Promise.all([
libraryStore.getTracks(),
libraryStore.getAlbums(),
]);
@@ -1776,8 +1778,9 @@ export class TrackDetails extends LitElement {
this.errorMessage = '';
this.cleanupPendingCoverArt();
// Refresh data from library store.
const [tracks, albums] = await Promise.all([
// Refresh data from library store; album list is
// refreshed for side effects only.
const [tracks] = await Promise.all([
libraryStore.getTracks(),
libraryStore.getAlbums(),
]);
+9
View File
@@ -52,6 +52,15 @@ export const Events = {
TrackMetadataChanged: "TrackMetadataChanged",
BatchWriteProgress: "BatchWriteProgress",
// Autotag apply events — emitted while an async ApplyAsync job is in flight so the review UI can render per-folder progress
AutotagApplyStarted: "AutotagApplyStarted",
AutotagApplyProgress: "AutotagApplyProgress",
AutotagApplyFinished: "AutotagApplyFinished",
// Autotag prefetch events — emitted by the background worker that scores pending tagging items so sidebar pills populate without the user having to open each folder
AutotagPrefetchProgress: "AutotagPrefetchProgress",
AutotagPrefetchFinished: "AutotagPrefetchFinished",
// Explore / search index events
IndexStatusChanged: "IndexStatusChanged",
} as const;
+3 -1
View File
@@ -10,7 +10,9 @@
* album detail page → check cache before API calls
*/
import type { MBReleaseGroup, LBTopRecording } from '@go/explore/Service';
import type { explore } from '@go/models';
type MBReleaseGroup = explore.MBReleaseGroup;
type LBTopRecording = explore.LBTopRecording;
/** Cached artist data from search results. */
export interface CachedArtist {
+48
View File
@@ -0,0 +1,48 @@
// Shared helpers for grouping a tracklist by disc number — used by
// both the explore album view and the autotag review UI so the
// rendering rules stay consistent (single-disc albums skip the
// "Disc 1" separator, multi-disc albums show one per disc).
export interface Disced {
discNumber?: number;
position?: number;
}
/**
* Returns true when any track has a discNumber > 1. A list with
* only disc 1 (or no discNumber set) renders without "Disc N"
* headers.
*/
export function isMultiDisc<T extends Disced>(tracks: T[]): boolean {
return tracks.some((t) => (t.discNumber ?? 1) > 1);
}
/**
* Group tracks by disc number, sorting tracks within each disc
* by position. Discs with no number default to disc 1, which
* matches MusicBrainz behaviour for releases that omit the field.
*/
export function groupByDisc<T extends Disced>(tracks: T[]): Map<number, T[]> {
const discMap = new Map<number, T[]>();
for (const track of tracks) {
const disc = track.discNumber ?? 1;
const bucket = discMap.get(disc);
if (bucket) {
bucket.push(track);
} else {
discMap.set(disc, [track]);
}
}
for (const bucket of discMap.values()) {
bucket.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
}
return discMap;
}
/** Returns disc numbers in ascending order from a grouped map. */
export function discNumbers<T>(discMap: Map<number, T[]>): number[] {
return [...discMap.keys()].sort((a, b) => a - b);
}
+92
View File
@@ -0,0 +1,92 @@
// Inline text diff for the autotag review UI. Splits both sides
// into tokens (word runs, whitespace runs, individual punctuation
// chars), runs LCS, and emits a flat segment list the renderer
// drops into spans. Punctuation is its own token so an apostrophe
// type swap (' vs ') shows just the apostrophe as changed instead
// of the whole word — that's the case the visible-but-identical
// titles in the autotag view were tripping on.
export type SegmentType = 'equal' | 'remove' | 'add';
export interface DiffSegment {
type: SegmentType;
text: string;
}
const tokenRe = /(\w+|\s+|[^\w\s])/g;
function tokenize(s: string): string[] {
return s.match(tokenRe) ?? [];
}
/**
* Compute an inline word/punct-level diff between `a` (old) and
* `b` (new), returning a list of segments suitable for inline
* rendering: equal segments come from both sides, remove segments
* come from `a` only, add segments come from `b` only. Adjacent
* segments of the same type are coalesced. Both sides empty
* returns a single empty equal segment.
*/
export function inlineDiff(a: string, b: string): DiffSegment[] {
if (a === b) {
return [{ type: 'equal', text: a }];
}
if (a === '') {
return [{ type: 'add', text: b }];
}
if (b === '') {
return [{ type: 'remove', text: a }];
}
const ta = tokenize(a);
const tb = tokenize(b);
const m = ta.length;
const n = tb.length;
// LCS table — O(m*n) memory. Track titles cap out at ~100
// tokens so this stays trivially small.
const dp: number[][] = Array.from({ length: m + 1 }, () =>
new Array<number>(n + 1).fill(0),
);
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
if (ta[i - 1] === tb[j - 1]) {
dp[i]![j] = dp[i - 1]![j - 1]! + 1;
} else {
dp[i]![j] = Math.max(dp[i - 1]![j]!, dp[i]![j - 1]!);
}
}
}
// Backtrack to build the op list (reversed).
const ops: DiffSegment[] = [];
let i = m;
let j = n;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && ta[i - 1] === tb[j - 1]) {
ops.push({ type: 'equal', text: ta[i - 1]! });
i--;
j--;
} else if (j > 0 && (i === 0 || dp[i]![j - 1]! >= dp[i - 1]![j]!)) {
ops.push({ type: 'add', text: tb[j - 1]! });
j--;
} else {
ops.push({ type: 'remove', text: ta[i - 1]! });
i--;
}
}
ops.reverse();
// Coalesce adjacent same-type segments so the renderer outputs
// one span per visual run instead of per token.
const merged: DiffSegment[] = [];
for (const seg of ops) {
const last = merged[merged.length - 1];
if (last && last.type === seg.type) {
last.text += seg.text;
} else {
merged.push({ type: seg.type, text: seg.text });
}
}
return merged;
}