diff --git a/src/frontend/src/app/app.component.html b/src/frontend/src/app/app.component.html
index d72a041..fbc5216 100644
--- a/src/frontend/src/app/app.component.html
+++ b/src/frontend/src/app/app.component.html
@@ -4,15 +4,18 @@
Jarri Spooty
- v{{version}}
+ v{{ version }}
Spotify metadata → YouTube candidate scoring → Local archive
-
- {{ spotifyConnected ? 'Spotify connected' : 'Spotify disconnected' }}
+
+ {{ spotifyConnected ? "Spotify connected" : "Spotify disconnected" }}
@@ -49,7 +52,11 @@
Reset Current Tab
-
-
- No playlists
+
+
+ No playlists
+
@@ -184,7 +212,10 @@
-
+
No songs
@@ -193,9 +224,14 @@
- No downloaded files found.
+
+ No downloaded files found.
+
-
+
{{ group.folder }}
-
+
{{ file.name }}
{{ folderPath(file) }}
@@ -256,47 +308,157 @@
-
+
-
- - Spotify URL
- - {{ selectedTrack.spotifyUrl || 'none' }}
- - Status
- - {{ trackStatusLabel(selectedTrack.status) }}
- - Download attempts
- - {{ selectedTrack.downloadAttemptCount || 0 }}
- - Error
- - {{ selectedTrack.error || 'none' }}
-
+
+
+ - Spotify URL
+ -
+ {{
+ selectedTrack.spotifyUrl || "none"
+ }}
+
+ - Status
+ - {{ trackStatusLabel(selectedTrack.status) }}
+ - Download attempts
+ - {{ selectedTrack.downloadAttemptCount || 0 }}
+ - Error class
+ - {{ errorClass(trackTruth) }}
+ - Error summary
+ - {{ errorSummary(trackTruth) }}
+ - Error detail
+ -
+
+ Show backend evidence
+ {{ errorDetail(trackTruth) }}
+
+ none
+
+
-
-
Other options
-
- Selected candidate:
- {{ selectedTrack.youtubeUrl }}
-
-
Rejected candidates: {{ rejectedUrls(selectedTrack).length }}
-
{{ url }}
-
- Candidate alternatives are not persisted yet. Future scoring history will appear here.
-
-
+
+
Selected candidate
+
+
+ - URL
+ -
+ {{
+ candidate.url
+ }}
+
+ - Title
+ - {{ candidate.title || "unknown" }}
+ - Author
+ - {{ candidate.author || "unknown" }}
+ - Score
+ - {{ candidate.score ?? "unknown" }}
+ - Reason
+ - {{ candidate.reason || "unknown" }}
+
+
+
+ No selected candidate.
+
+
+
Rejected candidates:
+ {{ rejectedCandidateCount(trackTruth) }}
+
+
+ - URL
+ -
+ {{
+ candidate.url
+ }}
+
+ - Title
+ - {{ candidate.title || "unknown" }}
+ - Author
+ - {{ candidate.author || "unknown" }}
+ - Score
+ - {{ candidate.score ?? "unknown" }}
+ - Reason
+ - {{ candidate.reason || "unknown" }}
+ - Rejection class
+ - {{ candidate.rejectionClass || "unknown" }}
+ - Rejection summary
+ - {{ candidate.rejectionSummary || "unknown" }}
+ - Rejected at
+ - {{ candidate.rejectedAt || "unknown" }}
+
+
+
+ No rejected candidates.
+
+
+
+
+
+
+
+
+ {{
+ operationsSnapshotError ||
+ "Waiting for backend operations truth."
+ }}
+
+
+ Refresh truth
+
+
+
-
Select a track row inside an expanded playlist to inspect Spotify, YouTube, retry, and error details.
+
+ Select a track row inside an expanded playlist to inspect
+ Spotify, YouTube, retry, and error details.
+
= {
const DEFAULT_TAB_PANELS: Record = {
intake: [
makePanel('source-intake', 24, 24, 520, 336, 'intake-source-intake'),
- makePanel('queue-observatory', 568, 24, 520, 260, 'intake-queue-observatory'),
+ makePanel(
+ 'queue-observatory',
+ 568,
+ 24,
+ 520,
+ 260,
+ 'intake-queue-observatory',
+ ),
makePanel('playlist-history', 24, 392, 760, 448, 'intake-playlist-history'),
makePanel('single-songs', 820, 392, 520, 448, 'intake-single-songs'),
],
archive: [
makePanel('archive-browser', 24, 24, 900, 720, 'archive-archive-browser'),
makePanel('source-intake', 960, 24, 420, 336, 'archive-source-intake'),
- makePanel('queue-observatory', 960, 396, 420, 260, 'archive-queue-observatory'),
+ makePanel(
+ 'queue-observatory',
+ 960,
+ 396,
+ 420,
+ 260,
+ 'archive-queue-observatory',
+ ),
],
diagnostics: [
- makePanel('candidate-inspector', 24, 24, 620, 720, 'diagnostics-candidate-inspector'),
- makePanel('playlist-history', 680, 24, 620, 520, 'diagnostics-playlist-history'),
- makePanel('queue-observatory', 1320, 24, 420, 260, 'diagnostics-queue-observatory'),
- makePanel('archive-browser', 1320, 320, 420, 420, 'diagnostics-archive-browser'),
+ makePanel(
+ 'candidate-inspector',
+ 24,
+ 24,
+ 620,
+ 720,
+ 'diagnostics-candidate-inspector',
+ ),
+ makePanel(
+ 'playlist-history',
+ 680,
+ 24,
+ 620,
+ 520,
+ 'diagnostics-playlist-history',
+ ),
+ makePanel(
+ 'queue-observatory',
+ 1320,
+ 24,
+ 420,
+ 260,
+ 'diagnostics-queue-observatory',
+ ),
+ makePanel(
+ 'archive-browser',
+ 1320,
+ 320,
+ 420,
+ 420,
+ 'diagnostics-archive-browser',
+ ),
],
};
@@ -106,9 +157,11 @@ export function defaultWorkspaceState(): SpootyWorkspaceState {
return defaultWorkspaceStateForTab('intake');
}
-export function defaultWorkspaceStateForTab(tab: SpootyWorkspaceTab): SpootyWorkspaceState {
+export function defaultWorkspaceStateForTab(
+ tab: SpootyWorkspaceTab,
+): SpootyWorkspaceState {
return {
- panels: DEFAULT_TAB_PANELS[tab].map(panel => ({...panel})),
+ panels: DEFAULT_TAB_PANELS[tab].map((panel) => ({ ...panel })),
};
}
@@ -133,8 +186,8 @@ export function loadWorkspaceState(): SpootyWorkspaceState {
const parsed = JSON.parse(raw) as SpootyWorkspaceState;
const panels = parsed.panels
- ?.filter(panel => panel?.id && panel?.type && PANEL_TITLES[panel.type])
- .map(panel => ({
+ ?.filter((panel) => panel?.id && panel?.type && PANEL_TITLES[panel.type])
+ .map((panel) => ({
...panel,
title: PANEL_TITLES[panel.type],
x: snap(panel.x),
@@ -143,7 +196,7 @@ export function loadWorkspaceState(): SpootyWorkspaceState {
h: Math.max(MIN_PANEL_HEIGHT, snap(panel.h)),
}));
- return panels?.length ? {panels} : defaultWorkspaceState();
+ return panels?.length ? { panels } : defaultWorkspaceState();
} catch {
return defaultWorkspaceState();
}
@@ -156,11 +209,23 @@ export function loadWorkspaceTabsState(): SpootyWorkspaceTabsState {
if (raw) {
const parsed = JSON.parse(raw) as SpootyWorkspaceTabsState;
return {
- activeTab: parsed.activeTab && WORKSPACE_TAB_LABELS[parsed.activeTab] ? parsed.activeTab : 'intake',
+ activeTab:
+ parsed.activeTab && WORKSPACE_TAB_LABELS[parsed.activeTab]
+ ? parsed.activeTab
+ : 'intake',
tabs: {
- intake: sanitizeWorkspaceState(parsed.tabs?.intake, defaultWorkspaceStateForTab('intake')),
- archive: sanitizeWorkspaceState(parsed.tabs?.archive, defaultWorkspaceStateForTab('archive')),
- diagnostics: sanitizeWorkspaceState(parsed.tabs?.diagnostics, defaultWorkspaceStateForTab('diagnostics')),
+ intake: sanitizeWorkspaceState(
+ parsed.tabs?.intake,
+ defaultWorkspaceStateForTab('intake'),
+ ),
+ archive: sanitizeWorkspaceState(
+ parsed.tabs?.archive,
+ defaultWorkspaceStateForTab('archive'),
+ ),
+ diagnostics: sanitizeWorkspaceState(
+ parsed.tabs?.diagnostics,
+ defaultWorkspaceStateForTab('diagnostics'),
+ ),
},
};
}
@@ -182,8 +247,8 @@ function sanitizeWorkspaceState(
fallback: SpootyWorkspaceState,
): SpootyWorkspaceState {
const panels = state?.panels
- ?.filter(panel => panel?.id && panel?.type && PANEL_TITLES[panel.type])
- .map(panel => ({
+ ?.filter((panel) => panel?.id && panel?.type && PANEL_TITLES[panel.type])
+ .map((panel) => ({
...panel,
title: PANEL_TITLES[panel.type],
x: snap(panel.x),
@@ -192,7 +257,7 @@ function sanitizeWorkspaceState(
h: Math.max(MIN_PANEL_HEIGHT, snap(panel.h)),
}));
- return panels?.length ? {panels} : fallback;
+ return panels?.length ? { panels } : fallback;
}
function snap(value: number): number {
@@ -200,16 +265,16 @@ function snap(value: number): number {
}
@Component({
- selector: 'app-root',
- imports: [CommonModule, FormsModule, NgFor, PlaylistBoxComponent],
- templateUrl: './app.component.html',
- styleUrl: './app.component.scss',
- standalone: true,
+ selector: 'app-root',
+ imports: [CommonModule, FormsModule, NgFor, PlaylistBoxComponent],
+ templateUrl: './app.component.html',
+ styleUrl: './app.component.scss',
+ standalone: true,
})
export class AppComponent implements OnDestroy {
-
- url = ''
- private readonly spotifyUrlPattern = /^https:\/\/open\.spotify\.com\/(track|playlist|album|artist)\/[a-zA-Z0-9]+/;
+ url = '';
+ private readonly spotifyUrlPattern =
+ /^https:\/\/open\.spotify\.com\/(track|playlist|album|artist)\/[a-zA-Z0-9]+/;
private interaction?: {
mode: 'drag' | 'resize';
panelId: string;
@@ -220,6 +285,8 @@ export class AppComponent implements OnDestroy {
startPanelW: number;
startPanelH: number;
};
+ private readonly operationsRefreshMs = 5000;
+ private operationsRefreshTimer?: number;
readonly panelTypes: SpootyPanelType[] = [
'source-intake',
'queue-observatory',
@@ -228,7 +295,11 @@ export class AppComponent implements OnDestroy {
'archive-browser',
'candidate-inspector',
];
- readonly workspaceTabs: SpootyWorkspaceTab[] = ['intake', 'archive', 'diagnostics'];
+ readonly workspaceTabs: SpootyWorkspaceTab[] = [
+ 'intake',
+ 'archive',
+ 'diagnostics',
+ ];
readonly archiveSortModes: ArchiveSortMode[] = ['newest', 'name', 'size'];
selectedPanelType: SpootyPanelType = 'source-intake';
workspaceTabsState: SpootyWorkspaceTabsState = loadWorkspaceTabsState();
@@ -237,8 +308,12 @@ export class AppComponent implements OnDestroy {
return this.spotifyUrlPattern.test(this.url);
}
createLoading$ = this.playlistService.createLoading$;
- playlists$ = this.playlistService.all$.pipe(map(items => items.filter(item => !item.isTrack)));
- songs$ = this.playlistService.all$.pipe(map(items => items.filter(item => item.isTrack)));
+ playlists$ = this.playlistService.all$.pipe(
+ map((items) => items.filter((item) => !item.isTrack)),
+ );
+ songs$ = this.playlistService.all$.pipe(
+ map((items) => items.filter((item) => item.isTrack)),
+ );
allTracks$ = this.trackService.all$;
selectedTrack$ = this.trackService.selectedTrack$;
version = this.versionService.getVersion();
@@ -250,6 +325,8 @@ export class AppComponent implements OnDestroy {
archiveSearch = '';
archiveSort: ArchiveSortMode = 'newest';
trackStatuses = TrackStatusEnum;
+ operationsSnapshot?: OperationsSnapshot;
+ operationsSnapshotError = '';
get activeWorkspaceTab(): SpootyWorkspaceTab {
return this.workspaceTabsState.activeTab;
@@ -270,10 +347,18 @@ export class AppComponent implements OnDestroy {
this.checkSpotifyStatus();
this.fetchPlaylists();
this.refreshArchive();
+ this.refreshOperationsSnapshot();
+ this.operationsRefreshTimer = window.setInterval(
+ () => this.refreshOperationsSnapshot(),
+ this.operationsRefreshMs,
+ );
}
ngOnDestroy(): void {
this.stopWorkspaceInteraction();
+ if (this.operationsRefreshTimer) {
+ window.clearInterval(this.operationsRefreshTimer);
+ }
}
private bootstrapAuthTokenFromUrl(): void {
@@ -337,6 +422,18 @@ export class AppComponent implements OnDestroy {
});
}
+ refreshOperationsSnapshot(): void {
+ this.http.get('/api/operations/snapshot').subscribe({
+ next: (snapshot) => {
+ this.operationsSnapshot = snapshot;
+ this.operationsSnapshotError = '';
+ },
+ error: () => {
+ this.operationsSnapshotError = 'Operations snapshot is unavailable.';
+ },
+ });
+ }
+
formatBytes(sizeBytes: number): string {
if (sizeBytes < 1024) {
return `${sizeBytes} B`;
@@ -372,31 +469,56 @@ export class AppComponent implements OnDestroy {
}
}
- rejectedUrls(track: Track): string[] {
- if (Array.isArray(track.rejectedYoutubeUrls)) {
- return track.rejectedYoutubeUrls;
- }
-
- if (!track.rejectedYoutubeUrls) {
- return [];
- }
-
- try {
- const parsed = JSON.parse(track.rejectedYoutubeUrls);
- return Array.isArray(parsed) ? parsed.filter(item => typeof item === 'string') : [];
- } catch {
- return [];
- }
- }
-
selectTrack(track: Track): void {
this.trackService.select(track);
+ this.refreshOperationsSnapshot();
}
clearSelectedTrack(): void {
this.trackService.clearSelection();
}
+ selectedTrackTruth(track: Track): TrackTruth | undefined {
+ return this.findTrackTruth(track.id);
+ }
+
+ selectedCandidate(truth?: TrackTruth): SelectedCandidateTruth | undefined {
+ return truth?.selectedCandidate;
+ }
+
+ rejectedCandidates(truth?: TrackTruth): RejectedCandidateTruth[] {
+ return truth?.rejectedCandidates || [];
+ }
+
+ rejectedCandidateCount(truth?: TrackTruth): number {
+ return truth?.rejectedCandidateCount || 0;
+ }
+
+ errorClass(truth?: TrackTruth): string {
+ return truth?.errorClass || 'none';
+ }
+
+ errorSummary(truth?: TrackTruth): string {
+ return truth?.errorSummary || 'none';
+ }
+
+ errorDetail(truth?: TrackTruth): string {
+ return truth?.errorDetail || '';
+ }
+
+ private findTrackTruth(id: number): TrackTruth | undefined {
+ if (!this.operationsSnapshot) {
+ return undefined;
+ }
+
+ return [
+ ...this.operationsSnapshot.active.searching,
+ ...this.operationsSnapshot.active.downloading,
+ ...this.operationsSnapshot.recentFailures,
+ ...this.operationsSnapshot.recentTracks,
+ ].find((track) => track.id === id);
+ }
+
getWorkspaceTabLabel(tab: SpootyWorkspaceTab): string {
return WORKSPACE_TAB_LABELS[tab];
}
@@ -410,7 +532,9 @@ export class AppComponent implements OnDestroy {
}
resetLayout(): void {
- this.setActiveWorkspace(defaultWorkspaceStateForTab(this.activeWorkspaceTab));
+ this.setActiveWorkspace(
+ defaultWorkspaceStateForTab(this.activeWorkspaceTab),
+ );
}
resetAllLayouts(): void {
@@ -422,7 +546,9 @@ export class AppComponent implements OnDestroy {
const files = this.archiveListing?.files || [];
const query = this.archiveSearch.trim().toLowerCase();
const filtered = query
- ? files.filter(file => `${file.name} ${file.path}`.toLowerCase().includes(query))
+ ? files.filter((file) =>
+ `${file.name} ${file.path}`.toLowerCase().includes(query),
+ )
: [...files];
return filtered.sort((a, b) => {
@@ -441,12 +567,12 @@ export class AppComponent implements OnDestroy {
archiveGroups(): { folder: string; files: ArchiveFile[] }[] {
const groups = new Map();
- this.filteredArchiveFiles().forEach(file => {
+ this.filteredArchiveFiles().forEach((file) => {
const folder = this.topLevelFolder(file);
groups.set(folder, [...(groups.get(folder) || []), file]);
});
- return [...groups.entries()].map(([folder, files]) => ({folder, files}));
+ return [...groups.entries()].map(([folder, files]) => ({ folder, files }));
}
archiveFileCount(): number {
@@ -454,7 +580,10 @@ export class AppComponent implements OnDestroy {
}
archiveTotalBytes(): number {
- return this.filteredArchiveFiles().reduce((total, file) => total + file.sizeBytes, 0);
+ return this.filteredArchiveFiles().reduce(
+ (total, file) => total + file.sizeBytes,
+ 0,
+ );
}
topLevelFolder(file: ArchiveFile): string {
@@ -490,7 +619,7 @@ export class AppComponent implements OnDestroy {
}
this.setActiveWorkspace({
- panels: this.workspace.panels.filter(panel => panel.id !== panelId),
+ panels: this.workspace.panels.filter((panel) => panel.id !== panelId),
});
}
@@ -583,7 +712,9 @@ export class AppComponent implements OnDestroy {
private startWorkspaceInteraction(): void {
document.addEventListener('pointermove', this.handlePointerMove);
- document.addEventListener('pointerup', this.handlePointerUp, {once: true});
+ document.addEventListener('pointerup', this.handlePointerUp, {
+ once: true,
+ });
}
private stopWorkspaceInteraction(): void {
@@ -593,7 +724,7 @@ export class AppComponent implements OnDestroy {
}
private bringPanelToFront(panelId: string): void {
- const panel = this.workspace.panels.find(item => item.id === panelId);
+ const panel = this.workspace.panels.find((item) => item.id === panelId);
if (!panel) {
return;
@@ -601,16 +732,19 @@ export class AppComponent implements OnDestroy {
this.setActiveWorkspace({
panels: [
- ...this.workspace.panels.filter(item => item.id !== panelId),
+ ...this.workspace.panels.filter((item) => item.id !== panelId),
panel,
],
});
}
- private updatePanel(panelId: string, changes: Partial): void {
+ private updatePanel(
+ panelId: string,
+ changes: Partial,
+ ): void {
this.setActiveWorkspace({
- panels: this.workspace.panels.map(panel =>
- panel.id === panelId ? {...panel, ...changes} : panel
+ panels: this.workspace.panels.map((panel) =>
+ panel.id === panelId ? { ...panel, ...changes } : panel,
),
});
}
@@ -627,6 +761,9 @@ export class AppComponent implements OnDestroy {
}
private saveWorkspaceState(): void {
- localStorage.setItem(WORKSPACE_TABS_STORAGE_KEY, JSON.stringify(this.workspaceTabsState));
+ localStorage.setItem(
+ WORKSPACE_TABS_STORAGE_KEY,
+ JSON.stringify(this.workspaceTabsState),
+ );
}
}
diff --git a/src/frontend/src/app/models/operations-snapshot.ts b/src/frontend/src/app/models/operations-snapshot.ts
new file mode 100644
index 0000000..68ca663
--- /dev/null
+++ b/src/frontend/src/app/models/operations-snapshot.ts
@@ -0,0 +1,61 @@
+export interface SelectedCandidateTruth {
+ url: string;
+ title?: string;
+ author?: string;
+ score?: number;
+ reason?: string;
+}
+
+export interface RejectedCandidateTruth {
+ url: string;
+ title?: string;
+ author?: string;
+ score?: number;
+ reason?: string;
+ rejectionClass?: string;
+ rejectionSummary?: string;
+ rejectedAt?: string;
+}
+
+export interface TrackTruth {
+ id: number;
+ artist: string;
+ name: string;
+ status: string;
+ spotifyUrl?: string;
+ youtubeUrl?: string;
+ selectedCandidate?: SelectedCandidateTruth;
+ downloadAttemptCount?: number;
+ errorClass?: string;
+ errorSummary?: string;
+ errorDetail?: string;
+ rejectedCandidateCount: number;
+ rejectedCandidates: RejectedCandidateTruth[];
+}
+
+export interface OperationsSnapshot {
+ generatedAt: string;
+ runtime: {
+ operation: string;
+ spotifyConnected: boolean;
+ searchQueueDepth: number;
+ downloadQueueDepth: number;
+ activeSearches: number;
+ activeDownloads: number;
+ };
+ counters: {
+ playlists: number;
+ singleSongs: number;
+ tracks: number;
+ active: number;
+ completed: number;
+ failed: number;
+ retries: number;
+ };
+ active: {
+ searching: TrackTruth[];
+ downloading: TrackTruth[];
+ };
+ recentFailures: TrackTruth[];
+ recentTracks: TrackTruth[];
+}
diff --git a/src/frontend/src/styles.scss b/src/frontend/src/styles.scss
index 3c2b9e0..9adc9a9 100644
--- a/src/frontend/src/styles.scss
+++ b/src/frontend/src/styles.scss
@@ -112,6 +112,62 @@ body {
overflow-wrap: anywhere;
}
+.candidate-card {
+ display: block;
+ padding: 10px;
+ border: 1px solid rgba(126, 255, 180, 0.14);
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.05);
+}
+
+.candidate-card,
+.candidate-card dl {
+ margin: 0;
+}
+
+.candidate-card dt {
+ color: #f1fff5;
+ font-size: 0.76rem;
+ font-weight: 900;
+ text-transform: uppercase;
+}
+
+.candidate-card dd {
+ margin: 0 0 8px;
+ color: #bcd1c4;
+ overflow-wrap: anywhere;
+}
+
+.candidate-card dd:last-child {
+ margin-bottom: 0;
+}
+
+.rejected-candidate-card {
+ border-color: rgba(255, 214, 102, 0.18);
+}
+
+.truth-detail summary {
+ color: #9fffc3;
+ cursor: pointer;
+ font-weight: 800;
+}
+
+.truth-detail pre {
+ margin-top: 8px;
+ padding: 10px;
+ border: 1px solid rgba(126, 255, 180, 0.14);
+ border-radius: 8px;
+ background: rgba(0, 0, 0, 0.22);
+ color: #dceee4;
+ font-size: 0.78rem;
+ overflow: auto;
+ white-space: pre-wrap;
+}
+
+.empty-state.compact {
+ padding: 10px;
+}
+
@media (max-width: 900px) {
.workspace-tabs {
position: static;