refactor(utils): group utils/ files into topic folders (Phase L, part 1) (#689)

111 of 122 top-level src/utils/ files move into 16 topic folders (audio,
cache, cover, share, server, playback, playlist, deviceSync, waveform,
mix, format, export, changelog, ui, perf, componentHelpers). True
singletons with no cluster stay at the utils/ root.

Pure file-move: a path-aware codemod rewrote 539 relative-import
specifiers across 275 files; no logic touched. The hot-path coverage
gate list (.github/frontend-hot-path-files.txt) is updated to the new
paths for the 11 gated utils files — a mechanical consequence of the
move, not a CI change. tsc is green.
This commit is contained in:
Frank Stellmacher
2026-05-14 14:27:44 +02:00
committed by GitHub
parent 2409a1fec8
commit 7a7a9f5e6b
324 changed files with 551 additions and 551 deletions
@@ -0,0 +1,36 @@
import type { Track } from '../../store/playerStoreTypes';
export interface BulkPlayDeps {
songsLength: number;
id: string | undefined;
tracks: Track[];
touchPlaylist: (id: string) => void;
playTrack: (track: Track, queue: Track[]) => void;
enqueue: (tracks: Track[]) => void;
}
export function playPlaylistAll(deps: BulkPlayDeps): void {
const { songsLength, id, tracks, touchPlaylist, playTrack } = deps;
if (!songsLength || !id) return;
touchPlaylist(id);
playTrack(tracks[0], tracks);
}
export function shufflePlaylistAll(deps: BulkPlayDeps): void {
const { songsLength, id, tracks, touchPlaylist, playTrack } = deps;
if (!songsLength || !id) return;
touchPlaylist(id);
const shuffled = [...tracks];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
playTrack(shuffled[0], shuffled);
}
export function enqueuePlaylistAll(deps: BulkPlayDeps): void {
const { songsLength, id, tracks, touchPlaylist, enqueue } = deps;
if (!songsLength || !id) return;
touchPlaylist(id);
enqueue(tracks);
}
@@ -0,0 +1,43 @@
import type { SubsonicSong } from '../../api/subsonicTypes';
export type PlaylistSortKey = 'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration';
export type PlaylistSortDir = 'asc' | 'desc';
export interface DisplayedSongsOptions {
filterText: string;
sortKey: PlaylistSortKey;
sortDir: PlaylistSortDir;
ratings: Record<string, number>;
userRatingOverrides: Record<string, number>;
starredOverrides: Record<string, boolean>;
starredSongs: Set<string>;
}
export function getDisplayedSongs(songs: SubsonicSong[], opts: DisplayedSongsOptions): SubsonicSong[] {
const q = opts.filterText.trim().toLowerCase();
if (!q && opts.sortKey === 'natural') return songs;
let result = [...songs];
if (q) result = result.filter(s => s.title.toLowerCase().includes(q) || (s.artist ?? '').toLowerCase().includes(q));
if (opts.sortKey !== 'natural') {
result.sort((a, b) => {
let av: string | number;
let bv: string | number;
const effectiveRating = (s: SubsonicSong) => opts.ratings[s.id] ?? opts.userRatingOverrides[s.id] ?? s.userRating ?? 0;
const effectiveStarred = (s: SubsonicSong) => (s.id in opts.starredOverrides ? opts.starredOverrides[s.id] : opts.starredSongs.has(s.id)) ? 1 : 0;
switch (opts.sortKey) {
case 'title': av = a.title; bv = b.title; break;
case 'artist': av = a.artist ?? ''; bv = b.artist ?? ''; break;
case 'album': av = a.album ?? ''; bv = b.album ?? ''; break;
case 'favorite': av = effectiveStarred(a); bv = effectiveStarred(b); break;
case 'rating': av = effectiveRating(a); bv = effectiveRating(b); break;
case 'duration': av = a.duration ?? 0; bv = b.duration ?? 0; break;
default: av = a.title; bv = b.title;
}
if (typeof av === 'number' && typeof bv === 'number') {
return opts.sortDir === 'asc' ? av - bv : bv - av;
}
return opts.sortDir === 'asc' ? (av as string).localeCompare(bv as string) : (bv as string).localeCompare(av as string);
});
}
return result;
}
+179
View File
@@ -0,0 +1,179 @@
export const SMART_PREFIX = 'psy-smart-';
export const LIMIT_MAX = 500;
export const YEAR_MIN = 1950;
export const YEAR_MAX = new Date().getFullYear() + 1;
export type GenreMode = 'include' | 'exclude';
export type YearMode = 'include' | 'exclude';
export type SmartFilters = {
name: string;
limit: string;
sort: string;
artistContains: string;
albumContains: string;
titleContains: string;
minRating: number;
excludeUnrated: boolean;
compilationOnly: boolean;
selectedGenres: string[];
genreMode: GenreMode;
yearFrom: number;
yearTo: number;
yearMode: YearMode;
};
export type PendingSmartPlaylist = {
name: string;
id?: string;
firstSeenCoverArt?: string;
attempts: number;
};
export type NdSmartRuleNode = Record<string, unknown>;
export const defaultSmartFilters: SmartFilters = {
name: '',
limit: '50',
sort: '+random',
artistContains: '',
albumContains: '',
titleContains: '',
minRating: 0,
excludeUnrated: false,
compilationOnly: false,
selectedGenres: [],
genreMode: 'include',
yearFrom: YEAR_MIN,
yearTo: YEAR_MAX,
yearMode: 'include',
};
export function clampYear(v: number): number {
return Math.max(YEAR_MIN, Math.min(YEAR_MAX, v));
}
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 asRecord(v: unknown): Record<string, unknown> | null {
return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
}
export function parseSmartRulesToFilters(
rules: Record<string, unknown> | undefined,
playlistName: string,
): SmartFilters {
const next: SmartFilters = {
...defaultSmartFilters,
name: displayPlaylistName(playlistName),
};
if (!rules) return next;
if (typeof rules.limit === 'number' && Number.isFinite(rules.limit)) {
next.limit = String(Math.max(1, Math.min(LIMIT_MAX, Number(rules.limit))));
}
if (typeof rules.sort === 'string' && rules.sort.trim()) next.sort = rules.sort;
const includeGenres: string[] = [];
const excludeGenres: string[] = [];
const all = Array.isArray(rules.all) ? rules.all : [];
for (const node of all) {
const obj = asRecord(node);
if (!obj) continue;
const contains = asRecord(obj.contains);
if (contains) {
if (typeof contains.artist === 'string') next.artistContains = contains.artist;
if (typeof contains.album === 'string') next.albumContains = contains.album;
if (typeof contains.title === 'string') next.titleContains = contains.title;
}
const gt = asRecord(obj.gt);
if (gt && typeof gt.rating === 'number') {
if (gt.rating > 0) next.minRating = Math.max(0, Math.min(5, Math.floor(gt.rating)));
else if (gt.rating === 0) next.excludeUnrated = true;
}
const is = asRecord(obj.is);
if (is?.compilation === true) next.compilationOnly = true;
const notContains = asRecord(obj.notContains);
if (notContains && typeof notContains.genre === 'string') excludeGenres.push(notContains.genre);
const inTheRange = asRecord(obj.inTheRange);
if (inTheRange && Array.isArray(inTheRange.year) && inTheRange.year.length === 2) {
const from = Number(inTheRange.year[0]);
const to = Number(inTheRange.year[1]);
if (Number.isFinite(from) && Number.isFinite(to)) {
next.yearMode = 'include';
next.yearFrom = clampYear(Math.min(from, to));
next.yearTo = clampYear(Math.max(from, to));
}
}
const any = Array.isArray(obj.any) ? (obj.any as NdSmartRuleNode[]) : [];
if (any.length > 0) {
const parsedGenreIncludes = any
.map((item) => asRecord(asRecord(item)?.contains)?.genre)
.filter((v): v is string => typeof v === 'string');
if (parsedGenreIncludes.length > 0) includeGenres.push(...parsedGenreIncludes);
const ltYear = any.map((item) => asRecord(asRecord(item)?.lt)?.year).find((v) => typeof v === 'number');
const gtYear = any.map((item) => asRecord(asRecord(item)?.gt)?.year).find((v) => typeof v === 'number');
if (typeof ltYear === 'number' && typeof gtYear === 'number') {
next.yearMode = 'exclude';
next.yearFrom = clampYear(Math.min(ltYear, gtYear));
next.yearTo = clampYear(Math.max(ltYear, gtYear));
}
}
}
if (includeGenres.length > 0) {
next.genreMode = 'include';
next.selectedGenres = [...new Set(includeGenres)];
} else if (excludeGenres.length > 0) {
next.genreMode = 'exclude';
next.selectedGenres = [...new Set(excludeGenres)];
}
return next;
}
export function buildSmartRulesPayload(filters: SmartFilters): Record<string, unknown> {
const all: Record<string, unknown>[] = [];
if (filters.artistContains.trim()) all.push({ contains: { artist: filters.artistContains.trim() } });
if (filters.albumContains.trim()) all.push({ contains: { album: filters.albumContains.trim() } });
if (filters.titleContains.trim()) all.push({ contains: { title: filters.titleContains.trim() } });
const minRating = Number(filters.minRating);
if (Number.isFinite(minRating) && minRating > 0) all.push({ gt: { rating: minRating } });
else if (filters.excludeUnrated) all.push({ gt: { rating: 0 } });
if (filters.compilationOnly) all.push({ is: { compilation: true } });
if (filters.selectedGenres.length > 0) {
if (filters.genreMode === 'include') {
all.push({ any: filters.selectedGenres.map(v => ({ contains: { genre: v } })) });
} else {
for (const g of filters.selectedGenres) all.push({ notContains: { genre: g } });
}
}
if (filters.yearMode === 'include') {
all.push({ inTheRange: { year: [filters.yearFrom, filters.yearTo] } });
} else {
all.push({ any: [{ lt: { year: filters.yearFrom } }, { gt: { year: filters.yearTo } }] });
}
const rules: Record<string, unknown> = { all };
rules.limit = Math.max(1, Math.min(LIMIT_MAX, Number(filters.limit) || 50));
rules.sort = filters.sort;
return rules;
}
+221
View File
@@ -0,0 +1,221 @@
import type { TFunction } from 'i18next';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { readTextFile } from '@tauri-apps/plugin-fs';
import { search } from '../../api/subsonicSearch';
import type { SubsonicSong } from '../../api/subsonicTypes';
import { showToast } from '../ui/toast';
import { parseSpotifyCsv, type SpotifyCsvTrack } from './spotifyCsvImport';
import {
cleanTrackTitle,
similarityScore,
calculateDynamicThreshold,
processBatch,
} from './spotifyCsvMatch';
export interface CsvImportReport {
added: number;
notFound: SpotifyCsvTrack[];
duplicates: number;
duplicateTracks: SpotifyCsvTrack[];
total: number;
searchErrors?: SpotifyCsvTrack[];
}
export interface RunPlaylistCsvImportDeps {
songs: SubsonicSong[];
t: TFunction;
savePlaylist: (updatedSongs: SubsonicSong[], prevCount?: number) => Promise<void>;
setSongs: (next: SubsonicSong[]) => void;
setCsvImporting: (v: boolean) => void;
setCsvImportReport: (r: CsvImportReport | null) => void;
}
export async function runPlaylistCsvImport(deps: RunPlaylistCsvImportDeps): Promise<void> {
const { songs, t, savePlaylist, setSongs, setCsvImporting, setCsvImportReport } = deps;
try {
const selected = await openDialog({
filters: [{ name: 'CSV Files', extensions: ['csv'] }],
multiple: false,
title: 'Import Spotify Playlist CSV',
});
if (!selected || typeof selected !== 'string') return;
setCsvImporting(true);
const content = await readTextFile(selected);
const csvTracks = parseSpotifyCsv(content);
if (csvTracks.length === 0) {
showToast(t('playlists.csvImportNoValidTracks'), 3000, 'error');
setCsvImporting(false);
return;
}
const existingIds = new Set(songs.map(s => s.id));
const addedSongs: SubsonicSong[] = [];
const notFound: SpotifyCsvTrack[] = [];
const searchErrors: SpotifyCsvTrack[] = [];
const duplicateTracks: SpotifyCsvTrack[] = [];
let duplicateCount = 0;
// Process in batches of 10 to balance speed/server load
await processBatch(csvTracks, 10, async (track) => {
try {
// Retry: 2 attempts in case of network error
let searchResult;
let attempts = 0;
const maxAttempts = 2;
// Clean title before search to find matches despite version suffixes
const cleanTitleForSearch = cleanTrackTitle(track.trackName);
while (attempts < maxAttempts) {
try {
searchResult = await search(cleanTitleForSearch, { songCount: 40, artistCount: 0, albumCount: 0 });
break;
} catch (err) {
attempts++;
if (attempts >= maxAttempts) throw err;
// Wait 500ms before retrying
await new Promise(r => setTimeout(r, 500));
}
}
if (!searchResult || searchResult.songs.length === 0) {
notFound.push({
...track,
score: 0,
thresholdNeeded: 0.6, // Minimum threshold, nothing to compare
});
return null;
}
// Confidence scoring for each result
// Clean CSV title for fair comparison
const cleanCsvTitle = cleanTrackTitle(track.trackName);
const scoredMatches = searchResult.songs.map(s => {
// Fast ISRC path: if both have ISRC and they match, perfect score
if (track.isrc && s.isrc && typeof s.isrc === 'string' && track.isrc.toUpperCase() === s.isrc.toUpperCase()) {
return { song: s, score: 1.0, titleScore: 1.0, artistScore: 1.0, isrcMatch: true };
}
// Clean the result title as well
const cleanResultTitle = cleanTrackTitle(s.title);
const titleScore = similarityScore(cleanResultTitle, cleanCsvTitle);
// Artist scoring: maximum score against any of the CSV artists
const artistScore = s.artist
? Math.max(...track.artistNames.map(csvArtist =>
similarityScore(s.artist || '', csvArtist)
))
: 0;
// If no album in CSV or local, use 1.0 (neutral) to avoid penalizing
const albumScore = (s.album && track.albumName)
? similarityScore(s.album, track.albumName)
: 1.0;
// Dynamic weight: specific titles (>4 words) → more weight to title
const titleWords = cleanCsvTitle.split(/\s+/).length;
const isSpecificTitle = titleWords > 4;
const titleWeight = isSpecificTitle ? 0.55 : 0.4;
const artistWeight = isSpecificTitle ? 0.25 : 0.4;
const totalScore = artistScore * artistWeight + titleScore * titleWeight + albumScore * 0.2;
return { song: s, score: totalScore, titleScore, artistScore, albumScore, isrcMatch: false };
}).sort((a, b) => b.score - a.score);
// Use dynamic threshold based on match quality signals
const bestMatch = scoredMatches[0];
const secondMatch = scoredMatches[1];
const titleWords = cleanCsvTitle.split(/\s+/).length;
const threshold = calculateDynamicThreshold(bestMatch, secondMatch, titleWords);
if (bestMatch.score < threshold) {
notFound.push({
...track,
score: bestMatch.score,
thresholdNeeded: threshold,
});
return null;
}
// Check for duplicates
if (existingIds.has(bestMatch.song.id)) {
duplicateCount++;
duplicateTracks.push(track);
return null;
}
// Check for duplicates in tracks already queued for addition
if (addedSongs.some(s => s.id === bestMatch.song.id)) {
duplicateCount++;
duplicateTracks.push(track);
return null;
}
addedSongs.push(bestMatch.song);
existingIds.add(bestMatch.song.id);
return bestMatch.song;
} catch {
searchErrors.push(track);
return null;
}
});
if (addedSongs.length > 0) {
const next = [...songs, ...addedSongs];
setSongs(next);
await savePlaylist(next);
}
// Auto-show report if there are not found tracks, duplicates, or search errors
if (notFound.length > 0 || duplicateCount > 0 || searchErrors.length > 0) {
// Small delay to let the toast appear first
setTimeout(() => {
setCsvImportReport({
added: addedSongs.length,
notFound,
duplicates: duplicateCount,
duplicateTracks,
total: csvTracks.length,
searchErrors,
});
}, 500);
}
const errorMsg = searchErrors.length > 0
? ` (${searchErrors.length} network errors - may retry)`
: '';
// Determine toast type based on results:
// - success: all songs were added successfully
// - warning: at least one added, but some not found/duplicates
// - error: none added (all duplicates or not found)
const hasAdded = addedSongs.length > 0;
const hasIssues = notFound.length > 0 || duplicateCount > 0 || searchErrors.length > 0;
let toastVariant: 'success' | 'warning' | 'error';
if (hasAdded && !hasIssues) {
toastVariant = 'success';
} else if (hasAdded && hasIssues) {
toastVariant = 'warning';
} else {
toastVariant = 'error';
}
showToast(
t('playlists.csvImportToast', { added: addedSongs.length, notFound: notFound.length, duplicates: duplicateCount }) + errorMsg,
5000,
toastVariant
);
} catch (err) {
console.error('CSV import failed:', err);
showToast(t('playlists.csvImportFailed'), 3000, 'error');
} finally {
setCsvImporting(false);
}
}
+38
View File
@@ -0,0 +1,38 @@
import type React from 'react';
import { getPlaylist } from '../../api/subsonicPlaylists';
import { filterSongsToActiveLibrary } from '../../api/subsonicLibrary';
import type { SubsonicPlaylist, SubsonicSong } from '../../api/subsonicTypes';
export interface RunPlaylistLoadDeps {
id: string;
setLoading: (v: boolean) => void;
setPlaylist: React.Dispatch<React.SetStateAction<SubsonicPlaylist | null>>;
setSongs: React.Dispatch<React.SetStateAction<SubsonicSong[]>>;
setCustomCoverId: React.Dispatch<React.SetStateAction<string | null>>;
setRatings: React.Dispatch<React.SetStateAction<Record<string, number>>>;
setStarredSongs: React.Dispatch<React.SetStateAction<Set<string>>>;
}
export async function runPlaylistLoad(deps: RunPlaylistLoadDeps): Promise<void> {
const { id, setLoading, setPlaylist, setSongs, setCustomCoverId, setRatings, setStarredSongs } = deps;
setLoading(true);
try {
const { playlist, songs } = await getPlaylist(id);
const filteredSongs = await filterSongsToActiveLibrary(songs);
setPlaylist(playlist);
setSongs(filteredSongs);
if (playlist.coverArt) setCustomCoverId(playlist.coverArt);
const init: Record<string, number> = {};
const starred = new Set<string>();
filteredSongs.forEach(s => {
if (s.userRating) init[s.id] = s.userRating;
if (s.starred) starred.add(s.id);
});
setRatings(init);
setStarredSongs(starred);
} catch {
// intentional swallow; load failure leaves loading false + playlist null
} finally {
setLoading(false);
}
}
@@ -0,0 +1,45 @@
import type React from 'react';
import type { SubsonicSong } from '../../api/subsonicTypes';
export interface RunPlaylistReorderDropDeps {
e: Event;
songs: SubsonicSong[];
savePlaylist: (next: SubsonicSong[], prevCount?: number) => Promise<void>;
setDropTargetIdx: React.Dispatch<React.SetStateAction<{ idx: number; before: boolean } | null>>;
setSongs: React.Dispatch<React.SetStateAction<SubsonicSong[]>>;
}
export function runPlaylistReorderDrop(deps: RunPlaylistReorderDropDeps): void {
const { e, songs, savePlaylist, setDropTargetIdx, setSongs } = deps;
const detail = (e as CustomEvent).detail;
if (!detail?.data) return;
let parsed: any;
try { parsed = JSON.parse(detail.data); } catch { return; }
if (parsed.type !== 'playlist_reorder') return;
setDropTargetIdx(null);
const fromIdx: number = parsed.index;
// Determine drop index from the event target row
const target = (e.target as HTMLElement).closest('[data-track-idx]');
let toIdx = songs.length;
if (target) {
const targetIdx = parseInt(target.getAttribute('data-track-idx') ?? '', 10);
const rect = target.getBoundingClientRect();
const cursorY = (e as CustomEvent & { clientY?: number }).clientY ?? (rect.top + rect.height / 2);
const before = cursorY < rect.top + rect.height / 2;
toIdx = before ? targetIdx : targetIdx + 1;
}
if (fromIdx === toIdx || fromIdx === toIdx - 1) return;
setSongs(prev => {
const next = [...prev];
const [moved] = next.splice(fromIdx, 1);
const insertAt = toIdx > fromIdx ? toIdx - 1 : toIdx;
next.splice(insertAt, 0, moved);
savePlaylist(next);
return next;
});
}
+46
View File
@@ -0,0 +1,46 @@
import type { TFunction } from 'i18next';
import { getPlaylist, updatePlaylistMeta, uploadPlaylistCoverArt } from '../../api/subsonicPlaylists';
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
import { showToast } from '../ui/toast';
export interface RunPlaylistSaveMetaDeps {
id: string;
playlist: SubsonicPlaylist;
t: TFunction;
setPlaylist: (updater: (p: SubsonicPlaylist | null) => SubsonicPlaylist | null) => void;
setCustomCoverId: (id: string | null) => void;
setEditingMeta: (v: boolean) => void;
}
export async function runPlaylistSaveMeta(
deps: RunPlaylistSaveMetaDeps,
opts: {
name: string;
comment: string;
isPublic: boolean;
coverFile: File | null;
coverRemoved: boolean;
},
): Promise<void> {
const { id, playlist, t, setPlaylist, setCustomCoverId, setEditingMeta } = deps;
await updatePlaylistMeta(id, opts.name.trim() || playlist.name, opts.comment, opts.isPublic);
setPlaylist(p => p
? { ...p, name: opts.name.trim() || p.name, comment: opts.comment, public: opts.isPublic }
: p
);
if (opts.coverFile) {
try {
await uploadPlaylistCoverArt(id, opts.coverFile);
const { playlist: refreshed } = await getPlaylist(id);
setPlaylist(prev => prev ? { ...prev, coverArt: refreshed.coverArt } : prev);
if (refreshed.coverArt) setCustomCoverId(refreshed.coverArt);
showToast(t('playlists.coverUpdated'));
} catch (err) {
showToast(err instanceof Error ? err.message : t('playlists.coverUpdated'), 3000, 'error');
}
} else if (opts.coverRemoved) {
setCustomCoverId(null);
}
showToast(t('playlists.metaSaved'));
setEditingMeta(false);
}
@@ -0,0 +1,36 @@
import { invoke } from '@tauri-apps/api/core';
import { join } from '@tauri-apps/api/path';
import { buildDownloadUrl } from '../../api/subsonicStreamUrl';
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
import { useZipDownloadStore } from '../../store/zipDownloadStore';
import { sanitizeFilename } from '../componentHelpers/playlistDetailHelpers';
export interface RunPlaylistZipDownloadDeps {
playlist: SubsonicPlaylist;
id: string;
downloadFolder: string | null;
requestDownloadFolder: () => Promise<string | null>;
setZipDownloadId: (id: string | null) => void;
}
export async function runPlaylistZipDownload(deps: RunPlaylistZipDownloadDeps): Promise<void> {
const { playlist, id, downloadFolder, requestDownloadFolder, setZipDownloadId } = deps;
const folder = downloadFolder || await requestDownloadFolder();
if (!folder) return;
const filename = `${sanitizeFilename(playlist.name)}.zip`;
const destPath = await join(folder, filename);
const url = buildDownloadUrl(id);
const downloadId = crypto.randomUUID();
const { start, complete, fail } = useZipDownloadStore.getState();
start(downloadId, filename);
setZipDownloadId(downloadId);
try {
await invoke('download_zip', { id: downloadId, url, destPath });
complete(downloadId);
} catch (e) {
fail(downloadId);
console.error('ZIP download failed:', e);
}
}
+110
View File
@@ -0,0 +1,110 @@
import type React from 'react';
import type { TFunction } from 'i18next';
import { deletePlaylist, getPlaylist, updatePlaylist } from '../../api/subsonicPlaylists';
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
import { usePlaylistStore } from '../../store/playlistStore';
import { showToast } from '../ui/toast';
export interface RunPlaylistDeleteDeps {
e: React.MouseEvent;
pl: SubsonicPlaylist;
deleteConfirmId: string | null;
setDeleteConfirmId: React.Dispatch<React.SetStateAction<string | null>>;
removeId: (id: string) => void;
t: TFunction;
}
export async function runPlaylistDelete(deps: RunPlaylistDeleteDeps): Promise<void> {
const { e, pl, deleteConfirmId, setDeleteConfirmId, removeId, t } = deps;
e.stopPropagation();
if (deleteConfirmId !== pl.id) {
setDeleteConfirmId(pl.id);
const btn = e.currentTarget as HTMLElement;
requestAnimationFrame(() => {
btn.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
});
return;
}
try {
await deletePlaylist(pl.id);
removeId(pl.id);
usePlaylistStore.setState((s) => ({
playlists: s.playlists.filter((p) => p.id !== pl.id),
}));
showToast(t('playlists.deleteSuccess', { count: 1 }), 3000, 'info');
} catch {
showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error');
}
setDeleteConfirmId(null);
}
export interface RunPlaylistDeleteSelectedDeps {
selectedPlaylists: SubsonicPlaylist[];
selectedIds: Set<string>;
isPlaylistDeletable: (pl: SubsonicPlaylist) => boolean;
removeId: (id: string) => void;
clearSelection: () => void;
t: TFunction;
}
export async function runPlaylistDeleteSelected(deps: RunPlaylistDeleteSelectedDeps): Promise<void> {
const { selectedPlaylists, selectedIds, isPlaylistDeletable, removeId, clearSelection, t } = deps;
const deletable = selectedPlaylists.filter(isPlaylistDeletable);
if (deletable.length === 0) return;
let deleted = 0;
for (const pl of deletable) {
try {
await deletePlaylist(pl.id);
removeId(pl.id);
deleted++;
} catch {
showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error');
}
}
usePlaylistStore.setState((s) => ({
playlists: s.playlists.filter((p) => !(selectedIds.has(p.id) && isPlaylistDeletable(p))),
}));
clearSelection();
if (deleted > 0) {
showToast(t('playlists.deleteSuccess', { count: deleted }), 3000, 'info');
}
}
export interface RunPlaylistMergeSelectedDeps {
targetPlaylist: SubsonicPlaylist;
selectedPlaylists: SubsonicPlaylist[];
touchPlaylist: (id: string) => void;
clearSelection: () => void;
t: TFunction;
}
export async function runPlaylistMergeSelected(deps: RunPlaylistMergeSelectedDeps): Promise<void> {
const { targetPlaylist, selectedPlaylists, touchPlaylist, clearSelection, t } = deps;
if (selectedPlaylists.length === 0) return;
try {
const { songs: targetSongs } = await getPlaylist(targetPlaylist.id);
const targetIds = new Set(targetSongs.map(s => s.id));
let totalAdded = 0;
for (const pl of selectedPlaylists) {
if (pl.id === targetPlaylist.id) continue;
const { songs } = await getPlaylist(pl.id);
const newSongs = songs.filter(s => !targetIds.has(s.id));
if (newSongs.length > 0) {
newSongs.forEach(s => targetIds.add(s.id));
totalAdded += newSongs.length;
}
}
if (totalAdded > 0) {
await updatePlaylist(targetPlaylist.id, Array.from(targetIds));
touchPlaylist(targetPlaylist.id);
showToast(t('playlists.mergeSuccess', { count: totalAdded, playlist: targetPlaylist.name }), 3000, 'info');
} else {
showToast(t('playlists.mergeNoNewSongs'), 3000, 'info');
}
clearSelection();
} catch {
showToast(t('playlists.mergeError'), 4000, 'error');
}
}
@@ -0,0 +1,78 @@
import type React from 'react';
import type { TFunction } from 'i18next';
import { ndGetSmartPlaylist, ndListSmartPlaylists } from '../../api/navidromeSmart';
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
import {
defaultSmartFilters, displayPlaylistName, isSmartPlaylistName,
parseSmartRulesToFilters, type SmartFilters,
} from './playlistsSmart';
import { showToast } from '../ui/toast';
export interface RunPlaylistsOpenSmartEditorDeps {
pl: SubsonicPlaylist;
isNavidromeServer: boolean;
t: TFunction;
setSmartFilters: React.Dispatch<React.SetStateAction<SmartFilters>>;
setEditingSmartId: React.Dispatch<React.SetStateAction<string | null>>;
setGenreQuery: React.Dispatch<React.SetStateAction<string>>;
setCreating: React.Dispatch<React.SetStateAction<boolean>>;
setCreatingSmart: React.Dispatch<React.SetStateAction<boolean>>;
setCreatingSmartBusy: React.Dispatch<React.SetStateAction<boolean>>;
}
export async function runPlaylistsOpenSmartEditor(deps: RunPlaylistsOpenSmartEditorDeps): Promise<void> {
const {
pl, isNavidromeServer, t,
setSmartFilters, setEditingSmartId, setGenreQuery,
setCreating, setCreatingSmart, setCreatingSmartBusy,
} = deps;
if (!isNavidromeServer || !isSmartPlaylistName(pl.name)) return;
setCreatingSmartBusy(true);
try {
let target: { id: string; name: string; rules?: Record<string, unknown> } | null = null;
try {
// Prefer direct endpoint for this playlist: returns freshest rules.
const direct = await ndGetSmartPlaylist(pl.id);
if (direct.id && (direct.rules || isSmartPlaylistName(direct.name))) target = direct;
} catch {
// Fallback to list endpoint below.
}
if (!target) {
const smart = await ndListSmartPlaylists();
target = smart.find((v) =>
v.id === pl.id ||
v.name === pl.name ||
displayPlaylistName(v.name) === displayPlaylistName(pl.name),
) ?? null;
}
if (target) {
setSmartFilters(parseSmartRulesToFilters(target.rules, target.name));
setEditingSmartId(target.id);
} else {
// Fallback: allow editing even if Navidrome smart list endpoint
// doesn't return this playlist (shared/migrated/legacy edge cases).
setSmartFilters({
...defaultSmartFilters,
name: displayPlaylistName(pl.name),
});
setEditingSmartId(pl.id);
}
setGenreQuery('');
setCreating(false);
setCreatingSmart(true);
} catch {
// Degrade gracefully instead of blocking the editor on transient/API errors.
setSmartFilters({
...defaultSmartFilters,
name: displayPlaylistName(pl.name),
});
setGenreQuery('');
setEditingSmartId(pl.id);
setCreating(false);
setCreatingSmart(true);
showToast(t('smartPlaylists.loadFailed'), 3500, 'warning');
} finally {
setCreatingSmartBusy(false);
}
}
@@ -0,0 +1,85 @@
import type React from 'react';
import type { TFunction } from 'i18next';
import { ndCreateSmartPlaylist, ndUpdateSmartPlaylist } from '../../api/navidromeSmart';
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
import { usePlaylistStore } from '../../store/playlistStore';
import {
buildSmartRulesPayload, defaultSmartFilters, SMART_PREFIX,
type PendingSmartPlaylist, type SmartFilters,
} from './playlistsSmart';
import { showToast } from '../ui/toast';
export interface RunPlaylistsSaveSmartDeps {
isNavidromeServer: boolean;
smartFilters: SmartFilters;
editingSmartId: string | null;
playlists: SubsonicPlaylist[];
fetchPlaylists: () => Promise<void>;
t: TFunction;
setPendingSmart: React.Dispatch<React.SetStateAction<PendingSmartPlaylist[]>>;
setCreatingSmart: React.Dispatch<React.SetStateAction<boolean>>;
setEditingSmartId: React.Dispatch<React.SetStateAction<string | null>>;
setSmartFilters: React.Dispatch<React.SetStateAction<SmartFilters>>;
setGenreQuery: React.Dispatch<React.SetStateAction<string>>;
setCreatingSmartBusy: React.Dispatch<React.SetStateAction<boolean>>;
}
export async function runPlaylistsSaveSmart(deps: RunPlaylistsSaveSmartDeps): Promise<void> {
const {
isNavidromeServer, smartFilters, editingSmartId, playlists, fetchPlaylists, t,
setPendingSmart, setCreatingSmart, setEditingSmartId, setSmartFilters,
setGenreQuery, setCreatingSmartBusy,
} = deps;
if (!isNavidromeServer) {
showToast(t('smartPlaylists.navidromeOnly'), 3500, 'error');
return;
}
setCreatingSmartBusy(true);
try {
let baseName = smartFilters.name.trim() || `mix-${new Date().toISOString().slice(0, 10)}`;
if (!editingSmartId) {
const existingNames = new Set(playlists.map((p) => (p.name ?? '').toLowerCase()));
const requestedBaseName = baseName;
let ordinal = 2;
while (existingNames.has(`${SMART_PREFIX}${baseName}`.toLowerCase())) {
baseName = `${requestedBaseName}-${ordinal}`;
ordinal += 1;
}
}
const rules = buildSmartRulesPayload(smartFilters);
const fullName = `${SMART_PREFIX}${baseName}`;
if (editingSmartId) {
await ndUpdateSmartPlaylist(editingSmartId, fullName, rules, true);
} else {
await ndCreateSmartPlaylist(fullName, rules, true);
}
await fetchPlaylists();
const createdName = fullName;
const updatedId = editingSmartId;
setPendingSmart(prev => {
const existing = prev.find(p => p.id === updatedId || p.name === createdName);
if (existing) return prev;
const created = usePlaylistStore.getState().playlists.find((p) => p.id === updatedId || p.name === createdName);
return [
...prev,
{
name: createdName,
id: updatedId ?? created?.id,
firstSeenCoverArt: created?.coverArt,
attempts: 0,
},
];
});
setCreatingSmart(false);
setEditingSmartId(null);
setSmartFilters(defaultSmartFilters);
setGenreQuery('');
if (updatedId) showToast(t('smartPlaylists.updated', { name: createdName }), 3500, 'success');
else showToast(t('smartPlaylists.created', { name: createdName }), 3500, 'success');
} catch {
showToast(editingSmartId ? t('smartPlaylists.updateFailed') : t('smartPlaylists.createFailed'), 3500, 'error');
} finally {
setCreatingSmartBusy(false);
}
}
+144
View File
@@ -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;
}
+177
View File
@@ -0,0 +1,177 @@
// Normalize strings for matching: remove accents, special chars, lowercase, trim
export function normalizeForMatching(s: string): string {
return s.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[ø]/gi, 'o')
.replace(/[æ]/gi, 'ae')
.trim();
}
// Clean common title suffixes (remastered, live, editions, etc.)
export function cleanTrackTitle(title: string): string {
const suffixes = [
// Remastered variants
/\s*-\s*remasterizado$/i,
/\s*-\s*remaster$/i,
/\s*-\s*remastered$/i,
/\s*\(remasterizado\)$/i,
/\s*\(remaster\)$/i,
/\s*\(remastered\)$/i,
/\s*\[remasterizado\]$/i,
/\s*\[remaster\]$/i,
/\s*\[remastered\]$/i,
/\s*-\s*remasterizado\s+\d{4}$/i,
/\s*-\s*remastered\s+\d{4}$/i,
/\s*\(\d{4}\s+remaster\)$/i,
/\s*\(\d{4}\s+remastered\)$/i,
// Live variants
/\s*-\s*en vivo$/i,
/\s*-\s*live$/i,
/\s*-\s*version en vivo$/i,
/\s*-\s*studio version$/i,
/\s*-\s*version de estudio$/i,
/\s*\(en vivo\)$/i,
/\s*\(live\)$/i,
/\s*\(live .*\)$/i,
/\s*\[en vivo\]$/i,
/\s*\[live\]$/i,
/\s*\[live .*\]$/i,
/\s*-\s*live at.*$/i,
/\s*\(live at.*\)$/i,
// Version/Edition variants
/\s*-\s*version$/i,
/\s*-\s*versión$/i,
/\s*\(version\)$/i,
/\s*\(versión\)$/i,
/\s*\[version\]$/i,
/\s*\[versión\]$/i,
/\s*-\s*album version$/i,
/\s*\(album version\)$/i,
/\s*\[album version\]$/i,
// Radio/Edit variants
/\s*-\s*radio edit$/i,
/\s*-\s*radio version$/i,
/\s*\(radio edit\)$/i,
/\s*\(radio version\)$/i,
/\s*\[radio edit\]$/i,
/\s*\[radio version\]$/i,
/\s*-\s*edit$/i,
/\s*\(edit\)$/i,
/\s*\[edit\]$/i,
// Acoustic/Instrumental variants
/\s*-\s*acoustic$/i,
/\s*-\s*acústico$/i,
/\s*\(acoustic\)$/i,
/\s*\(acústico\)$/i,
/\s*\[acoustic\]$/i,
/\s*\[acústico\]$/i,
/\s*-\s*instrumental$/i,
/\s*\(instrumental\)$/i,
/\s*\[instrumental\]$/i,
// Featuring/Feat/Ft/With variants
/\s*\(feat\.?\s+.*\)$/i,
/\s*\[feat\.?\s+.*\]$/i,
/\s*-\s*feat\.?\s+.*$/i,
/\s*\(featuring\s+.*\)$/i,
/\s*\[featuring\s+.*\]$/i,
/\s*\(ft\.?\s+.*\)$/i,
/\s*\[ft\.?\s+.*\]$/i,
/\s*-\s*ft\.?\s+.*$/i,
/\s*\(with\s+.*\)$/i,
/\s*\[with\s+.*\]$/i,
/\s*-\s*with\s+.*$/i,
/\s*ft\.?\s+.*$/i,
// Explicit/Clean tags
/\s*\(explicit\)$/i,
/\s*\[explicit\]$/i,
/\s*\(clean\)$/i,
/\s*\[clean\]$/i,
// Mono/Stereo
/\s*\(mono\)$/i,
/\s*\[mono\]$/i,
/\s*\(stereo\)$/i,
/\s*\[stereo\]$/i,
// Deluxe/Special editions
/\s*-\s*deluxe$/i,
/\s*\(deluxe\)$/i,
/\s*\[deluxe\]$/i,
/\s*-\s*special edition$/i,
/\s*\(special edition\)$/i,
/\s*\[special edition\]$/i,
// Year in parentheses (common in remasters)
/\s*\(\d{4}\)$/i,
];
let cleaned = title.trim();
// Apply patterns multiple times for nested cases
for (let i = 0; i < 3; i++) {
const previous = cleaned;
for (const regex of suffixes) {
cleaned = cleaned.replace(regex, '');
}
cleaned = cleaned.trim();
if (previous === cleaned) break; // No more changes
}
return cleaned;
}
// Levenshtein distance for similarity scoring
export function levenshtein(a: string, b: string): number {
const matrix: number[][] = [];
for (let i = 0; i <= b.length; i++) matrix[i] = [i];
for (let j = 0; j <= a.length; j++) matrix[0][j] = j;
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
matrix[i][j] = b[i - 1] === a[j - 1]
? matrix[i - 1][j - 1]
: Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1);
}
}
return matrix[b.length][a.length];
}
export function similarityScore(a: string, b: string): number {
const maxLen = Math.max(a.length, b.length);
if (maxLen === 0) return 1;
// Use normalized strings (without accents) for comparison
const dist = levenshtein(normalizeForMatching(a), normalizeForMatching(b));
return 1 - dist / maxLen;
}
// Calculate dynamic threshold based on match quality signals
export function calculateDynamicThreshold(
bestMatch: { score: number; artistScore: number },
secondMatch: { score: number } | undefined,
titleWords: number
): number {
const baseThreshold = 0.6; // Minimum acceptable score
// Bonus if there's a large gap between best and second match (clear winner)
const gap = secondMatch ? bestMatch.score - secondMatch.score : 0.3;
const gapBonus = gap > 0.15 ? 0.1 : gap > 0.08 ? 0.05 : 0;
// Short titles (< 3 words) are more ambiguous, need higher threshold
// Long titles (> 4 words) are more specific, can use lower threshold
const lengthBonus = titleWords > 4 ? 0.05 : titleWords < 3 ? -0.05 : 0;
// Strong artist match gives confidence to accept lower overall score
const artistBonus = bestMatch.artistScore > 0.85 ? 0.08 : bestMatch.artistScore > 0.7 ? 0.04 : 0;
// Calculate final threshold, clamp between 0.55 and 0.75
return Math.max(0.55, Math.min(0.75, baseThreshold - gapBonus - lengthBonus - artistBonus));
}
// Process searches in batches to avoid overloading the server
export async function processBatch<T, R>(
items: T[],
batchSize: number,
processor: (item: T) => Promise<R | null>
): Promise<(R | null)[]> {
const results: (R | null)[] = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(processor));
results.push(...batchResults);
}
return results;
}
@@ -0,0 +1,47 @@
import type React from 'react';
import type { SubsonicSong } from '../../api/subsonicTypes';
import { songToTrack } from '../playback/songToTrack';
export interface StartPlaylistRowDragDeps {
e: React.MouseEvent;
idx: number;
songs: SubsonicSong[];
selectedIds: Set<string>;
isFiltered: boolean;
startDrag: (payload: { data: string; label: string }, x: number, y: number) => void;
}
export function startPlaylistRowDrag(deps: StartPlaylistRowDragDeps): void {
const { e, idx, songs, selectedIds, isFiltered, startDrag } = deps;
if (e.button !== 0) return;
if ((e.target as HTMLElement).closest('button, input')) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
if (!isFiltered && selectedIds.has(songs[idx]?.id) && selectedIds.size > 1) {
const bulkTracks = songs.filter(s => selectedIds.has(s.id)).map(songToTrack);
startDrag({ data: JSON.stringify({ type: 'songs', tracks: bulkTracks }), label: `${bulkTracks.length} Songs` }, me.clientX, me.clientY);
} else if (!isFiltered) {
startDrag(
{ data: JSON.stringify({ type: 'playlist_reorder', index: idx }), label: songs[idx]?.title ?? '' },
me.clientX, me.clientY
);
} else {
// filtered view: single-song drag to queue
startDrag(
{ data: JSON.stringify({ type: 'song', track: songToTrack(songs[idx]) }), label: songs[idx]?.title ?? '' },
me.clientX, me.clientY
);
}
}
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}