mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
refactor(playlist): co-locate playlist feature into features/playlist
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user