diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index baec6304..73b1c1ea 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -565,6 +565,99 @@ async fn nd_set_user_libraries( Ok(()) } +/// GET `/api/playlist` — list playlists; pass `smart=true` to filter smart playlists. +#[tauri::command] +async fn nd_list_playlists( + server_url: String, + token: String, + smart: Option, +) -> Result { + let resp = nd_retry(|| { + let client = nd_http_client(); + let mut req = client + .get(format!("{}/api/playlist", server_url)) + .header("X-ND-Authorization", format!("Bearer {}", token)); + if let Some(s) = smart { + req = req.query(&[("smart", s)]); + } + req.send() + }) + .await?; + if !resp.status().is_success() { + return Err(format!("HTTP {}", resp.status())); + } + resp.json::().await.map_err(nd_err) +} + +/// POST `/api/playlist` — create playlist (supports smart rules payload). +#[tauri::command] +async fn nd_create_playlist( + server_url: String, + token: String, + body: serde_json::Value, +) -> Result { + let resp = nd_retry(|| { + nd_http_client() + .post(format!("{}/api/playlist", server_url)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .json(&body) + .send() + }) + .await?; + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(format!("HTTP {}: {}", status, text)); + } + serde_json::from_str(&text).map_err(|e| e.to_string()) +} + +/// PUT `/api/playlist/{id}` — update playlist (supports smart rules payload). +#[tauri::command] +async fn nd_update_playlist( + server_url: String, + token: String, + id: String, + body: serde_json::Value, +) -> Result { + let resp = nd_retry(|| { + nd_http_client() + .put(format!("{}/api/playlist/{}", server_url, id)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .json(&body) + .send() + }) + .await?; + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(format!("HTTP {}: {}", status, text)); + } + Ok(serde_json::from_str(&text).unwrap_or(serde_json::Value::Null)) +} + +/// DELETE `/api/playlist/{id}` — delete playlist. +#[tauri::command] +async fn nd_delete_playlist( + server_url: String, + token: String, + id: String, +) -> Result<(), String> { + let resp = nd_retry(|| { + nd_http_client() + .delete(format!("{}/api/playlist/{}", server_url, id)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .send() + }) + .await?; + let status = resp.status(); + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(format!("HTTP {}: {}", status, text)); + } + Ok(()) +} + const RADIO_PAGE_SIZE: u32 = 25; /// Search the radio-browser.info directory (needs User-Agent header — CORS would block WebView). @@ -3724,6 +3817,10 @@ pub fn run() { nd_delete_user, nd_list_libraries, nd_set_user_libraries, + nd_list_playlists, + nd_create_playlist, + nd_update_playlist, + nd_delete_playlist, search_radio_browser, get_top_radio_stations, fetch_url_bytes, diff --git a/src/api/navidromeSmart.ts b/src/api/navidromeSmart.ts new file mode 100644 index 00000000..878005c4 --- /dev/null +++ b/src/api/navidromeSmart.ts @@ -0,0 +1,114 @@ +import { invoke } from '@tauri-apps/api/core'; +import { useAuthStore } from '../store/authStore'; +import { ndLogin } from './navidromeAdmin'; + +export type SmartRuleOperator = + | 'is' + | 'isNot' + | 'contains' + | 'notContains' + | 'startsWith' + | 'endsWith' + | 'gt' + | 'lt' + | 'inTheRange'; + +export interface SmartRuleCondition { + field: string; + operator: SmartRuleOperator; + value: string | number | boolean | [number, number]; +} + +export interface NdSmartPlaylist { + id: string; + name: string; + songCount: number; + duration?: number; + rules?: Record; + sync?: boolean; + updatedAt?: string; +} + +let authCache: { + key: string; + token: string; + expiresAt: number; +} | null = null; + +async function getNavidromeAuth(): Promise<{ serverUrl: string; token: string }> { + const s = useAuthStore.getState(); + const server = s.getActiveServer(); + const serverUrl = s.getBaseUrl(); + if (!serverUrl || !server?.username || !server?.password) { + throw new Error('No active server credentials'); + } + const key = `${serverUrl}|${server.username}|${server.password}`; + if (authCache && authCache.key === key && Date.now() < authCache.expiresAt) { + return { serverUrl, token: authCache.token }; + } + const login = await ndLogin(serverUrl, server.username, server.password); + authCache = { + key, + token: login.token, + expiresAt: Date.now() + 10 * 60 * 1000, + }; + return { serverUrl, token: login.token }; +} + +function conditionToRule(c: SmartRuleCondition): Record { + return { [c.operator]: { [c.field]: c.value } }; +} + +export function buildSmartRules(conditions: SmartRuleCondition[], opts?: { limit?: number; sort?: string }) { + const all = conditions.map(conditionToRule); + const rules: Record = { all }; + if (typeof opts?.limit === 'number' && opts.limit > 0) rules.limit = opts.limit; + if (opts?.sort) rules.sort = opts.sort; + return rules; +} + +export async function ndListSmartPlaylists(): Promise { + const { serverUrl, token } = await getNavidromeAuth(); + const raw = await invoke('nd_list_playlists', { serverUrl, token, smart: true }); + const list = Array.isArray(raw) + ? raw + : (raw && typeof raw === 'object' && Array.isArray((raw as { items?: unknown[] }).items)) + ? (raw as { items: unknown[] }).items + : []; + return list.map((v) => { + const o = (v as Record) ?? {}; + return { + id: String(o.id ?? ''), + name: String(o.name ?? ''), + songCount: Number(o.songCount ?? 0), + duration: typeof o.duration === 'number' ? o.duration : undefined, + rules: typeof o.rules === 'object' && o.rules ? (o.rules as Record) : undefined, + sync: typeof o.sync === 'boolean' ? o.sync : undefined, + updatedAt: typeof o.updatedAt === 'string' ? o.updatedAt : undefined, + }; + }); +} + +export async function ndCreateSmartPlaylist(name: string, rules: Record, sync = true): Promise { + const { serverUrl, token } = await getNavidromeAuth(); + const raw = await invoke('nd_create_playlist', { + serverUrl, + token, + body: { name, rules, sync }, + }); + const o = (raw as Record) ?? {}; + return { + id: String(o.id ?? ''), + name: String(o.name ?? name), + songCount: Number(o.songCount ?? 0), + duration: typeof o.duration === 'number' ? o.duration : undefined, + rules: typeof o.rules === 'object' && o.rules ? (o.rules as Record) : undefined, + sync: typeof o.sync === 'boolean' ? o.sync : undefined, + updatedAt: typeof o.updatedAt === 'string' ? o.updatedAt : undefined, + }; +} + +export async function ndDeletePlaylist(id: string): Promise { + const { serverUrl, token } = await getNavidromeAuth(); + await invoke('nd_delete_playlist', { serverUrl, token, id }); +} diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 78735743..17456780 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -499,6 +499,30 @@ export async function getRandomSongs(size = 50, genre?: string, timeout = 15000) return data.randomSongs?.song ?? []; } +export interface RandomSongsFilters { + size?: number; + genre?: string; + fromYear?: number; + toYear?: number; +} + +/** Extended random song fetch with server-side year/genre filtering. */ +export async function getRandomSongsFiltered( + filters: RandomSongsFilters, + timeout = 15000, +): Promise { + const params: Record = { + size: filters.size ?? 50, + _t: Date.now(), + ...libraryFilterParams(), + }; + if (filters.genre) params.genre = filters.genre; + if (typeof filters.fromYear === 'number') params.fromYear = filters.fromYear; + if (typeof filters.toYear === 'number') params.toYear = filters.toYear; + const data = await api<{ randomSongs: { song: SubsonicSong[] } }>('getRandomSongs.view', params, timeout); + return data.randomSongs?.song ?? []; +} + export async function getSong(id: string): Promise { try { const data = await api<{ song: SubsonicSong }>('getSong.view', { id }); @@ -995,7 +1019,7 @@ export function buildDownloadUrl(id: string): string { // ─── Playlists ──────────────────────────────────────────────── export async function getPlaylists(): Promise { - const data = await api<{ playlists: { playlist: SubsonicPlaylist[] } }>('getPlaylists.view'); + const data = await api<{ playlists: { playlist: SubsonicPlaylist[] } }>('getPlaylists.view', { _t: Date.now() }); return data.playlists?.playlist ?? []; } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 772763a5..d27346a9 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -11,7 +11,7 @@ import { useTranslation } from 'react-i18next'; import { Settings, PanelLeftClose, PanelLeft, AudioLines, HardDriveDownload, HardDriveUpload, - ChevronDown, Check, Music2, X, ChevronRight, PlayCircle, Trash2, + ChevronDown, Check, Music2, X, ChevronRight, PlayCircle, Sparkles, Trash2, } from 'lucide-react'; import PsysonicLogo from './PsysonicLogo'; import PSmallLogo from './PSmallLogo'; @@ -31,6 +31,17 @@ import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable'; const SIDEBAR_NAV_LONG_PRESS_MS = 1000; const SIDEBAR_NAV_LONG_PRESS_MOVE_CANCEL_PX = 10; +const SMART_PREFIX = 'psy-smart-'; + +function isSmartPlaylistName(name: string): boolean { + return (name ?? '').toLowerCase().startsWith(SMART_PREFIX); +} + +function displayPlaylistName(name: string): string { + const n = name ?? ''; + if (isSmartPlaylistName(n)) return n.slice(SMART_PREFIX.length); + return n; +} function isPointerOutsideAsideSidebar(clientX: number, clientY: number): boolean { const aside = document.querySelector('aside.sidebar'); @@ -569,11 +580,11 @@ export default function Sidebar({ key={pl.id} to={`/playlists/${pl.id}`} className={({ isActive }) => `nav-link sidebar-playlist-item ${isActive ? 'active' : ''}`} - data-tooltip={isCollapsed ? pl.name : undefined} + data-tooltip={isCollapsed ? displayPlaylistName(pl.name) : undefined} data-tooltip-pos="bottom" > - - {pl.name} + {isSmartPlaylistName(pl.name) ? : } + {displayPlaylistName(pl.name)} )) )} diff --git a/src/locales/en.ts b/src/locales/en.ts index 2103e92c..cd7748f5 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -23,6 +23,7 @@ export const enTranslation = { offlineLibrary: 'Offline Library', genres: 'Genres', playlists: 'Playlists', + smartPlaylists: 'Smart Playlists', mostPlayed: 'Most Played', radio: 'Internet Radio', folderBrowser: 'Folder Browser', @@ -1317,6 +1318,50 @@ export const enTranslation = { csvImportDownloadSuccess: 'Report downloaded successfully', csvImportDownloadError: 'Failed to download report', }, + smartPlaylists: { + title: 'Smart Playlists', + empty: 'No smart playlists yet.', + sectionBasic: '1. Basic', + sectionGenres: '2. Genres', + sectionYearsAndFilters: '3. Years and filters', + genreMode: 'Genre mode', + genreModeInclude: 'Include genres', + genreModeExclude: 'Exclude genres', + genreSearchPlaceholder: 'Filter genres...', + availableGenres: 'Available', + selectedGenres: 'Selected', + yearMode: 'Year mode', + yearModeInclude: 'Include range', + yearModeExclude: 'Exclude range', + name: 'Name (without prefix)', + anyGenre: 'Any genre', + limit: 'Limit', + limitHint: 'How many tracks to include in playlist (1-{{max}}, usually 50).', + artistContains: 'Artist contains…', + albumContains: 'Album contains…', + titleContains: 'Title contains…', + minRating: 'Minimum rating', + minRatingAria: 'Minimum rating for smart playlist', + minRatingHint: '0 disables minimum threshold; 1-5 keeps tracks with rating above selected value.', + fromYear: 'From year', + toYear: 'To year', + excludeUnrated: 'Exclude unrated tracks', + compilationOnly: 'Only compilations', + create: 'New Smart Playlist', + created: 'Created {{name}}', + createFailed: 'Could not create smart playlist.', + deleted: 'Deleted {{name}}', + deleteFailed: 'Could not delete smart playlist.', + navidromeOnly: 'Smart playlists can only be created on Navidrome servers.', + loadFailed: 'Could not load smart playlists from Navidrome.', + liveBadge: 'live', + sortRandom: 'Sort: random', + sortTitleAsc: 'Sort: title A-Z', + sortTitleDesc: 'Sort: title Z-A', + sortYearDesc: 'Sort: year desc', + sortYearAsc: 'Sort: year asc', + sortPlayCountDesc: 'Sort: play count desc', + }, mostPlayed: { title: 'Most Played', topArtists: 'Top Artists', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index c2b16385..434edd82 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -23,6 +23,7 @@ export const ruTranslation = { offlineLibrary: 'Офлайн-библиотека', genres: 'Жанры', playlists: 'Плейлисты', + smartPlaylists: 'Смарт-плейлисты', mostPlayed: 'Популярное', radio: 'Онлайн-радио', folderBrowser: 'Браузер папок', @@ -1381,6 +1382,50 @@ export const ruTranslation = { csvImportDownloadSuccess: 'Отчёт успешно скачан', csvImportDownloadError: 'Не удалось скачать отчёт', }, + smartPlaylists: { + title: 'Смарт-плейлисты', + empty: 'Смарт-плейлистов пока нет.', + sectionBasic: '1. База', + sectionGenres: '2. Жанры', + sectionYearsAndFilters: '3. Годы и фильтры', + genreMode: 'Режим жанров', + genreModeInclude: 'Включить жанры', + genreModeExclude: 'Исключить жанры', + genreSearchPlaceholder: 'Фильтр жанров...', + availableGenres: 'Доступные', + selectedGenres: 'Выбранные', + yearMode: 'Режим годов', + yearModeInclude: 'Включить диапазон', + yearModeExclude: 'Исключить диапазон', + name: 'Название (без префикса)', + anyGenre: 'Любой жанр', + limit: 'Лимит', + limitHint: 'Сколько треков включать в плейлист (1-{{max}}, обычно 50).', + artistContains: 'Исполнитель содержит…', + albumContains: 'Альбом содержит…', + titleContains: 'Название содержит…', + minRating: 'Мин. рейтинг', + minRatingAria: 'Минимальный рейтинг для смарт-плейлиста', + minRatingHint: '0 — без минимального порога; 1-5 — оставить треки с рейтингом выше выбранного.', + fromYear: 'Год от', + toYear: 'Год до', + excludeUnrated: 'Исключить треки без рейтинга', + compilationOnly: 'Только сборники', + create: 'Создать смарт-плейлист', + created: 'Создан {{name}}', + createFailed: 'Не удалось создать смарт-плейлист.', + deleted: 'Удалён {{name}}', + deleteFailed: 'Не удалось удалить смарт-плейлист.', + navidromeOnly: 'Смарт-плейлисты можно создавать только на серверах Navidrome.', + loadFailed: 'Не удалось загрузить смарт-плейлисты из Navidrome.', + liveBadge: 'live', + sortRandom: 'Сортировка: случайно', + sortTitleAsc: 'Сортировка: название А-Я', + sortTitleDesc: 'Сортировка: название Я-А', + sortYearDesc: 'Сортировка: год по убыванию', + sortYearAsc: 'Сортировка: год по возрастанию', + sortPlayCountDesc: 'Сортировка: прослушивания по убыванию', + }, mostPlayed: { title: 'Популярное', topArtists: 'Топ исполнителей', diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 69ad6faf..e7dff109 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { useParams, useNavigate } from 'react-router-dom'; -import { ChevronDown, ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera, Download, FileUp, RotateCcw } from 'lucide-react'; +import { ChevronDown, ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera, Download, FileUp, RotateCcw, Sparkles } from 'lucide-react'; import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns'; import { AddToPlaylistSubmenu } from '../components/ContextMenu'; import { @@ -55,6 +55,18 @@ function totalDurationLabel(songs: SubsonicSong[]): string { return formatHumanHoursMinutes(total); } +const SMART_PREFIX = 'psy-smart-'; + +function isSmartPlaylistName(name: string): boolean { + return (name ?? '').toLowerCase().startsWith(SMART_PREFIX); +} + +function displayPlaylistName(name: string): string { + const n = name ?? ''; + if (isSmartPlaylistName(n)) return n.slice(SMART_PREFIX.length); + return n; +} + function codecLabel(song: SubsonicSong, showBitrate: boolean): string { const parts: string[] = []; if (song.suffix) parts.push(song.suffix.toUpperCase()); @@ -1146,7 +1158,10 @@ export default function PlaylistDetail() {
<>
-

