mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
refactor(playlist): G.61 — extract helpers + CSV import + modal components (cluster) (#628)
Four-cut cluster on PlaylistDetail.tsx. 2274 → 1761 LOC (−513); the page keeps every behaviour and stateful path, but the leaf helpers, the Spotify CSV import workflow, and the two stand-alone modals each live in their own files now. playlistDetailHelpers — pure helpers: sanitizeFilename, formatDuration, formatSize, totalDurationLabel, codecLabel, plus SMART_PREFIX with isSmartPlaylistName / displayPlaylistName. No deps beyond formatHumanHoursMinutes + SubsonicSong. Kept the duplicates that already live in ContextMenu / Sidebar / Playlists in place — dedup is a separate cut, not part of this code-move. spotifyCsvImport — full Spotify CSV pipeline: HEADER_MAPPINGS, normalizeHeader, findColumnField, parseArtists, extractFeaturedArtists, parseSpotifyCsv, plus the SpotifyCsvTrack type re-exported for callers. papaparse moves with it; PlaylistDetail no longer imports Papa directly. Header strings keep the \uXXXX escapes so the diff is byte-identical. PlaylistEditModal — full edit-meta dialog (name / description / public toggle / cover swap / cover remove / save spinner). Props match the old inline component verbatim. Uses React + i18next + CachedImage + the same lucide icons (Camera, Loader2, X) and SubsonicPlaylist type. CsvImportReportModal — full import-result dialog (4- or 5-cell stat grid, duplicate / not-found / network-error lists, download-report button via Blob + URL.createObjectURL). Still rendered through createPortal to document.body so the z-index-99999 overlay clears playlist UI. Imports the SpotifyCsvTrack type from the new CSV module. PlaylistDetail loses createPortal and Papa from its import list, picks up two component imports (PlaylistEditModal, CsvImportReportModal), and the three util imports (playlistDetailHelpers, spotifyCsvImport, the type-only SpotifyCsvTrack). Pure code move otherwise — no behaviour change.
This commit is contained in:
committed by
GitHub
parent
8adad2be6f
commit
84ceb5f423
@@ -0,0 +1,45 @@
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import { formatHumanHoursMinutes } from './formatHumanDuration';
|
||||
|
||||
export function sanitizeFilename(name: string): string {
|
||||
return name
|
||||
.replace(/[/\\?%*:|"<>]/g, '-')
|
||||
.replace(/\.{2,}/g, '.')
|
||||
.replace(/^[\s.]+|[\s.]+$/g, '')
|
||||
.substring(0, 200) || 'download';
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function formatSize(bytes?: number): string {
|
||||
if (!bytes) return '';
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
export function totalDurationLabel(songs: SubsonicSong[]): string {
|
||||
const total = songs.reduce((acc, s) => acc + (s.duration ?? 0), 0);
|
||||
return formatHumanHoursMinutes(total);
|
||||
}
|
||||
|
||||
export const SMART_PREFIX = 'psy-smart-';
|
||||
|
||||
export function isSmartPlaylistName(name: string): boolean {
|
||||
return (name ?? '').toLowerCase().startsWith(SMART_PREFIX);
|
||||
}
|
||||
|
||||
export function displayPlaylistName(name: string): string {
|
||||
const n = name ?? '';
|
||||
if (isSmartPlaylistName(n)) return n.slice(SMART_PREFIX.length);
|
||||
return n;
|
||||
}
|
||||
|
||||
export function codecLabel(song: SubsonicSong, showBitrate: boolean): string {
|
||||
const parts: string[] = [];
|
||||
if (song.suffix) parts.push(song.suffix.toUpperCase());
|
||||
if (showBitrate && song.bitRate) parts.push(`${song.bitRate} kbps`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import Papa from 'papaparse';
|
||||
|
||||
export interface SpotifyCsvTrack {
|
||||
trackName: string;
|
||||
artistName: string;
|
||||
artistNames: string[]; // Array of all artists for better matching
|
||||
albumName: string;
|
||||
isrc?: string;
|
||||
score?: number; // Match score when track not found
|
||||
thresholdNeeded?: number; // Threshold required to pass
|
||||
}
|
||||
|
||||
// Header mapping to canonical fields (supports English and Spanish)
|
||||
const HEADER_MAPPINGS: Record<string, string> = {
|
||||
// Track name
|
||||
'track name': 'trackName',
|
||||
'track name(s)': 'trackName',
|
||||
'track': 'trackName',
|
||||
'name': 'trackName',
|
||||
'nombre de la cancion': 'trackName',
|
||||
'nombre de cancion': 'trackName',
|
||||
'nombre de la canci\u00f3n': 'trackName',
|
||||
'nombre cancion': 'trackName',
|
||||
't\u00edtulo': 'trackName',
|
||||
'titulo': 'trackName',
|
||||
// Artist name
|
||||
'artist name': 'artistName',
|
||||
'artist name(s)': 'artistName',
|
||||
'artists name': 'artistName',
|
||||
'artists name(s)': 'artistName',
|
||||
'artist': 'artistName',
|
||||
'artists': 'artistName',
|
||||
'nombre del artista': 'artistName',
|
||||
'nombres del artista': 'artistName',
|
||||
'nombre artista': 'artistName',
|
||||
'artista': 'artistName',
|
||||
// Album name
|
||||
'album name': 'albumName',
|
||||
'album name(s)': 'albumName',
|
||||
'album': 'albumName',
|
||||
'nombre del album': 'albumName',
|
||||
'nombre del \u00e1lbum': 'albumName',
|
||||
'nombre album': 'albumName',
|
||||
// ISRC
|
||||
'isrc': 'isrc',
|
||||
'isrc code': 'isrc',
|
||||
'codigo isrc': 'isrc',
|
||||
'c\u00f3digo isrc': 'isrc',
|
||||
};
|
||||
|
||||
function normalizeHeader(header: string): string {
|
||||
return header
|
||||
.toLowerCase()
|
||||
.replace(/\(s\)/g, '')
|
||||
.replace(/[()]/g, '')
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function findColumnField(header: string): string | undefined {
|
||||
const normalized = normalizeHeader(header);
|
||||
return HEADER_MAPPINGS[normalized];
|
||||
}
|
||||
|
||||
function parseArtists(artistField: string): string[] {
|
||||
// Spotify uses commas in extended format, semicolons in simple format
|
||||
const separator = artistField.includes(';') ? ';' : ',';
|
||||
return artistField
|
||||
.split(separator)
|
||||
.map(a => a.trim())
|
||||
.filter(a => a.length > 0);
|
||||
}
|
||||
|
||||
function extractFeaturedArtists(title: string): string[] {
|
||||
const patterns = [
|
||||
/\(feat\.?\s+([^)]+)\)/i,
|
||||
/\(ft\.?\s+([^)]+)\)/i,
|
||||
/\(featuring\s+([^)]+)\)/i,
|
||||
/\(with\s+([^)]+)\)/i,
|
||||
];
|
||||
for (const regex of patterns) {
|
||||
const match = title.match(regex);
|
||||
if (match) {
|
||||
return match[1].split(/,|\sand\s|\s&\s/).map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function parseSpotifyCsv(csvContent: string): SpotifyCsvTrack[] {
|
||||
// Strip BOM and parse with Papa Parse
|
||||
const cleanContent = csvContent.replace(/^\uFEFF/, '');
|
||||
|
||||
const parseResult = Papa.parse(cleanContent, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
transformHeader: (header: string) => {
|
||||
const field = findColumnField(header);
|
||||
return field || header;
|
||||
},
|
||||
});
|
||||
|
||||
if (parseResult.errors.length > 0) {
|
||||
console.warn('CSV parse warnings:', parseResult.errors);
|
||||
}
|
||||
|
||||
const data = parseResult.data as Record<string, string>[];
|
||||
|
||||
// Verify required columns
|
||||
if (!data.length || !data[0].trackName || !data[0].artistName) {
|
||||
console.error('CSV columns not found. Available headers:', Object.keys(data[0] || {}));
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log('CSV parsed with Papa Parse:', {
|
||||
rows: data.length,
|
||||
sample: data[0],
|
||||
});
|
||||
|
||||
const tracks: SpotifyCsvTrack[] = [];
|
||||
for (const row of data) {
|
||||
const trackName = row.trackName?.trim();
|
||||
const artistField = row.artistName?.trim() || '';
|
||||
|
||||
if (!trackName || !artistField) continue;
|
||||
|
||||
// Parse multiple artists from field + extract collaborators from title
|
||||
const artistNames = parseArtists(artistField);
|
||||
const featuredArtists = extractFeaturedArtists(trackName);
|
||||
const allArtists = [...new Set([...artistNames, ...featuredArtists])];
|
||||
const primaryArtist = allArtists[0] || '';
|
||||
|
||||
tracks.push({
|
||||
trackName,
|
||||
artistName: primaryArtist,
|
||||
artistNames: allArtists,
|
||||
albumName: row.albumName?.trim() || '',
|
||||
isrc: row.isrc?.trim() || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return tracks;
|
||||
}
|
||||
Reference in New Issue
Block a user