refactor(playlist): co-locate playlist feature into features/playlist

This commit is contained in:
Psychotoxical
2026-06-30 01:37:05 +02:00
parent 2a425862ae
commit 862941c145
91 changed files with 336 additions and 292 deletions
@@ -0,0 +1,19 @@
import { getPlaylist } from '@/features/playlist/api/subsonicPlaylists';
import { songToTrack } from '@/utils/playback/songToTrack';
import { usePlayerStore } from '@/store/playerStore';
import { playPlaylistAll } from '@/features/playlist/utils/playlistBulkPlayActions';
/**
* Load a playlist's songs and start playback immediately ("Play Now").
*
* Used where only the playlist metadata is on hand — the playlist context menu
* on the Playlists overview — so the tracks have to be fetched first. Once
* loaded it defers to {@link playPlaylistAll}, the same action the playlist
* detail "Play All" button uses, so playback behaviour stays in one place.
*/
export async function playPlaylistById(id: string): Promise<void> {
const { songs } = await getPlaylist(id);
const tracks = songs.map(songToTrack);
const { playTrack, enqueue } = usePlayerStore.getState();
playPlaylistAll({ songsLength: tracks.length, id, tracks, playTrack, enqueue });
}
@@ -0,0 +1,49 @@
import { describe, expect, it, vi } from 'vitest';
import { enqueuePlaylistAll, playPlaylistAll, shufflePlaylistAll } from '@/features/playlist/utils/playlistBulkPlayActions';
import type { Track } from '@/store/playerStoreTypes';
// Only id/queue identity matters for these actions.
const tracks = [{ id: 'a' }, { id: 'b' }, { id: 'c' }] as unknown as Track[];
describe('playlistBulkPlayActions', () => {
it('playPlaylistAll starts the first track with the full queue', () => {
const playTrack = vi.fn();
const enqueue = vi.fn();
playPlaylistAll({ songsLength: tracks.length, id: 'p1', tracks, playTrack, enqueue });
expect(playTrack).toHaveBeenCalledWith(tracks[0], tracks);
expect(enqueue).not.toHaveBeenCalled();
});
it('enqueuePlaylistAll appends every track without starting playback', () => {
const playTrack = vi.fn();
const enqueue = vi.fn();
enqueuePlaylistAll({ songsLength: tracks.length, id: 'p1', tracks, playTrack, enqueue });
expect(enqueue).toHaveBeenCalledWith(tracks);
expect(playTrack).not.toHaveBeenCalled();
});
it('shufflePlaylistAll plays a track from the playlist with the full queue', () => {
const playTrack = vi.fn();
shufflePlaylistAll({ songsLength: tracks.length, id: 'p1', tracks, playTrack, enqueue: vi.fn() });
expect(playTrack).toHaveBeenCalledTimes(1);
const [first, queue] = playTrack.mock.calls[0];
expect(tracks).toContain(first);
expect(queue).toHaveLength(tracks.length);
});
it('no-ops on an empty playlist', () => {
const playTrack = vi.fn();
const enqueue = vi.fn();
playPlaylistAll({ songsLength: 0, id: 'p1', tracks: [], playTrack, enqueue });
shufflePlaylistAll({ songsLength: 0, id: 'p1', tracks: [], playTrack, enqueue });
enqueuePlaylistAll({ songsLength: 0, id: 'p1', tracks: [], playTrack, enqueue });
expect(playTrack).not.toHaveBeenCalled();
expect(enqueue).not.toHaveBeenCalled();
});
it('no-ops without a playlist id', () => {
const playTrack = vi.fn();
playPlaylistAll({ songsLength: tracks.length, id: undefined, tracks, playTrack, enqueue: vi.fn() });
expect(playTrack).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,36 @@
import type { Track } from '@/store/playerStoreTypes';
// No `touchPlaylist` here: playing/shuffling/enqueuing does not modify the
// playlist. Touching it bumps `lastModified`, which is the playlist detail
// page's load-effect trigger, so it would re-fetch and flash the whole
// container on every Play click. Real mutations (add/remove/save) still touch.
export interface BulkPlayDeps {
songsLength: number;
id: string | undefined;
tracks: Track[];
playTrack: (track: Track, queue: Track[]) => void;
enqueue: (tracks: Track[]) => void;
}
export function playPlaylistAll(deps: BulkPlayDeps): void {
const { songsLength, id, tracks, playTrack } = deps;
if (!songsLength || !id) return;
playTrack(tracks[0], tracks);
}
export function shufflePlaylistAll(deps: BulkPlayDeps): void {
const { songsLength, id, tracks, playTrack } = deps;
if (!songsLength || !id) return;
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, enqueue } = deps;
if (!songsLength || !id) return;
enqueue(tracks);
}
@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import type { SubsonicSong } from '@/api/subsonicTypes';
import {
getDisplayedSongs,
type DisplayedSongsOptions,
type PlaylistSortKey,
type PlaylistSortDir,
} from '@/features/playlist/utils/playlistDisplayedSongs';
const song = (id: string, title = id, artist = ''): SubsonicSong =>
({ id, title, artist }) as SubsonicSong;
const opts = (over: Partial<DisplayedSongsOptions> = {}): DisplayedSongsOptions => ({
filterText: '',
sortKey: 'natural',
sortDir: 'asc',
ratings: {},
userRatingOverrides: {},
starredOverrides: {},
starredSongs: new Set<string>(),
...over,
});
const ids = (songs: SubsonicSong[]) => songs.map(s => s.id);
describe('getDisplayedSongs — position (date added)', () => {
// Playlist load order is oldest→newest (servers append new tracks at the end).
const list = [song('a'), song('b'), song('c'), song('d')];
it('ascending keeps the playlist load order (oldest → newest)', () => {
expect(ids(getDisplayedSongs(list, opts({ sortKey: 'position', sortDir: 'asc' })))).toEqual([
'a',
'b',
'c',
'd',
]);
});
it('descending reverses it (newest added first) — the requested behaviour', () => {
expect(ids(getDisplayedSongs(list, opts({ sortKey: 'position', sortDir: 'desc' })))).toEqual([
'd',
'c',
'b',
'a',
]);
});
it('never mutates the input array', () => {
const input = [song('a'), song('b'), song('c')];
getDisplayedSongs(input, opts({ sortKey: 'position', sortDir: 'desc' }));
expect(ids(input)).toEqual(['a', 'b', 'c']);
});
it('filters first, then reverses the surviving rows', () => {
const mixed = [song('1', 'alpha'), song('2', 'beta'), song('3', 'alphabet')];
const out = getDisplayedSongs(
mixed,
opts({ sortKey: 'position', sortDir: 'desc', filterText: 'alpha' }),
);
expect(ids(out)).toEqual(['3', '1']);
});
it("natural still ignores sortDir (it is the reset state, not a position sort)", () => {
const k: PlaylistSortKey = 'natural';
const d: PlaylistSortDir = 'desc';
expect(ids(getDisplayedSongs(list, opts({ sortKey: k, sortDir: d })))).toEqual([
'a',
'b',
'c',
'd',
]);
});
});
@@ -0,0 +1,53 @@
import type { SubsonicSong } from '@/api/subsonicTypes';
export type PlaylistSortKey = 'natural' | 'position' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration' | 'playCount' | 'lastPlayed' | 'bpm';
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 === 'position') {
// Playlist position is the "date added" proxy: servers append new tracks at
// the end, so ascending = oldest→newest (load order) and descending =
// newest→oldest. Reverse rather than compare — stable and O(n), and the
// Subsonic playlist response carries no per-entry timestamp to compare on.
return opts.sortDir === 'desc' ? result.reverse() : result;
}
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;
case 'playCount': av = a.playCount ?? 0; bv = b.playCount ?? 0; break;
case 'lastPlayed': av = a.played ? Date.parse(a.played) || 0 : 0; bv = b.played ? Date.parse(b.played) || 0 : 0; break;
case 'bpm': av = a.bpm ?? 0; bv = b.bpm ?? 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,58 @@
import { describe, expect, it } from 'vitest';
import {
groupPlaylistsByFolder,
nextFolderOrder,
type PlaylistFolder,
} from '@/features/playlist/utils/playlistFolders';
const folder = (id: string, order: number, name = id): PlaylistFolder =>
({ id, name, order, collapsed: false });
const pl = (id: string) => ({ id });
describe('groupPlaylistsByFolder', () => {
it('places playlists into their assigned folder and the rest in ungrouped', () => {
const folders = [folder('f1', 0), folder('f2', 1)];
const result = groupPlaylistsByFolder(
[pl('a'), pl('b'), pl('c'), pl('d')],
folders,
{ a: 'f1', c: 'f2' },
);
expect(result.folders[0].playlists.map(p => p.id)).toEqual(['a']);
expect(result.folders[1].playlists.map(p => p.id)).toEqual(['c']);
expect(result.ungrouped.map(p => p.id)).toEqual(['b', 'd']);
});
it('returns folders in order, including empty ones', () => {
const result = groupPlaylistsByFolder(
[pl('a')],
[folder('f2', 1, 'B'), folder('f1', 0, 'A')],
{ a: 'f1' },
);
expect(result.folders.map(g => g.folder.id)).toEqual(['f1', 'f2']);
expect(result.folders[1].playlists).toEqual([]);
});
it('treats assignments to a missing folder as ungrouped', () => {
const result = groupPlaylistsByFolder([pl('a')], [folder('f1', 0)], { a: 'gone' });
expect(result.ungrouped.map(p => p.id)).toEqual(['a']);
expect(result.folders[0].playlists).toEqual([]);
});
it('preserves input order within a bucket', () => {
const result = groupPlaylistsByFolder(
[pl('c'), pl('a'), pl('b')],
[folder('f1', 0)],
{ a: 'f1', b: 'f1', c: 'f1' },
);
expect(result.folders[0].playlists.map(p => p.id)).toEqual(['c', 'a', 'b']);
});
});
describe('nextFolderOrder', () => {
it('is 0 for an empty list', () => {
expect(nextFolderOrder([])).toBe(0);
});
it('is one past the highest existing order', () => {
expect(nextFolderOrder([folder('a', 0), folder('b', 5)])).toBe(6);
});
});
@@ -0,0 +1,68 @@
/**
* Playlist folders — a local, client-side organisation layer over the server's
* flat playlist list. The Subsonic API has no folder concept, so folders and
* their playlist assignments live only in Psysonic (see `playlistFolderStore`),
* scoped per server. This module holds the shared types and the pure grouping
* function used by every surface that renders folders (sidebar, Playlists page).
*/
export interface PlaylistFolder {
id: string;
name: string;
/** Stable sort order among folders (assigned at creation). */
order: number;
collapsed: boolean;
}
export interface PlaylistFolderGroup<T> {
folder: PlaylistFolder;
playlists: T[];
}
export interface GroupedPlaylists<T> {
/** Folders in display order; each carries its playlists (possibly empty). */
folders: PlaylistFolderGroup<T>[];
/** Playlists not assigned to any (existing) folder, in input order. */
ungrouped: T[];
}
/**
* Split `playlists` into folder groups + an ungrouped remainder.
*
* Folders are returned in `order` (then name) order and always appear, even
* when empty, so a freshly created folder is visible. Playlists keep their
* incoming order within each bucket — callers sort the input upstream. An
* assignment pointing at a folder that no longer exists falls back to ungrouped.
*/
export function groupPlaylistsByFolder<T extends { id: string }>(
playlists: readonly T[],
folders: readonly PlaylistFolder[],
assignments: Readonly<Record<string, string>>,
): GroupedPlaylists<T> {
const orderedFolders = [...folders].sort(
(a, b) => a.order - b.order || a.name.localeCompare(b.name),
);
const byFolder = new Map<string, T[]>();
for (const folder of orderedFolders) byFolder.set(folder.id, []);
const ungrouped: T[] = [];
for (const playlist of playlists) {
const folderId = assignments[playlist.id];
const bucket = folderId != null ? byFolder.get(folderId) : undefined;
if (bucket) bucket.push(playlist);
else ungrouped.push(playlist);
}
return {
folders: orderedFolders.map(folder => ({
folder,
playlists: byFolder.get(folder.id) ?? [],
})),
ungrouped,
};
}
/** Next stable `order` value for a new folder appended to `folders`. */
export function nextFolderOrder(folders: readonly PlaylistFolder[]): number {
return folders.reduce((max, f) => Math.max(max, f.order + 1), 0);
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { buildSmartRulesPayload, defaultSmartFilters, parseSmartRulesToFilters } from '@/features/playlist/utils/playlistsSmart';
describe('buildSmartRulesPayload', () => {
it('collapses exclude-all-genres into an untagged-only rule', () => {
const filters = {
...defaultSmartFilters,
genreMode: 'exclude' as const,
selectedGenres: ['Rock', 'Jazz', 'Pop'],
};
const rules = buildSmartRulesPayload(filters, { allGenres: ['Rock', 'Jazz', 'Pop'] });
const all = rules.all as Record<string, unknown>[];
expect(all.some(r => (r as { is?: { genre?: string } }).is?.genre === '')).toBe(true);
expect(all.filter(r => 'notContains' in r)).toHaveLength(0);
});
it('keeps per-genre exclusions when only some genres are selected', () => {
const filters = {
...defaultSmartFilters,
genreMode: 'exclude' as const,
selectedGenres: ['Rock'],
};
const rules = buildSmartRulesPayload(filters, { allGenres: ['Rock', 'Jazz'] });
const all = rules.all as Record<string, unknown>[];
expect(all).toContainEqual({ notContains: { genre: 'Rock' } });
});
});
describe('parseSmartRulesToFilters', () => {
it('restores untagged-only exclude rules', () => {
const parsed = parseSmartRulesToFilters(
{ all: [{ is: { genre: '' } }], limit: 50, sort: '+random' },
'psy-smart-test',
);
expect(parsed.untaggedGenresOnly).toBe(true);
expect(parsed.genreMode).toBe('exclude');
});
});
@@ -0,0 +1,204 @@
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;
/** Navidrome `{ is: { genre: '' } }` — tracks with no genre tag. */
untaggedGenresOnly: boolean;
};
export type BuildSmartRulesOptions = {
/** Full genre catalog — used to collapse “exclude every genre” into an untagged-only rule. */
allGenres?: string[];
};
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',
untaggedGenresOnly: false,
};
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;
if (is && is.genre === '') {
next.genreMode = 'exclude';
next.untaggedGenresOnly = 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;
}
function shouldUseUntaggedGenreRule(filters: SmartFilters, allGenres?: string[]): boolean {
if (filters.untaggedGenresOnly) return true;
if (filters.genreMode !== 'exclude' || filters.selectedGenres.length === 0) return false;
if (!allGenres || allGenres.length === 0) return false;
const selected = new Set(filters.selectedGenres);
return allGenres.every(g => selected.has(g));
}
export function buildSmartRulesPayload(
filters: SmartFilters,
opts?: BuildSmartRulesOptions,
): 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 (shouldUseUntaggedGenreRule(filters, opts?.allGenres)) {
all.push({ is: { genre: '' } });
} else 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 '@/utils/ui/toast';
import { parseSpotifyCsv, type SpotifyCsvTrack } from '@/features/playlist/utils/spotifyCsvImport';
import {
cleanTrackTitle,
similarityScore,
calculateDynamicThreshold,
processBatch,
} from '@/features/playlist/utils/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,64 @@
import type React from 'react';
import { getPlaylist } from '@/features/playlist/api/subsonicPlaylists';
import { filterSongsToActiveLibrary } from '@/api/subsonicLibrary';
import type { SubsonicPlaylist, SubsonicSong } from '@/api/subsonicTypes';
import { useAuthStore } from '@/store/authStore';
import { usePlaylistStore } from '@/features/playlist/store/playlistStore';
import { isOfflineBrowseActive } from '@/features/offline';
import { resolvePlaylist } from '@/features/offline';
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>>>;
}
function applyLoadedPlaylist(
deps: RunPlaylistLoadDeps,
playlist: SubsonicPlaylist,
songs: SubsonicSong[],
): void {
const { setPlaylist, setSongs, setCustomCoverId, setRatings, setStarredSongs } = deps;
setPlaylist(playlist);
setSongs(songs);
if (playlist.coverArt) setCustomCoverId(playlist.coverArt);
const init: Record<string, number> = {};
const starred = new Set<string>();
songs.forEach(s => {
if (s.userRating) init[s.id] = s.userRating;
if (s.starred) starred.add(s.id);
});
setRatings(init);
setStarredSongs(starred);
}
export async function runPlaylistLoad(deps: RunPlaylistLoadDeps): Promise<void> {
const { id, setLoading, setPlaylist, setSongs } = deps;
setLoading(true);
try {
const serverId = useAuthStore.getState().activeServerId ?? '';
if (isOfflineBrowseActive() && serverId) {
const loaded = await resolvePlaylist(serverId, id);
if (loaded) {
applyLoadedPlaylist(deps, loaded.playlist, loaded.songs);
return;
}
}
const { playlist, songs } = await getPlaylist(id);
const filteredSongs = await filterSongsToActiveLibrary(songs);
applyLoadedPlaylist(deps, playlist, filteredSongs);
} catch {
const stub = usePlaylistStore.getState().playlists.find(p => p.id === id);
if (stub) {
setPlaylist(stub);
setSongs([]);
}
} 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: { type?: string; index?: number };
try { parsed = JSON.parse(detail.data); } catch { return; }
if (parsed.type !== 'playlist_reorder') return;
setDropTargetIdx(null);
const fromIdx = parsed.index as number;
// 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 '@/features/playlist/api/subsonicPlaylists';
import type { SubsonicPlaylist } from '@/api/subsonicTypes';
import { showToast } from '@/utils/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 '@/features/offline';
import { sanitizeFilename } from '@/utils/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 '@/features/playlist/api/subsonicPlaylists';
import type { SubsonicPlaylist } from '@/api/subsonicTypes';
import { usePlaylistStore } from '@/features/playlist/store/playlistStore';
import { showToast } from '@/utils/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,83 @@
import type React from 'react';
import type { TFunction } from 'i18next';
import { ndGetSmartPlaylist, ndListSmartPlaylists } from '@/api/navidromeSmart';
import type { SubsonicGenre, SubsonicPlaylist } from '@/api/subsonicTypes';
import {
defaultSmartFilters, displayPlaylistName, isSmartPlaylistName,
parseSmartRulesToFilters, type SmartFilters,
} from '@/features/playlist/utils/playlistsSmart';
import { showToast } from '@/utils/ui/toast';
export interface RunPlaylistsOpenSmartEditorDeps {
pl: SubsonicPlaylist;
isNavidromeServer: boolean;
allGenres: SubsonicGenre[];
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, allGenres, 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) {
const parsed = parseSmartRulesToFilters(target.rules, target.name);
if (parsed.untaggedGenresOnly) {
parsed.selectedGenres = allGenres.map(g => g.value);
}
setSmartFilters(parsed);
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,86 @@
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 '@/features/playlist/store/playlistStore';
import {
buildSmartRulesPayload, defaultSmartFilters, SMART_PREFIX,
type PendingSmartPlaylist, type SmartFilters,
} from '@/features/playlist/utils/playlistsSmart';
import { showToast } from '@/utils/ui/toast';
export interface RunPlaylistsSaveSmartDeps {
isNavidromeServer: boolean;
smartFilters: SmartFilters;
allGenres: string[];
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, allGenres, 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, { allGenres });
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 '@/utils/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);
}