{playlist.name}

+

+ {isSmartPlaylistName(playlist.name) && } + {displayPlaylistName(playlist.name)} +

) : ( - )} + {!creating && isNavidromeServer && ( + + )} )}
+ {creatingSmart && ( +
+
+
+
{t('smartPlaylists.sectionBasic')}
+
+ setSmartFilters(v => ({ ...v, name: e.target.value }))} /> +
+ setSmartFilters(v => ({ ...v, limit: e.target.value }))} /> + {t('smartPlaylists.limitHint', { max: LIMIT_MAX })} +
+ +
+
+
+
{t('smartPlaylists.sectionGenres')}
+
+ {t('smartPlaylists.genreMode')} + + +
+ setGenreQuery(e.target.value)} style={{ marginBottom: '0.75rem' }} /> +
+
+
{t('smartPlaylists.availableGenres')}
+
+ {availableGenres.map(g => ( + + ))} +
+
+
+
{t('smartPlaylists.selectedGenres')}
+
+ {smartFilters.selectedGenres.map(g => ( + + ))} +
+
+
+
+
+
{t('smartPlaylists.sectionYearsAndFilters')}
+
+ {t('smartPlaylists.yearMode')} + + +
+
+ {t('smartPlaylists.fromYear')}: {smartFilters.yearFrom} + {t('smartPlaylists.toYear')}: {smartFilters.yearTo} +
+
+
+
+ setSmartFilters(v => ({ ...v, yearFrom: Math.min(clampYear(Number(e.target.value)), v.yearTo) }))} /> + setSmartFilters(v => ({ ...v, yearTo: Math.max(clampYear(Number(e.target.value)), v.yearFrom) }))} /> +
+
+ setSmartFilters(v => ({ ...v, artistContains: e.target.value }))} /> + setSmartFilters(v => ({ ...v, albumContains: e.target.value }))} /> + setSmartFilters(v => ({ ...v, titleContains: e.target.value }))} /> +
{t('smartPlaylists.minRating')}: {smartFilters.minRating}★
+ setSmartFilters(v => ({ ...v, minRating: rating }))} ariaLabel={t('smartPlaylists.minRatingAria')} /> + {t('smartPlaylists.minRatingHint')} +
+
+ + +
+
+
+ + +
+
+
+ )} {/* ── Grid ── */} {playlists.length === 0 ? ( @@ -246,11 +620,29 @@ export default function Playlists() { }} onMouseLeave={() => { if (deleteConfirmId === pl.id) setDeleteConfirmId(null); }} style={selectionMode && selectedIds.has(pl.id) ? { + position: 'relative', outline: '2px solid var(--accent)', outlineOffset: '2px', borderRadius: 'var(--radius-md)' - } : {}} + } : { position: 'relative' }} > + {!selectionMode && isPlaylistDeletable(pl) && ( + + )} {selectionMode && (
{selectedIds.has(pl.id) && } @@ -270,6 +662,29 @@ export default function Playlists() {
)} + {pendingSmart.some(p => p.id === pl.id || p.name === pl.name) && ( +
+ +
+ )} {/* Play overlay — same pattern as AlbumCard */}
@@ -288,7 +703,10 @@ export default function Playlists() {
-
{pl.name}
+
+ {isSmartPlaylistName(pl.name) && } + {displayPlaylistName(pl.name)} +
{t('playlists.songs', { n: pl.songCount })} {pl.duration > 0 && <> · {formatDuration(pl.duration)}} @@ -298,6 +716,7 @@ export default function Playlists() { ))}
)} +
); }