Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
422d372122 | ||
|
|
e744e5d713 | ||
|
|
3e96e5fd87 |
@@ -6,19 +6,19 @@ The MVP reports truth Spooty already owns without a schema migration:
|
|||||||
|
|
||||||
- track state from `TrackEntity.status`
|
- track state from `TrackEntity.status`
|
||||||
- selected YouTube URL from `TrackEntity.youtubeUrl`
|
- selected YouTube URL from `TrackEntity.youtubeUrl`
|
||||||
|
- selected YouTube candidate title, author, score, and reason from `TrackEntity`
|
||||||
- raw failure evidence from `TrackEntity.error`
|
- raw failure evidence from `TrackEntity.error`
|
||||||
- download retry count from `TrackEntity.downloadAttemptCount`
|
- download retry count from `TrackEntity.downloadAttemptCount`
|
||||||
- rejected YouTube candidates from `TrackEntity.rejectedYoutubeUrls`
|
- rejected YouTube candidate explanations from `TrackEntity.rejectedYoutubeCandidatesJson`
|
||||||
|
- legacy rejected YouTube candidate URLs from `TrackEntity.rejectedYoutubeUrls`
|
||||||
- playlist and single-song counts from `PlaylistEntity.isTrack`
|
- playlist and single-song counts from `PlaylistEntity.isTrack`
|
||||||
- queue depth and active jobs from BullMQ queues `track-search-processor` and `track-download-processor`
|
- queue depth and active jobs from BullMQ queues `track-search-processor` and `track-download-processor`
|
||||||
- Spotify connection presence from the stored Spotify user token
|
- Spotify connection presence from the stored Spotify user token
|
||||||
|
|
||||||
The endpoint intentionally does not infer candidate scoring history. Today, selected candidate title, author, score, and reason are logged during search but are not persisted. Rejected candidates are persisted only as URL strings.
|
The endpoint intentionally does not infer candidate scoring history. Selected candidate metadata is persisted when Spooty chooses a candidate. Rejected candidate metadata is persisted when a selected candidate is rejected by manual retry or download failure. Older rows and URL-only cases fall back to rejected candidate objects containing only `url`.
|
||||||
|
|
||||||
Future persistence work is required for:
|
Future persistence work is required for:
|
||||||
|
|
||||||
- selected candidate title, author, score, and scoring reason
|
|
||||||
- full searched candidate history
|
- full searched candidate history
|
||||||
- structured rejected candidate metadata beyond URL
|
|
||||||
- structured error class fields stored at write time instead of classified from the persisted raw error string
|
- structured error class fields stored at write time instead of classified from the persisted raw error string
|
||||||
- operation attempt history across search and download phases
|
- operation attempt history across search and download phases
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ErrorClass,
|
ErrorClass,
|
||||||
FailureTruth,
|
FailureTruth,
|
||||||
OperationsSnapshot,
|
OperationsSnapshot,
|
||||||
|
RejectedCandidateTruth,
|
||||||
SpootyOperation,
|
SpootyOperation,
|
||||||
TrackTruth,
|
TrackTruth,
|
||||||
} from './operations.types';
|
} from './operations.types';
|
||||||
@@ -176,6 +177,25 @@ export class OperationsService {
|
|||||||
status: TrackStatusEnum[track.status] || String(track.status),
|
status: TrackStatusEnum[track.status] || String(track.status),
|
||||||
...(track.spotifyUrl ? { spotifyUrl: track.spotifyUrl } : {}),
|
...(track.spotifyUrl ? { spotifyUrl: track.spotifyUrl } : {}),
|
||||||
...(track.youtubeUrl ? { youtubeUrl: track.youtubeUrl } : {}),
|
...(track.youtubeUrl ? { youtubeUrl: track.youtubeUrl } : {}),
|
||||||
|
...(track.youtubeUrl
|
||||||
|
? {
|
||||||
|
selectedCandidate: {
|
||||||
|
url: track.youtubeUrl,
|
||||||
|
...(track.selectedYoutubeTitle
|
||||||
|
? { title: track.selectedYoutubeTitle }
|
||||||
|
: {}),
|
||||||
|
...(track.selectedYoutubeAuthor
|
||||||
|
? { author: track.selectedYoutubeAuthor }
|
||||||
|
: {}),
|
||||||
|
...(typeof track.selectedYoutubeScore === 'number'
|
||||||
|
? { score: track.selectedYoutubeScore }
|
||||||
|
: {}),
|
||||||
|
...(track.selectedYoutubeReason
|
||||||
|
? { reason: track.selectedYoutubeReason }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(typeof track.downloadAttemptCount === 'number'
|
...(typeof track.downloadAttemptCount === 'number'
|
||||||
? { downloadAttemptCount: track.downloadAttemptCount }
|
? { downloadAttemptCount: track.downloadAttemptCount }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -185,7 +205,15 @@ export class OperationsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseRejectedCandidates(track: TrackEntity): string[] {
|
private parseRejectedCandidates(
|
||||||
|
track: TrackEntity,
|
||||||
|
): RejectedCandidateTruth[] {
|
||||||
|
const structured = this.parseRejectedCandidateJson(track);
|
||||||
|
|
||||||
|
if (structured.length) {
|
||||||
|
return structured;
|
||||||
|
}
|
||||||
|
|
||||||
if (!track.rejectedYoutubeUrls) {
|
if (!track.rejectedYoutubeUrls) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -193,7 +221,52 @@ export class OperationsService {
|
|||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(track.rejectedYoutubeUrls);
|
const parsed = JSON.parse(track.rejectedYoutubeUrls);
|
||||||
return Array.isArray(parsed)
|
return Array.isArray(parsed)
|
||||||
? parsed.filter((item) => typeof item === 'string')
|
? parsed
|
||||||
|
.filter((item) => typeof item === 'string')
|
||||||
|
.map((url) => ({ url }))
|
||||||
|
: [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseRejectedCandidateJson(
|
||||||
|
track: TrackEntity,
|
||||||
|
): RejectedCandidateTruth[] {
|
||||||
|
if (!track.rejectedYoutubeCandidatesJson) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(track.rejectedYoutubeCandidatesJson);
|
||||||
|
return Array.isArray(parsed)
|
||||||
|
? parsed
|
||||||
|
.filter(
|
||||||
|
(item) =>
|
||||||
|
!!item &&
|
||||||
|
typeof item === 'object' &&
|
||||||
|
typeof item.url === 'string',
|
||||||
|
)
|
||||||
|
.map((item) => ({
|
||||||
|
url: item.url,
|
||||||
|
...(typeof item.title === 'string' ? { title: item.title } : {}),
|
||||||
|
...(typeof item.author === 'string'
|
||||||
|
? { author: item.author }
|
||||||
|
: {}),
|
||||||
|
...(typeof item.score === 'number' ? { score: item.score } : {}),
|
||||||
|
...(typeof item.reason === 'string'
|
||||||
|
? { reason: item.reason }
|
||||||
|
: {}),
|
||||||
|
...(typeof item.rejectionClass === 'string'
|
||||||
|
? { rejectionClass: item.rejectionClass }
|
||||||
|
: {}),
|
||||||
|
...(typeof item.rejectionSummary === 'string'
|
||||||
|
? { rejectionSummary: item.rejectionSummary }
|
||||||
|
: {}),
|
||||||
|
...(typeof item.rejectedAt === 'string'
|
||||||
|
? { rejectedAt: item.rejectedAt }
|
||||||
|
: {}),
|
||||||
|
}))
|
||||||
: [];
|
: [];
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -16,6 +16,27 @@ export type ErrorClass =
|
|||||||
| 'FILE_WRITE_FAILED'
|
| 'FILE_WRITE_FAILED'
|
||||||
| 'UNKNOWN_DOWNLOAD_ERROR';
|
| 'UNKNOWN_DOWNLOAD_ERROR';
|
||||||
|
|
||||||
|
export type RejectionClass =
|
||||||
|
| 'DOWNLOAD_FAILED'
|
||||||
|
| 'YOUTUBE_VIDEO_UNAVAILABLE'
|
||||||
|
| 'YOUTUBE_AGE_GATED'
|
||||||
|
| 'YOUTUBE_NO_FORMATS'
|
||||||
|
| 'YOUTUBE_PRIVATE_VIDEO'
|
||||||
|
| 'YOUTUBE_EXTRACTION_FAILURE'
|
||||||
|
| 'UNKNOWN_DOWNLOAD_ERROR'
|
||||||
|
| 'MANUAL_RETRY';
|
||||||
|
|
||||||
|
export interface RejectedCandidateTruth {
|
||||||
|
url: string;
|
||||||
|
title?: string;
|
||||||
|
author?: string;
|
||||||
|
score?: number;
|
||||||
|
reason?: string;
|
||||||
|
rejectionClass?: RejectionClass;
|
||||||
|
rejectionSummary?: string;
|
||||||
|
rejectedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TrackTruth {
|
export interface TrackTruth {
|
||||||
id: number;
|
id: number;
|
||||||
artist: string;
|
artist: string;
|
||||||
@@ -23,12 +44,19 @@ export interface TrackTruth {
|
|||||||
status: string;
|
status: string;
|
||||||
spotifyUrl?: string;
|
spotifyUrl?: string;
|
||||||
youtubeUrl?: string;
|
youtubeUrl?: string;
|
||||||
|
selectedCandidate?: {
|
||||||
|
url: string;
|
||||||
|
title?: string;
|
||||||
|
author?: string;
|
||||||
|
score?: number;
|
||||||
|
reason?: string;
|
||||||
|
};
|
||||||
downloadAttemptCount?: number;
|
downloadAttemptCount?: number;
|
||||||
errorClass?: ErrorClass;
|
errorClass?: ErrorClass;
|
||||||
errorSummary?: string;
|
errorSummary?: string;
|
||||||
errorDetail?: string;
|
errorDetail?: string;
|
||||||
rejectedCandidateCount: number;
|
rejectedCandidateCount: number;
|
||||||
rejectedCandidates: string[];
|
rejectedCandidates: RejectedCandidateTruth[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FailureTruth extends TrackTruth {}
|
export interface FailureTruth extends TrackTruth {}
|
||||||
|
|||||||
@@ -27,9 +27,24 @@ export class TrackEntity {
|
|||||||
@Column({ nullable: true })
|
@Column({ nullable: true })
|
||||||
youtubeUrl?: string;
|
youtubeUrl?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
selectedYoutubeTitle?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
selectedYoutubeAuthor?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
selectedYoutubeScore?: number;
|
||||||
|
|
||||||
|
@Column({ nullable: true, type: 'text' })
|
||||||
|
selectedYoutubeReason?: string;
|
||||||
|
|
||||||
@Column({ nullable: true, type: 'text' })
|
@Column({ nullable: true, type: 'text' })
|
||||||
rejectedYoutubeUrls?: string;
|
rejectedYoutubeUrls?: string;
|
||||||
|
|
||||||
|
@Column({ nullable: true, type: 'text' })
|
||||||
|
rejectedYoutubeCandidatesJson?: string;
|
||||||
|
|
||||||
@Column({ default: 0 })
|
@Column({ default: 0 })
|
||||||
downloadAttemptCount?: number;
|
downloadAttemptCount?: number;
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,32 @@ type ClientTrack = Omit<Partial<TrackEntity>, 'rejectedYoutubeUrls'> & {
|
|||||||
rejectedYoutubeUrls?: string[];
|
rejectedYoutubeUrls?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RejectionClass =
|
||||||
|
| 'DOWNLOAD_FAILED'
|
||||||
|
| 'YOUTUBE_VIDEO_UNAVAILABLE'
|
||||||
|
| 'YOUTUBE_AGE_GATED'
|
||||||
|
| 'YOUTUBE_NO_FORMATS'
|
||||||
|
| 'YOUTUBE_PRIVATE_VIDEO'
|
||||||
|
| 'YOUTUBE_EXTRACTION_FAILURE'
|
||||||
|
| 'UNKNOWN_DOWNLOAD_ERROR'
|
||||||
|
| 'MANUAL_RETRY';
|
||||||
|
|
||||||
|
interface RejectedYoutubeCandidate {
|
||||||
|
url: string;
|
||||||
|
title?: string;
|
||||||
|
author?: string;
|
||||||
|
score?: number;
|
||||||
|
reason?: string;
|
||||||
|
rejectionClass?: RejectionClass;
|
||||||
|
rejectionSummary?: string;
|
||||||
|
rejectedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RejectionReason {
|
||||||
|
rejectionClass: RejectionClass;
|
||||||
|
rejectionSummary: string;
|
||||||
|
}
|
||||||
|
|
||||||
@WebSocketGateway()
|
@WebSocketGateway()
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TrackService {
|
export class TrackService {
|
||||||
@@ -98,7 +124,9 @@ export class TrackService {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(track.rejectedYoutubeUrls);
|
const parsed = JSON.parse(track.rejectedYoutubeUrls);
|
||||||
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : [];
|
return Array.isArray(parsed)
|
||||||
|
? parsed.filter((item) => typeof item === 'string')
|
||||||
|
: [];
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -108,6 +136,161 @@ export class TrackService {
|
|||||||
return JSON.stringify([...new Set(urls)].slice(0, 20));
|
return JSON.stringify([...new Set(urls)].slice(0, 20));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parseRejectedYoutubeCandidates(
|
||||||
|
track: TrackEntity,
|
||||||
|
): RejectedYoutubeCandidate[] {
|
||||||
|
if (!track.rejectedYoutubeCandidatesJson) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(track.rejectedYoutubeCandidatesJson);
|
||||||
|
return Array.isArray(parsed)
|
||||||
|
? parsed.filter(
|
||||||
|
(item): item is RejectedYoutubeCandidate =>
|
||||||
|
!!item &&
|
||||||
|
typeof item === 'object' &&
|
||||||
|
typeof item.url === 'string' &&
|
||||||
|
typeof item.rejectedAt === 'string',
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private stringifyRejectedYoutubeCandidates(
|
||||||
|
candidates: RejectedYoutubeCandidate[],
|
||||||
|
): string {
|
||||||
|
const byUrl = new Map<string, RejectedYoutubeCandidate>();
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (candidate.url) {
|
||||||
|
byUrl.set(candidate.url, candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify([...byUrl.values()].slice(-20));
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRejectedYoutubeCandidate(
|
||||||
|
track: TrackEntity,
|
||||||
|
reason: RejectionReason,
|
||||||
|
): RejectedYoutubeCandidate | null {
|
||||||
|
if (!track.youtubeUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: track.youtubeUrl,
|
||||||
|
...(track.selectedYoutubeTitle
|
||||||
|
? { title: track.selectedYoutubeTitle }
|
||||||
|
: {}),
|
||||||
|
...(track.selectedYoutubeAuthor
|
||||||
|
? { author: track.selectedYoutubeAuthor }
|
||||||
|
: {}),
|
||||||
|
...(typeof track.selectedYoutubeScore === 'number'
|
||||||
|
? { score: track.selectedYoutubeScore }
|
||||||
|
: {}),
|
||||||
|
...(track.selectedYoutubeReason
|
||||||
|
? { reason: track.selectedYoutubeReason }
|
||||||
|
: {}),
|
||||||
|
...reason,
|
||||||
|
rejectedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private rejectCurrentYoutubeCandidate(
|
||||||
|
track: TrackEntity,
|
||||||
|
reason: RejectionReason,
|
||||||
|
): {
|
||||||
|
rejectedYoutubeUrls: string;
|
||||||
|
rejectedYoutubeCandidatesJson: string;
|
||||||
|
} {
|
||||||
|
const rejectedUrls = this.parseRejectedYoutubeUrls(track);
|
||||||
|
const rejectedCandidates = this.parseRejectedYoutubeCandidates(track);
|
||||||
|
const rejectedCandidate = this.buildRejectedYoutubeCandidate(track, reason);
|
||||||
|
|
||||||
|
if (rejectedCandidate) {
|
||||||
|
if (!rejectedUrls.includes(rejectedCandidate.url)) {
|
||||||
|
rejectedUrls.push(rejectedCandidate.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectedCandidates.push(rejectedCandidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(rejectedUrls),
|
||||||
|
rejectedYoutubeCandidatesJson:
|
||||||
|
this.stringifyRejectedYoutubeCandidates(rejectedCandidates),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private classifyRejection(error?: string): RejectionReason {
|
||||||
|
if (!error) {
|
||||||
|
return {
|
||||||
|
rejectionClass: 'UNKNOWN_DOWNLOAD_ERROR',
|
||||||
|
rejectionSummary: 'Unknown download error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = error.toLowerCase();
|
||||||
|
|
||||||
|
if (normalized.includes('sign in to confirm your age')) {
|
||||||
|
return {
|
||||||
|
rejectionClass: 'YOUTUBE_AGE_GATED',
|
||||||
|
rejectionSummary: 'YouTube video is age-gated',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes('private video')) {
|
||||||
|
return {
|
||||||
|
rejectionClass: 'YOUTUBE_PRIVATE_VIDEO',
|
||||||
|
rejectionSummary: 'YouTube video is private',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.includes('only images are available') ||
|
||||||
|
normalized.includes('requested format is not available') ||
|
||||||
|
normalized.includes('no downloadable audio/video formats')
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
rejectionClass: 'YOUTUBE_NO_FORMATS',
|
||||||
|
rejectionSummary: 'No downloadable YouTube audio format',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.includes('video unavailable') ||
|
||||||
|
normalized.includes('this video is not available') ||
|
||||||
|
normalized.includes('selected video unavailable')
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
rejectionClass: 'YOUTUBE_VIDEO_UNAVAILABLE',
|
||||||
|
rejectionSummary: 'YouTube video is unavailable',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalized.includes('failed to parse yt-dlp search output') ||
|
||||||
|
normalized.includes('yt-dlp exited with code') ||
|
||||||
|
normalized.includes('failed to start yt-dlp') ||
|
||||||
|
normalized.includes('yt-dlp exceeded maximum runtime') ||
|
||||||
|
normalized.includes('unable to download webpage')
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
rejectionClass: 'YOUTUBE_EXTRACTION_FAILURE',
|
||||||
|
rejectionSummary: 'YouTube extraction failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rejectionClass: 'DOWNLOAD_FAILED',
|
||||||
|
rejectionSummary: 'Download failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async retry(id: number): Promise<void> {
|
async retry(id: number): Promise<void> {
|
||||||
const track = await this.get(id);
|
const track = await this.get(id);
|
||||||
|
|
||||||
@@ -116,16 +299,19 @@ export class TrackService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rejectedUrls = this.parseRejectedYoutubeUrls(track);
|
const rejectedCandidateFields = this.rejectCurrentYoutubeCandidate(track, {
|
||||||
|
rejectionClass: 'MANUAL_RETRY',
|
||||||
if (track.youtubeUrl && !rejectedUrls.includes(track.youtubeUrl)) {
|
rejectionSummary: 'Rejected by manual retry',
|
||||||
rejectedUrls.push(track.youtubeUrl);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
await this.update(id, {
|
await this.update(id, {
|
||||||
...track,
|
...track,
|
||||||
youtubeUrl: null,
|
youtubeUrl: null,
|
||||||
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(rejectedUrls),
|
selectedYoutubeTitle: null,
|
||||||
|
selectedYoutubeAuthor: null,
|
||||||
|
selectedYoutubeScore: null,
|
||||||
|
selectedYoutubeReason: null,
|
||||||
|
...rejectedCandidateFields,
|
||||||
downloadAttemptCount: 0,
|
downloadAttemptCount: 0,
|
||||||
error: null,
|
error: null,
|
||||||
status: TrackStatusEnum.New,
|
status: TrackStatusEnum.New,
|
||||||
@@ -161,6 +347,10 @@ export class TrackService {
|
|||||||
updatedTrack = {
|
updatedTrack = {
|
||||||
...track,
|
...track,
|
||||||
youtubeUrl: youtubeMatch.url,
|
youtubeUrl: youtubeMatch.url,
|
||||||
|
selectedYoutubeTitle: youtubeMatch.title,
|
||||||
|
selectedYoutubeAuthor: youtubeMatch.author,
|
||||||
|
selectedYoutubeScore: youtubeMatch.score,
|
||||||
|
selectedYoutubeReason: youtubeMatch.reason,
|
||||||
status: TrackStatusEnum.Queued,
|
status: TrackStatusEnum.Queued,
|
||||||
};
|
};
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
@@ -193,7 +383,9 @@ export class TrackService {
|
|||||||
const existingJob = await this.trackSearchQueue.getJob(jobId);
|
const existingJob = await this.trackSearchQueue.getJob(jobId);
|
||||||
|
|
||||||
if (existingJob) {
|
if (existingJob) {
|
||||||
this.logger.warn(`Removing existing search job for track ${id} before enqueue`);
|
this.logger.warn(
|
||||||
|
`Removing existing search job for track ${id} before enqueue`,
|
||||||
|
);
|
||||||
await existingJob.remove();
|
await existingJob.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +404,9 @@ export class TrackService {
|
|||||||
const existingJob = await this.trackDownloadQueue.getJob(jobId);
|
const existingJob = await this.trackDownloadQueue.getJob(jobId);
|
||||||
|
|
||||||
if (existingJob) {
|
if (existingJob) {
|
||||||
this.logger.warn(`Removing existing download job for track ${id} before enqueue`);
|
this.logger.warn(
|
||||||
|
`Removing existing download job for track ${id} before enqueue`,
|
||||||
|
);
|
||||||
await existingJob.remove();
|
await existingJob.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,13 +473,17 @@ export class TrackService {
|
|||||||
error = toSafeErrorMessage(err);
|
error = toSafeErrorMessage(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rejectedUrls = this.parseRejectedYoutubeUrls(track);
|
|
||||||
const nextAttemptCount = (track.downloadAttemptCount || 0) + 1;
|
const nextAttemptCount = (track.downloadAttemptCount || 0) + 1;
|
||||||
const maxAttempts = this.getMaxDownloadAttempts();
|
const maxAttempts = this.getMaxDownloadAttempts();
|
||||||
|
const rejectedCandidateFields = error
|
||||||
if (error && track.youtubeUrl && !rejectedUrls.includes(track.youtubeUrl)) {
|
? this.rejectCurrentYoutubeCandidate(track, this.classifyRejection(error))
|
||||||
rejectedUrls.push(track.youtubeUrl);
|
: {
|
||||||
}
|
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(
|
||||||
|
this.parseRejectedYoutubeUrls(track),
|
||||||
|
),
|
||||||
|
rejectedYoutubeCandidatesJson:
|
||||||
|
track.rejectedYoutubeCandidatesJson || JSON.stringify([]),
|
||||||
|
};
|
||||||
|
|
||||||
if (error && nextAttemptCount < maxAttempts) {
|
if (error && nextAttemptCount < maxAttempts) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -296,7 +494,11 @@ export class TrackService {
|
|||||||
await this.update(track.id, {
|
await this.update(track.id, {
|
||||||
...track,
|
...track,
|
||||||
youtubeUrl: null,
|
youtubeUrl: null,
|
||||||
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(rejectedUrls),
|
selectedYoutubeTitle: null,
|
||||||
|
selectedYoutubeAuthor: null,
|
||||||
|
selectedYoutubeScore: null,
|
||||||
|
selectedYoutubeReason: null,
|
||||||
|
...rejectedCandidateFields,
|
||||||
downloadAttemptCount: nextAttemptCount,
|
downloadAttemptCount: nextAttemptCount,
|
||||||
error: null,
|
error: null,
|
||||||
status: TrackStatusEnum.New,
|
status: TrackStatusEnum.New,
|
||||||
@@ -309,7 +511,7 @@ export class TrackService {
|
|||||||
const updatedTrack = {
|
const updatedTrack = {
|
||||||
...track,
|
...track,
|
||||||
status: error ? TrackStatusEnum.Error : TrackStatusEnum.Completed,
|
status: error ? TrackStatusEnum.Error : TrackStatusEnum.Completed,
|
||||||
rejectedYoutubeUrls: this.stringifyRejectedYoutubeUrls(rejectedUrls),
|
...rejectedCandidateFields,
|
||||||
downloadAttemptCount: error ? nextAttemptCount : 0,
|
downloadAttemptCount: error ? nextAttemptCount : 0,
|
||||||
...(error ? { error } : { error: null }),
|
...(error ? { error } : { error: null }),
|
||||||
};
|
};
|
||||||
@@ -320,7 +522,8 @@ export class TrackService {
|
|||||||
const safeArtist = track.artist || 'unknown_artist';
|
const safeArtist = track.artist || 'unknown_artist';
|
||||||
const safeName = track.name || 'unknown_track';
|
const safeName = track.name || 'unknown_track';
|
||||||
const fileName = `${safeArtist} - ${safeName}`;
|
const fileName = `${safeArtist} - ${safeName}`;
|
||||||
const format = this.configService.get<string>(EnvironmentEnum.FORMAT) || 'mp3';
|
const format =
|
||||||
|
this.configService.get<string>(EnvironmentEnum.FORMAT) || 'mp3';
|
||||||
return `${this.utilsService.stripFileIllegalChars(fileName)}.${format}`;
|
return `${this.utilsService.stripFileIllegalChars(fileName)}.${format}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,15 +4,18 @@
|
|||||||
<div class="brand-line">
|
<div class="brand-line">
|
||||||
<i class="fa-brands fa-spotify"></i>
|
<i class="fa-brands fa-spotify"></i>
|
||||||
<h1>Jarri Spooty</h1>
|
<h1>Jarri Spooty</h1>
|
||||||
<span class="tag version-tag">v{{version}}</span>
|
<span class="tag version-tag">v{{ version }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p>Spotify metadata → YouTube candidate scoring → Local archive</p>
|
<p>Spotify metadata → YouTube candidate scoring → Local archive</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="truth-strip">
|
<div class="truth-strip">
|
||||||
<span class="truth-pill" [class.connected]="spotifyConnected">
|
<span class="truth-pill" [class.connected]="spotifyConnected">
|
||||||
<i class="fa-solid" [ngClass]="spotifyConnected ? 'fa-circle-check' : 'fa-plug'"></i>
|
<i
|
||||||
{{ spotifyConnected ? 'Spotify connected' : 'Spotify disconnected' }}
|
class="fa-solid"
|
||||||
|
[ngClass]="spotifyConnected ? 'fa-circle-check' : 'fa-plug'"
|
||||||
|
></i>
|
||||||
|
{{ spotifyConnected ? "Spotify connected" : "Spotify disconnected" }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -49,7 +52,11 @@
|
|||||||
<i class="fa-solid fa-rotate-left"></i>
|
<i class="fa-solid fa-rotate-left"></i>
|
||||||
Reset Current Tab
|
Reset Current Tab
|
||||||
</button>
|
</button>
|
||||||
<button class="button reset-button" type="button" (click)="resetAllLayouts()">
|
<button
|
||||||
|
class="button reset-button"
|
||||||
|
type="button"
|
||||||
|
(click)="resetAllLayouts()"
|
||||||
|
>
|
||||||
<i class="fa-solid fa-rotate"></i>
|
<i class="fa-solid fa-rotate"></i>
|
||||||
Reset All Tabs
|
Reset All Tabs
|
||||||
</button>
|
</button>
|
||||||
@@ -88,8 +95,11 @@
|
|||||||
[class.connected]="spotifyConnected"
|
[class.connected]="spotifyConnected"
|
||||||
(click)="connectSpotify()"
|
(click)="connectSpotify()"
|
||||||
>
|
>
|
||||||
<i class="fa-solid" [ngClass]="spotifyConnected ? 'fa-circle-check' : 'fa-plug'"></i>
|
<i
|
||||||
{{ spotifyConnected ? 'Spotify connected' : 'Connect Spotify' }}
|
class="fa-solid"
|
||||||
|
[ngClass]="spotifyConnected ? 'fa-circle-check' : 'fa-plug'"
|
||||||
|
></i>
|
||||||
|
{{ spotifyConnected ? "Spotify connected" : "Connect Spotify" }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="intake-form">
|
<div class="intake-form">
|
||||||
@@ -121,7 +131,10 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="truth-note">
|
<div class="truth-note">
|
||||||
<p>Runtime download root: {{ archiveListing?.root || archiveDestination || 'loading...' }}</p>
|
<p>
|
||||||
|
Runtime download root:
|
||||||
|
{{ archiveListing?.root || archiveDestination || "loading..." }}
|
||||||
|
</p>
|
||||||
<p>Per-run destination routing: pending backend support</p>
|
<p>Per-run destination routing: pending backend support</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -135,7 +148,9 @@
|
|||||||
<ng-container *ngIf="songs$ | async as songs">
|
<ng-container *ngIf="songs$ | async as songs">
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<span>Spotify</span>
|
<span>Spotify</span>
|
||||||
<strong [class.online]="spotifyConnected">{{ spotifyConnected ? 'Connected' : 'Disconnected' }}</strong>
|
<strong [class.online]="spotifyConnected">{{
|
||||||
|
spotifyConnected ? "Connected" : "Disconnected"
|
||||||
|
}}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="metric-card">
|
<div class="metric-card">
|
||||||
<span>Playlists</span>
|
<span>Playlists</span>
|
||||||
@@ -160,18 +175,31 @@
|
|||||||
<p>One collapsed row per playlist run.</p>
|
<p>One collapsed row per playlist run.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="buttons has-addons">
|
<div class="buttons has-addons">
|
||||||
<button class="button is-outlined is-success" title="Remove completed from list" (click)="deleteCompleted()">
|
<button
|
||||||
|
class="button is-outlined is-success"
|
||||||
|
title="Remove completed from list"
|
||||||
|
(click)="deleteCompleted()"
|
||||||
|
>
|
||||||
<i class="fa-solid fa-check"></i>
|
<i class="fa-solid fa-check"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="button is-outlined is-danger" title="Remove failed from list" (click)="deleteFailed()">
|
<button
|
||||||
|
class="button is-outlined is-danger"
|
||||||
|
title="Remove failed from list"
|
||||||
|
(click)="deleteFailed()"
|
||||||
|
>
|
||||||
<i class="fa-solid fa-xmark"></i>
|
<i class="fa-solid fa-xmark"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-container *ngIf="playlists$ | async as playlists">
|
<ng-container *ngIf="playlists$ | async as playlists">
|
||||||
<app-playlist-box *ngFor="let playlist of playlists" [playlist]="playlist"></app-playlist-box>
|
<app-playlist-box
|
||||||
<p *ngIf="playlists?.length === 0" class="empty-state">No playlists</p>
|
*ngFor="let playlist of playlists"
|
||||||
|
[playlist]="playlist"
|
||||||
|
></app-playlist-box>
|
||||||
|
<p *ngIf="playlists?.length === 0" class="empty-state">
|
||||||
|
No playlists
|
||||||
|
</p>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -184,7 +212,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-container *ngIf="songs$ | async as songs">
|
<ng-container *ngIf="songs$ | async as songs">
|
||||||
<app-playlist-box *ngFor="let playlist of songs" [playlist]="playlist"></app-playlist-box>
|
<app-playlist-box
|
||||||
|
*ngFor="let playlist of songs"
|
||||||
|
[playlist]="playlist"
|
||||||
|
></app-playlist-box>
|
||||||
<p *ngIf="songs?.length === 0" class="empty-state">No songs</p>
|
<p *ngIf="songs?.length === 0" class="empty-state">No songs</p>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</section>
|
</section>
|
||||||
@@ -193,9 +224,14 @@
|
|||||||
<div class="section-toolbar">
|
<div class="section-toolbar">
|
||||||
<div>
|
<div>
|
||||||
<h2>Archive Browser</h2>
|
<h2>Archive Browser</h2>
|
||||||
<p>Root: {{ archiveListing?.root || 'loading...' }}</p>
|
<p>Root: {{ archiveListing?.root || "loading..." }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="button is-small reset-button" type="button" [class.is-loading]="archiveLoading" (click)="refreshArchive()">
|
<button
|
||||||
|
class="button is-small reset-button"
|
||||||
|
type="button"
|
||||||
|
[class.is-loading]="archiveLoading"
|
||||||
|
(click)="refreshArchive()"
|
||||||
|
>
|
||||||
<i class="fa-solid fa-rotate"></i>
|
<i class="fa-solid fa-rotate"></i>
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
@@ -226,24 +262,40 @@
|
|||||||
placeholder="Search archive"
|
placeholder="Search archive"
|
||||||
/>
|
/>
|
||||||
<div class="select is-small">
|
<div class="select is-small">
|
||||||
<select [(ngModel)]="archiveSort" aria-label="Sort archive files">
|
<select
|
||||||
<option *ngFor="let sortMode of archiveSortModes" [ngValue]="sortMode">
|
[(ngModel)]="archiveSort"
|
||||||
|
aria-label="Sort archive files"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
*ngFor="let sortMode of archiveSortModes"
|
||||||
|
[ngValue]="sortMode"
|
||||||
|
>
|
||||||
{{ sortMode }}
|
{{ sortMode }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p *ngIf="archiveFileCount() === 0" class="empty-state">No downloaded files found.</p>
|
<p *ngIf="archiveFileCount() === 0" class="empty-state">
|
||||||
|
No downloaded files found.
|
||||||
|
</p>
|
||||||
|
|
||||||
<section *ngFor="let group of archiveGroups()" class="archive-group">
|
<section
|
||||||
|
*ngFor="let group of archiveGroups()"
|
||||||
|
class="archive-group"
|
||||||
|
>
|
||||||
<h3>
|
<h3>
|
||||||
<i class="fa-solid fa-folder"></i>
|
<i class="fa-solid fa-folder"></i>
|
||||||
{{ group.folder }}
|
{{ group.folder }}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div *ngFor="let file of group.files" class="archive-file-card">
|
<div *ngFor="let file of group.files" class="archive-file-card">
|
||||||
<i class="fa-solid" [ngClass]="hasFolder(file) ? 'fa-folder-tree' : 'fa-file-audio'"></i>
|
<i
|
||||||
|
class="fa-solid"
|
||||||
|
[ngClass]="
|
||||||
|
hasFolder(file) ? 'fa-folder-tree' : 'fa-file-audio'
|
||||||
|
"
|
||||||
|
></i>
|
||||||
<div>
|
<div>
|
||||||
<strong>{{ file.name }}</strong>
|
<strong>{{ file.name }}</strong>
|
||||||
<span>{{ folderPath(file) }}</span>
|
<span>{{ folderPath(file) }}</span>
|
||||||
@@ -256,47 +308,157 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section *ngSwitchCase="'candidate-inspector'" class="history-panel">
|
<section *ngSwitchCase="'candidate-inspector'" class="history-panel">
|
||||||
<ng-container *ngIf="selectedTrack$ | async as selectedTrack; else noTrackSelected">
|
<ng-container
|
||||||
|
*ngIf="
|
||||||
|
selectedTrack$ | async as selectedTrack;
|
||||||
|
else noTrackSelected
|
||||||
|
"
|
||||||
|
>
|
||||||
<div class="section-toolbar">
|
<div class="section-toolbar">
|
||||||
<div>
|
<div>
|
||||||
<h2>{{ selectedTrack.artist }} - {{ selectedTrack.name }}</h2>
|
<h2>{{ selectedTrack.artist }} - {{ selectedTrack.name }}</h2>
|
||||||
<p>Status: {{ trackStatusLabel(selectedTrack.status) }}</p>
|
<p>Status: {{ trackStatusLabel(selectedTrack.status) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="button is-small reset-button" type="button" (click)="clearSelectedTrack()">
|
<button
|
||||||
|
class="button is-small reset-button"
|
||||||
|
type="button"
|
||||||
|
(click)="clearSelectedTrack()"
|
||||||
|
>
|
||||||
<i class="fa-solid fa-arrow-left"></i>
|
<i class="fa-solid fa-arrow-left"></i>
|
||||||
Back
|
Back
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<dl class="inspector-list">
|
<ng-container
|
||||||
<dt>Spotify URL</dt>
|
*ngIf="
|
||||||
<dd><a [href]="selectedTrack.spotifyUrl" target="_blank">{{ selectedTrack.spotifyUrl || 'none' }}</a></dd>
|
selectedTrackTruth(selectedTrack) as trackTruth;
|
||||||
<dt>Status</dt>
|
else noTrackTruth
|
||||||
<dd>{{ trackStatusLabel(selectedTrack.status) }}</dd>
|
"
|
||||||
<dt>Download attempts</dt>
|
>
|
||||||
<dd>{{ selectedTrack.downloadAttemptCount || 0 }}</dd>
|
<dl class="inspector-list">
|
||||||
<dt>Error</dt>
|
<dt>Spotify URL</dt>
|
||||||
<dd>{{ selectedTrack.error || 'none' }}</dd>
|
<dd>
|
||||||
</dl>
|
<a [href]="selectedTrack.spotifyUrl" target="_blank">{{
|
||||||
|
selectedTrack.spotifyUrl || "none"
|
||||||
|
}}</a>
|
||||||
|
</dd>
|
||||||
|
<dt>Status</dt>
|
||||||
|
<dd>{{ trackStatusLabel(selectedTrack.status) }}</dd>
|
||||||
|
<dt>Download attempts</dt>
|
||||||
|
<dd>{{ selectedTrack.downloadAttemptCount || 0 }}</dd>
|
||||||
|
<dt>Error class</dt>
|
||||||
|
<dd>{{ errorClass(trackTruth) }}</dd>
|
||||||
|
<dt>Error summary</dt>
|
||||||
|
<dd>{{ errorSummary(trackTruth) }}</dd>
|
||||||
|
<dt>Error detail</dt>
|
||||||
|
<dd>
|
||||||
|
<details
|
||||||
|
*ngIf="errorDetail(trackTruth); else noErrorDetail"
|
||||||
|
class="truth-detail"
|
||||||
|
>
|
||||||
|
<summary>Show backend evidence</summary>
|
||||||
|
<pre>{{ errorDetail(trackTruth) }}</pre>
|
||||||
|
</details>
|
||||||
|
<ng-template #noErrorDetail>none</ng-template>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
<div class="candidate-options">
|
<div class="candidate-options">
|
||||||
<strong>Other options</strong>
|
<strong>Selected candidate</strong>
|
||||||
<p *ngIf="selectedTrack.youtubeUrl">
|
<ng-container
|
||||||
Selected candidate:
|
*ngIf="
|
||||||
<a [href]="selectedTrack.youtubeUrl" target="_blank">{{ selectedTrack.youtubeUrl }}</a>
|
selectedCandidate(trackTruth) as candidate;
|
||||||
</p>
|
else noSelectedCandidate
|
||||||
<p>Rejected candidates: {{ rejectedUrls(selectedTrack).length }}</p>
|
"
|
||||||
<a *ngFor="let url of rejectedUrls(selectedTrack)" [href]="url" target="_blank">{{ url }}</a>
|
>
|
||||||
<p *ngIf="!selectedTrack.youtubeUrl && rejectedUrls(selectedTrack).length === 0" class="empty-state">
|
<dl class="candidate-card">
|
||||||
Candidate alternatives are not persisted yet. Future scoring history will appear here.
|
<dt>URL</dt>
|
||||||
</p>
|
<dd>
|
||||||
</div>
|
<a [href]="candidate.url" target="_blank">{{
|
||||||
|
candidate.url
|
||||||
|
}}</a>
|
||||||
|
</dd>
|
||||||
|
<dt>Title</dt>
|
||||||
|
<dd>{{ candidate.title || "unknown" }}</dd>
|
||||||
|
<dt>Author</dt>
|
||||||
|
<dd>{{ candidate.author || "unknown" }}</dd>
|
||||||
|
<dt>Score</dt>
|
||||||
|
<dd>{{ candidate.score ?? "unknown" }}</dd>
|
||||||
|
<dt>Reason</dt>
|
||||||
|
<dd>{{ candidate.reason || "unknown" }}</dd>
|
||||||
|
</dl>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #noSelectedCandidate>
|
||||||
|
<p class="empty-state compact">No selected candidate.</p>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<strong
|
||||||
|
>Rejected candidates:
|
||||||
|
{{ rejectedCandidateCount(trackTruth) }}</strong
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
*ngFor="let candidate of rejectedCandidates(trackTruth)"
|
||||||
|
class="candidate-card rejected-candidate-card"
|
||||||
|
>
|
||||||
|
<dl>
|
||||||
|
<dt>URL</dt>
|
||||||
|
<dd>
|
||||||
|
<a [href]="candidate.url" target="_blank">{{
|
||||||
|
candidate.url
|
||||||
|
}}</a>
|
||||||
|
</dd>
|
||||||
|
<dt>Title</dt>
|
||||||
|
<dd>{{ candidate.title || "unknown" }}</dd>
|
||||||
|
<dt>Author</dt>
|
||||||
|
<dd>{{ candidate.author || "unknown" }}</dd>
|
||||||
|
<dt>Score</dt>
|
||||||
|
<dd>{{ candidate.score ?? "unknown" }}</dd>
|
||||||
|
<dt>Reason</dt>
|
||||||
|
<dd>{{ candidate.reason || "unknown" }}</dd>
|
||||||
|
<dt>Rejection class</dt>
|
||||||
|
<dd>{{ candidate.rejectionClass || "unknown" }}</dd>
|
||||||
|
<dt>Rejection summary</dt>
|
||||||
|
<dd>{{ candidate.rejectionSummary || "unknown" }}</dd>
|
||||||
|
<dt>Rejected at</dt>
|
||||||
|
<dd>{{ candidate.rejectedAt || "unknown" }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
*ngIf="rejectedCandidateCount(trackTruth) === 0"
|
||||||
|
class="empty-state compact"
|
||||||
|
>
|
||||||
|
No rejected candidates.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-template #noTrackTruth>
|
||||||
|
<div class="placeholder-panel">
|
||||||
|
<i class="fa-solid fa-database"></i>
|
||||||
|
<p>
|
||||||
|
{{
|
||||||
|
operationsSnapshotError ||
|
||||||
|
"Waiting for backend operations truth."
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="button is-small reset-button"
|
||||||
|
type="button"
|
||||||
|
(click)="refreshOperationsSnapshot()"
|
||||||
|
>
|
||||||
|
Refresh truth
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-template #noTrackSelected>
|
<ng-template #noTrackSelected>
|
||||||
<div class="placeholder-panel">
|
<div class="placeholder-panel">
|
||||||
<i class="fa-solid fa-magnifying-glass-chart"></i>
|
<i class="fa-solid fa-magnifying-glass-chart"></i>
|
||||||
<p>Select a track row inside an expanded playlist to inspect Spotify, YouTube, retry, and error details.</p>
|
<p>
|
||||||
|
Select a track row inside an expanded playlist to inspect
|
||||||
|
Spotify, YouTube, retry, and error details.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ng-container *ngIf="allTracks$ | async as allTracks">
|
<ng-container *ngIf="allTracks$ | async as allTracks">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
import {Component, OnDestroy} from '@angular/core';
|
import { Component, OnDestroy } from '@angular/core';
|
||||||
import {FormsModule} from "@angular/forms";
|
import { FormsModule } from '@angular/forms';
|
||||||
import {CommonModule, NgFor} from "@angular/common";
|
import { CommonModule, NgFor } from '@angular/common';
|
||||||
import {PlaylistService, PlaylistStatusEnum} from "./services/playlist.service";
|
import {
|
||||||
import {PlaylistBoxComponent} from "./components/playlist-box/playlist-box.component";
|
PlaylistService,
|
||||||
import {VersionService} from "./services/version.service";
|
PlaylistStatusEnum,
|
||||||
import {map} from "rxjs";
|
} from './services/playlist.service';
|
||||||
import {HttpClient} from "@angular/common/http";
|
import { PlaylistBoxComponent } from './components/playlist-box/playlist-box.component';
|
||||||
import {ArchiveService} from "./services/archive.service";
|
import { VersionService } from './services/version.service';
|
||||||
import {ArchiveFile, ArchiveListing} from "./models/archive";
|
import { map } from 'rxjs';
|
||||||
import {Track, TrackStatusEnum} from "./models/track";
|
import { HttpClient } from '@angular/common/http';
|
||||||
import {TrackService} from "./services/track.service";
|
import { ArchiveService } from './services/archive.service';
|
||||||
|
import { ArchiveFile, ArchiveListing } from './models/archive';
|
||||||
|
import { Track, TrackStatusEnum } from './models/track';
|
||||||
|
import { TrackService } from './services/track.service';
|
||||||
|
import {
|
||||||
|
OperationsSnapshot,
|
||||||
|
RejectedCandidateTruth,
|
||||||
|
SelectedCandidateTruth,
|
||||||
|
TrackTruth,
|
||||||
|
} from './models/operations-snapshot';
|
||||||
|
|
||||||
export type SpootyPanelType =
|
export type SpootyPanelType =
|
||||||
'source-intake'
|
| 'source-intake'
|
||||||
| 'queue-observatory'
|
| 'queue-observatory'
|
||||||
| 'playlist-history'
|
| 'playlist-history'
|
||||||
| 'single-songs'
|
| 'single-songs'
|
||||||
@@ -66,20 +75,62 @@ const WORKSPACE_TAB_LABELS: Record<SpootyWorkspaceTab, string> = {
|
|||||||
const DEFAULT_TAB_PANELS: Record<SpootyWorkspaceTab, SpootyPanelInstance[]> = {
|
const DEFAULT_TAB_PANELS: Record<SpootyWorkspaceTab, SpootyPanelInstance[]> = {
|
||||||
intake: [
|
intake: [
|
||||||
makePanel('source-intake', 24, 24, 520, 336, 'intake-source-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('playlist-history', 24, 392, 760, 448, 'intake-playlist-history'),
|
||||||
makePanel('single-songs', 820, 392, 520, 448, 'intake-single-songs'),
|
makePanel('single-songs', 820, 392, 520, 448, 'intake-single-songs'),
|
||||||
],
|
],
|
||||||
archive: [
|
archive: [
|
||||||
makePanel('archive-browser', 24, 24, 900, 720, 'archive-archive-browser'),
|
makePanel('archive-browser', 24, 24, 900, 720, 'archive-archive-browser'),
|
||||||
makePanel('source-intake', 960, 24, 420, 336, 'archive-source-intake'),
|
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: [
|
diagnostics: [
|
||||||
makePanel('candidate-inspector', 24, 24, 620, 720, 'diagnostics-candidate-inspector'),
|
makePanel(
|
||||||
makePanel('playlist-history', 680, 24, 620, 520, 'diagnostics-playlist-history'),
|
'candidate-inspector',
|
||||||
makePanel('queue-observatory', 1320, 24, 420, 260, 'diagnostics-queue-observatory'),
|
24,
|
||||||
makePanel('archive-browser', 1320, 320, 420, 420, 'diagnostics-archive-browser'),
|
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');
|
return defaultWorkspaceStateForTab('intake');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultWorkspaceStateForTab(tab: SpootyWorkspaceTab): SpootyWorkspaceState {
|
export function defaultWorkspaceStateForTab(
|
||||||
|
tab: SpootyWorkspaceTab,
|
||||||
|
): SpootyWorkspaceState {
|
||||||
return {
|
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 parsed = JSON.parse(raw) as SpootyWorkspaceState;
|
||||||
const panels = parsed.panels
|
const panels = parsed.panels
|
||||||
?.filter(panel => panel?.id && panel?.type && PANEL_TITLES[panel.type])
|
?.filter((panel) => panel?.id && panel?.type && PANEL_TITLES[panel.type])
|
||||||
.map(panel => ({
|
.map((panel) => ({
|
||||||
...panel,
|
...panel,
|
||||||
title: PANEL_TITLES[panel.type],
|
title: PANEL_TITLES[panel.type],
|
||||||
x: snap(panel.x),
|
x: snap(panel.x),
|
||||||
@@ -143,7 +196,7 @@ export function loadWorkspaceState(): SpootyWorkspaceState {
|
|||||||
h: Math.max(MIN_PANEL_HEIGHT, snap(panel.h)),
|
h: Math.max(MIN_PANEL_HEIGHT, snap(panel.h)),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return panels?.length ? {panels} : defaultWorkspaceState();
|
return panels?.length ? { panels } : defaultWorkspaceState();
|
||||||
} catch {
|
} catch {
|
||||||
return defaultWorkspaceState();
|
return defaultWorkspaceState();
|
||||||
}
|
}
|
||||||
@@ -156,11 +209,23 @@ export function loadWorkspaceTabsState(): SpootyWorkspaceTabsState {
|
|||||||
if (raw) {
|
if (raw) {
|
||||||
const parsed = JSON.parse(raw) as SpootyWorkspaceTabsState;
|
const parsed = JSON.parse(raw) as SpootyWorkspaceTabsState;
|
||||||
return {
|
return {
|
||||||
activeTab: parsed.activeTab && WORKSPACE_TAB_LABELS[parsed.activeTab] ? parsed.activeTab : 'intake',
|
activeTab:
|
||||||
|
parsed.activeTab && WORKSPACE_TAB_LABELS[parsed.activeTab]
|
||||||
|
? parsed.activeTab
|
||||||
|
: 'intake',
|
||||||
tabs: {
|
tabs: {
|
||||||
intake: sanitizeWorkspaceState(parsed.tabs?.intake, defaultWorkspaceStateForTab('intake')),
|
intake: sanitizeWorkspaceState(
|
||||||
archive: sanitizeWorkspaceState(parsed.tabs?.archive, defaultWorkspaceStateForTab('archive')),
|
parsed.tabs?.intake,
|
||||||
diagnostics: sanitizeWorkspaceState(parsed.tabs?.diagnostics, defaultWorkspaceStateForTab('diagnostics')),
|
defaultWorkspaceStateForTab('intake'),
|
||||||
|
),
|
||||||
|
archive: sanitizeWorkspaceState(
|
||||||
|
parsed.tabs?.archive,
|
||||||
|
defaultWorkspaceStateForTab('archive'),
|
||||||
|
),
|
||||||
|
diagnostics: sanitizeWorkspaceState(
|
||||||
|
parsed.tabs?.diagnostics,
|
||||||
|
defaultWorkspaceStateForTab('diagnostics'),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -182,8 +247,8 @@ function sanitizeWorkspaceState(
|
|||||||
fallback: SpootyWorkspaceState,
|
fallback: SpootyWorkspaceState,
|
||||||
): SpootyWorkspaceState {
|
): SpootyWorkspaceState {
|
||||||
const panels = state?.panels
|
const panels = state?.panels
|
||||||
?.filter(panel => panel?.id && panel?.type && PANEL_TITLES[panel.type])
|
?.filter((panel) => panel?.id && panel?.type && PANEL_TITLES[panel.type])
|
||||||
.map(panel => ({
|
.map((panel) => ({
|
||||||
...panel,
|
...panel,
|
||||||
title: PANEL_TITLES[panel.type],
|
title: PANEL_TITLES[panel.type],
|
||||||
x: snap(panel.x),
|
x: snap(panel.x),
|
||||||
@@ -192,7 +257,7 @@ function sanitizeWorkspaceState(
|
|||||||
h: Math.max(MIN_PANEL_HEIGHT, snap(panel.h)),
|
h: Math.max(MIN_PANEL_HEIGHT, snap(panel.h)),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return panels?.length ? {panels} : fallback;
|
return panels?.length ? { panels } : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function snap(value: number): number {
|
function snap(value: number): number {
|
||||||
@@ -200,16 +265,16 @@ function snap(value: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [CommonModule, FormsModule, NgFor, PlaylistBoxComponent],
|
imports: [CommonModule, FormsModule, NgFor, PlaylistBoxComponent],
|
||||||
templateUrl: './app.component.html',
|
templateUrl: './app.component.html',
|
||||||
styleUrl: './app.component.scss',
|
styleUrl: './app.component.scss',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
})
|
})
|
||||||
export class AppComponent implements OnDestroy {
|
export class AppComponent implements OnDestroy {
|
||||||
|
url = '';
|
||||||
url = ''
|
private readonly spotifyUrlPattern =
|
||||||
private readonly spotifyUrlPattern = /^https:\/\/open\.spotify\.com\/(track|playlist|album|artist)\/[a-zA-Z0-9]+/;
|
/^https:\/\/open\.spotify\.com\/(track|playlist|album|artist)\/[a-zA-Z0-9]+/;
|
||||||
private interaction?: {
|
private interaction?: {
|
||||||
mode: 'drag' | 'resize';
|
mode: 'drag' | 'resize';
|
||||||
panelId: string;
|
panelId: string;
|
||||||
@@ -220,6 +285,8 @@ export class AppComponent implements OnDestroy {
|
|||||||
startPanelW: number;
|
startPanelW: number;
|
||||||
startPanelH: number;
|
startPanelH: number;
|
||||||
};
|
};
|
||||||
|
private readonly operationsRefreshMs = 5000;
|
||||||
|
private operationsRefreshTimer?: number;
|
||||||
readonly panelTypes: SpootyPanelType[] = [
|
readonly panelTypes: SpootyPanelType[] = [
|
||||||
'source-intake',
|
'source-intake',
|
||||||
'queue-observatory',
|
'queue-observatory',
|
||||||
@@ -228,7 +295,11 @@ export class AppComponent implements OnDestroy {
|
|||||||
'archive-browser',
|
'archive-browser',
|
||||||
'candidate-inspector',
|
'candidate-inspector',
|
||||||
];
|
];
|
||||||
readonly workspaceTabs: SpootyWorkspaceTab[] = ['intake', 'archive', 'diagnostics'];
|
readonly workspaceTabs: SpootyWorkspaceTab[] = [
|
||||||
|
'intake',
|
||||||
|
'archive',
|
||||||
|
'diagnostics',
|
||||||
|
];
|
||||||
readonly archiveSortModes: ArchiveSortMode[] = ['newest', 'name', 'size'];
|
readonly archiveSortModes: ArchiveSortMode[] = ['newest', 'name', 'size'];
|
||||||
selectedPanelType: SpootyPanelType = 'source-intake';
|
selectedPanelType: SpootyPanelType = 'source-intake';
|
||||||
workspaceTabsState: SpootyWorkspaceTabsState = loadWorkspaceTabsState();
|
workspaceTabsState: SpootyWorkspaceTabsState = loadWorkspaceTabsState();
|
||||||
@@ -237,8 +308,12 @@ export class AppComponent implements OnDestroy {
|
|||||||
return this.spotifyUrlPattern.test(this.url);
|
return this.spotifyUrlPattern.test(this.url);
|
||||||
}
|
}
|
||||||
createLoading$ = this.playlistService.createLoading$;
|
createLoading$ = this.playlistService.createLoading$;
|
||||||
playlists$ = this.playlistService.all$.pipe(map(items => items.filter(item => !item.isTrack)));
|
playlists$ = this.playlistService.all$.pipe(
|
||||||
songs$ = this.playlistService.all$.pipe(map(items => items.filter(item => item.isTrack)));
|
map((items) => items.filter((item) => !item.isTrack)),
|
||||||
|
);
|
||||||
|
songs$ = this.playlistService.all$.pipe(
|
||||||
|
map((items) => items.filter((item) => item.isTrack)),
|
||||||
|
);
|
||||||
allTracks$ = this.trackService.all$;
|
allTracks$ = this.trackService.all$;
|
||||||
selectedTrack$ = this.trackService.selectedTrack$;
|
selectedTrack$ = this.trackService.selectedTrack$;
|
||||||
version = this.versionService.getVersion();
|
version = this.versionService.getVersion();
|
||||||
@@ -250,6 +325,8 @@ export class AppComponent implements OnDestroy {
|
|||||||
archiveSearch = '';
|
archiveSearch = '';
|
||||||
archiveSort: ArchiveSortMode = 'newest';
|
archiveSort: ArchiveSortMode = 'newest';
|
||||||
trackStatuses = TrackStatusEnum;
|
trackStatuses = TrackStatusEnum;
|
||||||
|
operationsSnapshot?: OperationsSnapshot;
|
||||||
|
operationsSnapshotError = '';
|
||||||
|
|
||||||
get activeWorkspaceTab(): SpootyWorkspaceTab {
|
get activeWorkspaceTab(): SpootyWorkspaceTab {
|
||||||
return this.workspaceTabsState.activeTab;
|
return this.workspaceTabsState.activeTab;
|
||||||
@@ -270,10 +347,18 @@ export class AppComponent implements OnDestroy {
|
|||||||
this.checkSpotifyStatus();
|
this.checkSpotifyStatus();
|
||||||
this.fetchPlaylists();
|
this.fetchPlaylists();
|
||||||
this.refreshArchive();
|
this.refreshArchive();
|
||||||
|
this.refreshOperationsSnapshot();
|
||||||
|
this.operationsRefreshTimer = window.setInterval(
|
||||||
|
() => this.refreshOperationsSnapshot(),
|
||||||
|
this.operationsRefreshMs,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.stopWorkspaceInteraction();
|
this.stopWorkspaceInteraction();
|
||||||
|
if (this.operationsRefreshTimer) {
|
||||||
|
window.clearInterval(this.operationsRefreshTimer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private bootstrapAuthTokenFromUrl(): void {
|
private bootstrapAuthTokenFromUrl(): void {
|
||||||
@@ -337,6 +422,18 @@ export class AppComponent implements OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
refreshOperationsSnapshot(): void {
|
||||||
|
this.http.get<OperationsSnapshot>('/api/operations/snapshot').subscribe({
|
||||||
|
next: (snapshot) => {
|
||||||
|
this.operationsSnapshot = snapshot;
|
||||||
|
this.operationsSnapshotError = '';
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.operationsSnapshotError = 'Operations snapshot is unavailable.';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
formatBytes(sizeBytes: number): string {
|
formatBytes(sizeBytes: number): string {
|
||||||
if (sizeBytes < 1024) {
|
if (sizeBytes < 1024) {
|
||||||
return `${sizeBytes} B`;
|
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 {
|
selectTrack(track: Track): void {
|
||||||
this.trackService.select(track);
|
this.trackService.select(track);
|
||||||
|
this.refreshOperationsSnapshot();
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSelectedTrack(): void {
|
clearSelectedTrack(): void {
|
||||||
this.trackService.clearSelection();
|
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 {
|
getWorkspaceTabLabel(tab: SpootyWorkspaceTab): string {
|
||||||
return WORKSPACE_TAB_LABELS[tab];
|
return WORKSPACE_TAB_LABELS[tab];
|
||||||
}
|
}
|
||||||
@@ -410,7 +532,9 @@ export class AppComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resetLayout(): void {
|
resetLayout(): void {
|
||||||
this.setActiveWorkspace(defaultWorkspaceStateForTab(this.activeWorkspaceTab));
|
this.setActiveWorkspace(
|
||||||
|
defaultWorkspaceStateForTab(this.activeWorkspaceTab),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
resetAllLayouts(): void {
|
resetAllLayouts(): void {
|
||||||
@@ -422,7 +546,9 @@ export class AppComponent implements OnDestroy {
|
|||||||
const files = this.archiveListing?.files || [];
|
const files = this.archiveListing?.files || [];
|
||||||
const query = this.archiveSearch.trim().toLowerCase();
|
const query = this.archiveSearch.trim().toLowerCase();
|
||||||
const filtered = query
|
const filtered = query
|
||||||
? files.filter(file => `${file.name} ${file.path}`.toLowerCase().includes(query))
|
? files.filter((file) =>
|
||||||
|
`${file.name} ${file.path}`.toLowerCase().includes(query),
|
||||||
|
)
|
||||||
: [...files];
|
: [...files];
|
||||||
|
|
||||||
return filtered.sort((a, b) => {
|
return filtered.sort((a, b) => {
|
||||||
@@ -441,12 +567,12 @@ export class AppComponent implements OnDestroy {
|
|||||||
archiveGroups(): { folder: string; files: ArchiveFile[] }[] {
|
archiveGroups(): { folder: string; files: ArchiveFile[] }[] {
|
||||||
const groups = new Map<string, ArchiveFile[]>();
|
const groups = new Map<string, ArchiveFile[]>();
|
||||||
|
|
||||||
this.filteredArchiveFiles().forEach(file => {
|
this.filteredArchiveFiles().forEach((file) => {
|
||||||
const folder = this.topLevelFolder(file);
|
const folder = this.topLevelFolder(file);
|
||||||
groups.set(folder, [...(groups.get(folder) || []), 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 {
|
archiveFileCount(): number {
|
||||||
@@ -454,7 +580,10 @@ export class AppComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
archiveTotalBytes(): number {
|
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 {
|
topLevelFolder(file: ArchiveFile): string {
|
||||||
@@ -490,7 +619,7 @@ export class AppComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.setActiveWorkspace({
|
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 {
|
private startWorkspaceInteraction(): void {
|
||||||
document.addEventListener('pointermove', this.handlePointerMove);
|
document.addEventListener('pointermove', this.handlePointerMove);
|
||||||
document.addEventListener('pointerup', this.handlePointerUp, {once: true});
|
document.addEventListener('pointerup', this.handlePointerUp, {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private stopWorkspaceInteraction(): void {
|
private stopWorkspaceInteraction(): void {
|
||||||
@@ -593,7 +724,7 @@ export class AppComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private bringPanelToFront(panelId: string): void {
|
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) {
|
if (!panel) {
|
||||||
return;
|
return;
|
||||||
@@ -601,16 +732,19 @@ export class AppComponent implements OnDestroy {
|
|||||||
|
|
||||||
this.setActiveWorkspace({
|
this.setActiveWorkspace({
|
||||||
panels: [
|
panels: [
|
||||||
...this.workspace.panels.filter(item => item.id !== panelId),
|
...this.workspace.panels.filter((item) => item.id !== panelId),
|
||||||
panel,
|
panel,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private updatePanel(panelId: string, changes: Partial<SpootyPanelInstance>): void {
|
private updatePanel(
|
||||||
|
panelId: string,
|
||||||
|
changes: Partial<SpootyPanelInstance>,
|
||||||
|
): void {
|
||||||
this.setActiveWorkspace({
|
this.setActiveWorkspace({
|
||||||
panels: this.workspace.panels.map(panel =>
|
panels: this.workspace.panels.map((panel) =>
|
||||||
panel.id === panelId ? {...panel, ...changes} : panel
|
panel.id === panelId ? { ...panel, ...changes } : panel,
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -627,6 +761,9 @@ export class AppComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private saveWorkspaceState(): void {
|
private saveWorkspaceState(): void {
|
||||||
localStorage.setItem(WORKSPACE_TABS_STORAGE_KEY, JSON.stringify(this.workspaceTabsState));
|
localStorage.setItem(
|
||||||
|
WORKSPACE_TABS_STORAGE_KEY,
|
||||||
|
JSON.stringify(this.workspaceTabsState),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -112,6 +112,62 @@ body {
|
|||||||
overflow-wrap: anywhere;
|
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) {
|
@media (max-width: 900px) {
|
||||||
.workspace-tabs {
|
.workspace-tabs {
|
||||||
position: static;
|
position: static;
|
||||||
|
|||||||
Reference in New Issue
Block a user