soulsync/webui/src/routes/import/-import.helpers.ts
2026-05-24 21:17:21 +03:00

291 lines
9.7 KiB
TypeScript

import type {
ImportAlbumMatch,
ImportAutoFilter,
ImportAutoImportActiveItem,
ImportAutoImportMatchData,
ImportAutoImportResult,
ImportAutoImportStatusPayload,
ImportQueueEntry,
ImportStagingFile,
} from './-import.types';
export const IMPORT_PLACEHOLDER_IMAGE = '/static/placeholder.png';
export function getStagingFileKey(file: ImportStagingFile): string {
return file.full_path;
}
export function formatImportBytes(bytes: number): string {
if (bytes > 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
if (bytes > 1_048_576) return `${(bytes / 1_048_576).toFixed(0)} MB`;
return `${(bytes / 1024).toFixed(0)} KB`;
}
export function getStagingStatsText(files: ImportStagingFile[]): string {
const totalSize = files.reduce((sum, file) => sum + (file.size || 0), 0);
const fileLabel = `${files.length} file${files.length === 1 ? '' : 's'}`;
return totalSize ? `${fileLabel} - ${formatImportBytes(totalSize)}` : fileLabel;
}
export function getTrackDisplayInfo(match: ImportAlbumMatch, index: number) {
const track = match.track || match.spotify_track || {};
const rawTrackNumber = track.track_number ?? track.trackNumber ?? null;
const trackNumber =
rawTrackNumber === null || rawTrackNumber === undefined || rawTrackNumber === ''
? null
: String(rawTrackNumber).split('/')[0]?.trim() || null;
return {
track,
name: track.name || track.title || `Track ${index + 1}`,
trackNumber,
displayTrackNumber: trackNumber || String(index + 1),
};
}
export function getEffectiveAlbumMatches(
matches: ImportAlbumMatch[],
stagingFiles: ImportStagingFile[],
overrides: Record<number, number>,
): ImportAlbumMatch[] {
return matches.flatMap((match, index) => {
if (Object.hasOwn(overrides, index)) {
const override = overrides[index];
if (override === -1) return [];
const stagingFile = stagingFiles[override];
return stagingFile ? [{ ...match, staging_file: stagingFile, confidence: 1 }] : [];
}
return match.staging_file ? [match] : [];
});
}
export function getDisplayedMatchFile(
match: ImportAlbumMatch,
index: number,
stagingFiles: ImportStagingFile[],
overrides: Record<number, number>,
): { file: ImportStagingFile | null; confidence: number; isOverride: boolean } {
if (Object.hasOwn(overrides, index)) {
const override = overrides[index];
if (override === -1) return { file: null, confidence: match.confidence, isOverride: false };
return {
file: stagingFiles[override] ?? null,
confidence: 1,
isOverride: true,
};
}
if (!match.staging_file) {
return { file: null, confidence: match.confidence, isOverride: false };
}
const autoFileName = match.staging_file.filename;
const reassigned = Object.entries(overrides).some(([trackIndex, stagingFileIndex]) => {
const file = stagingFiles[stagingFileIndex];
return file && file.filename === autoFileName && Number(trackIndex) !== index;
});
return {
file: reassigned ? null : match.staging_file,
confidence: match.confidence,
isOverride: false,
};
}
export function getUnmatchedStagingFiles(
matches: ImportAlbumMatch[],
stagingFiles: ImportStagingFile[],
overrides: Record<number, number>,
): Array<{ file: ImportStagingFile; index: number }> {
return stagingFiles.flatMap((file, index) => {
if (Object.values(overrides).includes(index)) return [];
const autoUsed = matches.some((match, matchIndex) => {
if (Object.hasOwn(overrides, matchIndex)) return false;
return match.staging_file?.filename === file.filename;
});
return autoUsed ? [] : [{ file, index }];
});
}
export function getAutoImportCounts(results: ImportAutoImportResult[]) {
return {
imported: results.filter(
(result) => result.status === 'completed' || result.status === 'approved',
).length,
review: results.filter((result) => result.status === 'pending_review').length,
failed: results.filter(
(result) => result.status === 'failed' || result.status === 'needs_identification',
).length,
};
}
export function filterAutoImportResults(
results: ImportAutoImportResult[],
filter: ImportAutoFilter,
): ImportAutoImportResult[] {
if (filter === 'pending') return results.filter((result) => result.status === 'pending_review');
if (filter === 'imported') {
return results.filter(
(result) => result.status === 'completed' || result.status === 'approved',
);
}
if (filter === 'failed') {
return results.filter(
(result) => result.status === 'failed' || result.status === 'needs_identification',
);
}
return results;
}
export function getAutoImportStatusText(status: ImportAutoImportStatusPayload | undefined): string {
if (!status) return 'Loading...';
if (status.paused) return 'Paused';
if (status.current_status === 'processing') return 'Processing...';
if (status.current_status === 'scanning') return 'Scanning...';
if (!status.running) return 'Disabled';
if (status.last_scan_time) {
const lastScan = new Date(status.last_scan_time);
const diffSeconds = Math.floor((Date.now() - lastScan.getTime()) / 1000);
if (Number.isFinite(diffSeconds) && diffSeconds >= 0 && diffSeconds < 60) {
return `Watching (scanned ${diffSeconds}s ago)`;
}
if (Number.isFinite(diffSeconds) && diffSeconds < 3600) {
return `Watching (scanned ${Math.floor(diffSeconds / 60)}m ago)`;
}
}
return 'Watching';
}
export type AutoImportStatusTone = 'neutral' | 'info' | 'success';
export function getAutoImportStatusTone(
status: ImportAutoImportStatusPayload | undefined,
): AutoImportStatusTone {
if (!status?.running || status.paused) return 'neutral';
if (status.current_status === 'scanning' || status.current_status === 'processing') return 'info';
return 'success';
}
export function getActiveImportLines(status: ImportAutoImportStatusPayload | undefined): string[] {
const active = Array.isArray(status?.active_imports) ? status.active_imports : [];
if (active.length > 0) return active.map(getActiveImportLine);
if (status?.current_status === 'scanning') {
return [`Scanning... (${status.stats?.scanned || 0} processed)`];
}
return [];
}
function getActiveImportLine(item: ImportAutoImportActiveItem): string {
const folder = item.folder_name || '...';
const trackIndex = item.track_index || 0;
const trackTotal = item.track_total || 0;
const trackName = item.track_name || '';
if (item.status === 'processing' && trackTotal > 0) {
return `${folder} - track ${trackIndex}/${trackTotal}: ${trackName}`;
}
if (item.status === 'matching') return `${folder} - matching tracks...`;
if (item.status === 'identifying') return `${folder} - identifying...`;
return `${folder} - queued`;
}
export function parseAutoImportMatchData(
matchData: ImportAutoImportResult['match_data'],
): ImportAutoImportMatchData {
if (!matchData) return {};
if (typeof matchData === 'object') return matchData;
try {
const parsed = JSON.parse(matchData) as ImportAutoImportMatchData;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
export function getAutoImportTimeAgo(createdAt: string | null | undefined): string {
if (!createdAt) return '';
const created = new Date(createdAt);
const diffMinutes = Math.floor((Date.now() - created.getTime()) / 60_000);
if (!Number.isFinite(diffMinutes) || diffMinutes < 0) return '';
if (diffMinutes < 1) return 'just now';
if (diffMinutes < 60) return `${diffMinutes}m ago`;
if (diffMinutes < 1440) return `${Math.floor(diffMinutes / 60)}h ago`;
return `${Math.floor(diffMinutes / 1440)}d ago`;
}
export function getConfidenceClass(confidencePercent: number): 'high' | 'medium' | 'low' {
if (confidencePercent >= 90) return 'high';
if (confidencePercent >= 70) return 'medium';
return 'low';
}
export function getAutoImportStatusMeta(status: string): {
label: string;
icon: string;
className: 'completed' | 'review' | 'failed' | 'processing' | 'neutral';
} {
const labels: Record<string, string> = {
completed: 'Imported',
pending_review: 'Needs Review',
needs_identification: 'Unidentified',
failed: 'Failed',
scanning: 'Scanning...',
matched: 'Matched',
rejected: 'Dismissed',
approved: 'Approved',
processing: 'Processing',
};
const icons: Record<string, string> = {
completed: '✓',
pending_review: '⚠',
needs_identification: '✗',
failed: '✗',
scanning: '⌛',
matched: '✓',
rejected: '✕',
approved: '✓',
processing: '⧗',
};
return {
label: labels[status] || status,
icon: icons[status] || '',
className:
status === 'completed'
? 'completed'
: status === 'pending_review'
? 'review'
: status === 'failed' || status === 'needs_identification'
? 'failed'
: status === 'processing'
? 'processing'
: 'neutral',
};
}
export function getQueueProgressPercent(entry: ImportQueueEntry): number {
if (entry.status === 'done' || entry.status === 'error') return 100;
if (entry.total <= 0) return 0;
return Math.round((entry.processed / entry.total) * 100);
}
export function getQueueStatusText(entry: ImportQueueEntry): string {
if (entry.status === 'running') return `${entry.processed}/${entry.total}`;
if (entry.status === 'done') {
return entry.errors.length > 0
? `${entry.processed}/${entry.total} (${entry.errors.length} err)`
: 'Done';
}
return 'Failed';
}
export function formatDuration(durationMs: number | null | undefined): string {
if (!durationMs) return '';
const minutes = Math.floor(durationMs / 60_000);
const seconds = String(Math.floor((durationMs % 60_000) / 1000)).padStart(2, '0');
return `${minutes}:${seconds}`;
}