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(),
]);