mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
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:
committed by
GitHub
parent
2409a1fec8
commit
7a7a9f5e6b
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user