From 401fed8368e5fa813921b022a1ef899d1826e0ca Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Fri, 24 Apr 2026 17:29:41 +0300 Subject: [PATCH] feat(playlists): improve smart playlist editing and localization Open smart playlist editing from playlist cards, load rules via Navidrome single-playlist API with safer fallbacks, and keep edit visibility aligned with ownership rules. Also add/clean smart playlist locale keys across all supported languages and preserve smarter autogenerated naming behavior. --- src-tauri/src/lib.rs | 23 ++++ src/api/navidromeSmart.ts | 59 ++++++---- src/locales/de.ts | 41 +++++++ src/locales/en.ts | 9 +- src/locales/es.ts | 41 +++++++ src/locales/fr.ts | 41 +++++++ src/locales/nb.ts | 41 +++++++ src/locales/nl.ts | 41 +++++++ src/locales/ru.ts | 9 +- src/locales/zh.ts | 41 +++++++ src/pages/Playlists.tsx | 221 ++++++++++++++++++++++++++++++++++---- src/styles/components.css | 8 ++ 12 files changed, 519 insertions(+), 56 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 73b1c1ea..e4a39196 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -636,6 +636,28 @@ async fn nd_update_playlist( Ok(serde_json::from_str(&text).unwrap_or(serde_json::Value::Null)) } +/// GET `/api/playlist/{id}` — get a single playlist (includes smart rules if available). +#[tauri::command] +async fn nd_get_playlist( + server_url: String, + token: String, + id: String, +) -> Result { + let resp = nd_retry(|| { + nd_http_client() + .get(format!("{}/api/playlist/{}", server_url, id)) + .header("X-ND-Authorization", format!("Bearer {}", token)) + .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( @@ -3820,6 +3842,7 @@ pub fn run() { nd_list_playlists, nd_create_playlist, nd_update_playlist, + nd_get_playlist, nd_delete_playlist, search_radio_browser, get_top_radio_stations, diff --git a/src/api/navidromeSmart.ts b/src/api/navidromeSmart.ts index 878005c4..23ecc6c0 100644 --- a/src/api/navidromeSmart.ts +++ b/src/api/navidromeSmart.ts @@ -29,6 +29,19 @@ export interface NdSmartPlaylist { updatedAt?: string; } +function parseNdSmartPlaylist(raw: unknown, fallback: Partial = {}): NdSmartPlaylist { + const o = (raw as Record) ?? {}; + return { + id: String(o.id ?? fallback.id ?? ''), + name: String(o.name ?? fallback.name ?? ''), + songCount: Number(o.songCount ?? fallback.songCount ?? 0), + duration: typeof o.duration === 'number' ? o.duration : fallback.duration, + rules: typeof o.rules === 'object' && o.rules ? (o.rules as Record) : fallback.rules, + sync: typeof o.sync === 'boolean' ? o.sync : fallback.sync, + updatedAt: typeof o.updatedAt === 'string' ? o.updatedAt : fallback.updatedAt, + }; +} + let authCache: { key: string; token: string; @@ -75,18 +88,7 @@ export async function ndListSmartPlaylists(): Promise { : (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, - }; - }); + return list.map((v) => parseNdSmartPlaylist(v)); } export async function ndCreateSmartPlaylist(name: string, rules: Record, sync = true): Promise { @@ -96,16 +98,29 @@ export async function ndCreateSmartPlaylist(name: string, rules: 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, - }; + return parseNdSmartPlaylist(raw, { name, rules, sync }); +} + +export async function ndUpdateSmartPlaylist( + id: string, + name: string, + rules: Record, + sync = true, +): Promise { + const { serverUrl, token } = await getNavidromeAuth(); + const raw = await invoke('nd_update_playlist', { + serverUrl, + token, + id, + body: { name, rules, sync }, + }); + return parseNdSmartPlaylist(raw, { id, name, rules, sync }); +} + +export async function ndGetSmartPlaylist(id: string): Promise { + const { serverUrl, token } = await getNavidromeAuth(); + const raw = await invoke('nd_get_playlist', { serverUrl, token, id }); + return parseNdSmartPlaylist(raw, { id }); } export async function ndDeletePlaylist(id: string): Promise { diff --git a/src/locales/de.ts b/src/locales/de.ts index ce1874b1..5ddcf1d4 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -1315,6 +1315,47 @@ export const deTranslation = { csvImportDownloadSuccess: 'Bericht erfolgreich heruntergeladen', csvImportDownloadError: 'Bericht konnte nicht heruntergeladen werden', }, + smartPlaylists: { + sectionBasic: '1. Basis', + sectionGenres: '2. Genres', + sectionYearsAndFilters: '3. Jahre und Filter', + genreMode: 'Genre-Modus', + genreModeInclude: 'Genres einschließen', + genreModeExclude: 'Genres ausschließen', + genreSearchPlaceholder: 'Genres filtern...', + availableGenres: 'Verfügbar', + selectedGenres: 'Ausgewählt', + yearMode: 'Jahresmodus', + yearModeInclude: 'Bereich einschließen', + yearModeExclude: 'Bereich ausschließen', + name: 'Name (ohne Präfix)', + limit: 'Limit', + limitHint: 'Wie viele Songs in die Playlist aufgenommen werden (1-{{max}}, üblicherweise 50).', + artistContains: 'Künstler enthält…', + albumContains: 'Album enthält…', + titleContains: 'Titel enthält…', + minRating: 'Mindestbewertung', + minRatingAria: 'Mindestbewertung für Smart Playlist', + minRatingHint: '0 deaktiviert die Mindestgrenze; 1-5 behält Songs mit Bewertung über dem gewählten Wert.', + fromYear: 'Von Jahr', + toYear: 'Bis Jahr', + excludeUnrated: 'Unbewertete Songs ausschließen', + compilationOnly: 'Nur Sammlungen', + create: 'Neue Smart Playlist', + save: 'Smart Playlist speichern', + created: '{{name}} erstellt', + updated: '{{name}} aktualisiert', + createFailed: 'Smart Playlist konnte nicht erstellt werden.', + updateFailed: 'Smart Playlist konnte nicht aktualisiert werden.', + navidromeOnly: 'Smart Playlists können nur auf Navidrome-Servern erstellt werden.', + loadFailed: 'Smart Playlists konnten nicht von Navidrome geladen werden.', + sortRandom: 'Sortierung: zufällig', + sortTitleAsc: 'Sortierung: Titel A-Z', + sortTitleDesc: 'Sortierung: Titel Z-A', + sortYearDesc: 'Sortierung: Jahr absteigend', + sortYearAsc: 'Sortierung: Jahr aufsteigend', + sortPlayCountDesc: 'Sortierung: Wiedergaben absteigend', + }, mostPlayed: { title: 'Meistgehört', topArtists: 'Top-Künstler', diff --git a/src/locales/en.ts b/src/locales/en.ts index cd7748f5..19ea2901 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -1319,8 +1319,6 @@ export const enTranslation = { 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', @@ -1334,7 +1332,6 @@ export const enTranslation = { 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…', @@ -1348,13 +1345,13 @@ export const enTranslation = { excludeUnrated: 'Exclude unrated tracks', compilationOnly: 'Only compilations', create: 'New Smart Playlist', + save: 'Save Smart Playlist', created: 'Created {{name}}', + updated: 'Updated {{name}}', createFailed: 'Could not create smart playlist.', - deleted: 'Deleted {{name}}', - deleteFailed: 'Could not delete smart playlist.', + updateFailed: 'Could not update 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', diff --git a/src/locales/es.ts b/src/locales/es.ts index 1373f94f..e62d61bd 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -1308,6 +1308,47 @@ export const esTranslation = { csvImportDownloadSuccess: 'Reporte descargado exitosamente', csvImportDownloadError: 'Error al descargar el reporte', }, + smartPlaylists: { + sectionBasic: '1. Básico', + sectionGenres: '2. Géneros', + sectionYearsAndFilters: '3. Años y filtros', + genreMode: 'Modo de géneros', + genreModeInclude: 'Incluir géneros', + genreModeExclude: 'Excluir géneros', + genreSearchPlaceholder: 'Filtrar géneros...', + availableGenres: 'Disponibles', + selectedGenres: 'Seleccionados', + yearMode: 'Modo de años', + yearModeInclude: 'Incluir rango', + yearModeExclude: 'Excluir rango', + name: 'Nombre (sin prefijo)', + limit: 'Límite', + limitHint: 'Cuántas canciones incluir en la playlist (1-{{max}}, normalmente 50).', + artistContains: 'Artista contiene…', + albumContains: 'Álbum contiene…', + titleContains: 'Título contiene…', + minRating: 'Valoración mínima', + minRatingAria: 'Valoración mínima para la playlist inteligente', + minRatingHint: '0 desactiva el umbral mínimo; 1-5 mantiene canciones con valoración mayor que el valor seleccionado.', + fromYear: 'Desde año', + toYear: 'Hasta año', + excludeUnrated: 'Excluir canciones sin valoración', + compilationOnly: 'Solo compilaciones', + create: 'Nueva Smart Playlist', + save: 'Guardar Smart Playlist', + created: '{{name}} creada', + updated: '{{name}} actualizada', + createFailed: 'No se pudo crear la smart playlist.', + updateFailed: 'No se pudo actualizar la smart playlist.', + navidromeOnly: 'Las smart playlists solo se pueden crear en servidores Navidrome.', + loadFailed: 'No se pudieron cargar las smart playlists desde Navidrome.', + sortRandom: 'Ordenar: aleatorio', + sortTitleAsc: 'Ordenar: título A-Z', + sortTitleDesc: 'Ordenar: título Z-A', + sortYearDesc: 'Ordenar: año desc', + sortYearAsc: 'Ordenar: año asc', + sortPlayCountDesc: 'Ordenar: reproducciones desc', + }, mostPlayed: { title: 'Más Reproducidos', topArtists: 'Artistas Principales', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index d64e30b6..06183e9b 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -1303,6 +1303,47 @@ export const frTranslation = { csvImportDownloadSuccess: 'Rapport téléchargé avec succès', csvImportDownloadError: 'Échec du téléchargement du rapport', }, + smartPlaylists: { + sectionBasic: '1. Base', + sectionGenres: '2. Genres', + sectionYearsAndFilters: '3. Années et filtres', + genreMode: 'Mode des genres', + genreModeInclude: 'Inclure les genres', + genreModeExclude: 'Exclure les genres', + genreSearchPlaceholder: 'Filtrer les genres...', + availableGenres: 'Disponibles', + selectedGenres: 'Sélectionnés', + yearMode: 'Mode des années', + yearModeInclude: 'Inclure la plage', + yearModeExclude: 'Exclure la plage', + name: 'Nom (sans préfixe)', + limit: 'Limite', + limitHint: 'Nombre de morceaux à inclure dans la playlist (1-{{max}}, généralement 50).', + artistContains: 'Artiste contient…', + albumContains: 'Album contient…', + titleContains: 'Titre contient…', + minRating: 'Note minimale', + minRatingAria: 'Note minimale pour playlist intelligente', + minRatingHint: '0 désactive le seuil minimum; 1-5 conserve les morceaux avec une note supérieure à la valeur choisie.', + fromYear: 'Depuis l\'année', + toYear: 'Jusqu\'à l\'année', + excludeUnrated: 'Exclure les morceaux non notés', + compilationOnly: 'Compilations uniquement', + create: 'Nouvelle Smart Playlist', + save: 'Enregistrer la Smart Playlist', + created: '{{name}} créée', + updated: '{{name}} mise à jour', + createFailed: 'Impossible de créer la smart playlist.', + updateFailed: 'Impossible de mettre à jour la smart playlist.', + navidromeOnly: 'Les smart playlists ne peuvent être créées que sur des serveurs Navidrome.', + loadFailed: 'Impossible de charger les smart playlists depuis Navidrome.', + sortRandom: 'Tri : aléatoire', + sortTitleAsc: 'Tri : titre A-Z', + sortTitleDesc: 'Tri : titre Z-A', + sortYearDesc: 'Tri : année décroissante', + sortYearAsc: 'Tri : année croissante', + sortPlayCountDesc: 'Tri : nombre d\'écoutes décroissant', + }, mostPlayed: { title: 'Les plus joués', topArtists: 'Artistes populaires', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 69967fab..8253d1a2 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -1315,6 +1315,47 @@ export const nbTranslation = { filterCompilations: 'Skjul kompilasjonsartister (Various Artists, Soundtracks, etc.)', filterCompilationsShort: 'Skjul kompilasjoner', }, + smartPlaylists: { + sectionBasic: '1. Grunnleggende', + sectionGenres: '2. Sjanger', + sectionYearsAndFilters: '3. År og filtre', + genreMode: 'Sjanger-modus', + genreModeInclude: 'Inkluder sjangre', + genreModeExclude: 'Ekskluder sjangre', + genreSearchPlaceholder: 'Filtrer sjangre...', + availableGenres: 'Tilgjengelige', + selectedGenres: 'Valgte', + yearMode: 'År-modus', + yearModeInclude: 'Inkluder område', + yearModeExclude: 'Ekskluder område', + name: 'Navn (uten prefiks)', + limit: 'Grense', + limitHint: 'Hvor mange spor som skal inkluderes i spillelisten (1-{{max}}, vanligvis 50).', + artistContains: 'Artist inneholder…', + albumContains: 'Album inneholder…', + titleContains: 'Tittel inneholder…', + minRating: 'Minimumsvurdering', + minRatingAria: 'Minimumsvurdering for smart-spilleliste', + minRatingHint: '0 deaktiverer minimumsterskelen; 1-5 beholder spor med vurdering over valgt verdi.', + fromYear: 'Fra år', + toYear: 'Til år', + excludeUnrated: 'Ekskluder spor uten vurdering', + compilationOnly: 'Kun samlinger', + create: 'Ny Smart-spilleliste', + save: 'Lagre Smart-spilleliste', + created: '{{name}} opprettet', + updated: '{{name}} oppdatert', + createFailed: 'Kunne ikke opprette smart-spilleliste.', + updateFailed: 'Kunne ikke oppdatere smart-spilleliste.', + navidromeOnly: 'Smart-spillelister kan bare opprettes på Navidrome-servere.', + loadFailed: 'Kunne ikke laste smart-spillelister fra Navidrome.', + sortRandom: 'Sortering: tilfeldig', + sortTitleAsc: 'Sortering: tittel A-Å', + sortTitleDesc: 'Sortering: tittel Å-A', + sortYearDesc: 'Sortering: år synkende', + sortYearAsc: 'Sortering: år stigende', + sortPlayCountDesc: 'Sortering: avspillinger synkende', + }, radio: { title: 'Internettradio', empty: 'Ingen radiostasjoner konfigurert.', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index f32bcb3e..9c69d93d 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -1302,6 +1302,47 @@ export const nlTranslation = { csvImportDownloadSuccess: 'Rapport succesvol gedownload', csvImportDownloadError: 'Rapport downloaden mislukt', }, + smartPlaylists: { + sectionBasic: '1. Basis', + sectionGenres: '2. Genres', + sectionYearsAndFilters: '3. Jaren en filters', + genreMode: 'Genre-modus', + genreModeInclude: 'Genres opnemen', + genreModeExclude: 'Genres uitsluiten', + genreSearchPlaceholder: 'Genres filteren...', + availableGenres: 'Beschikbaar', + selectedGenres: 'Geselecteerd', + yearMode: 'Jaarmodus', + yearModeInclude: 'Bereik opnemen', + yearModeExclude: 'Bereik uitsluiten', + name: 'Naam (zonder prefix)', + limit: 'Limiet', + limitHint: 'Hoeveel nummers in de playlist moeten komen (1-{{max}}, meestal 50).', + artistContains: 'Artiest bevat…', + albumContains: 'Album bevat…', + titleContains: 'Titel bevat…', + minRating: 'Minimale beoordeling', + minRatingAria: 'Minimale beoordeling voor smart playlist', + minRatingHint: '0 schakelt de minimumdrempel uit; 1-5 behoudt nummers met een beoordeling boven de gekozen waarde.', + fromYear: 'Vanaf jaar', + toYear: 'Tot jaar', + excludeUnrated: 'Nummers zonder beoordeling uitsluiten', + compilationOnly: 'Alleen compilaties', + create: 'Nieuwe Smart Playlist', + save: 'Smart Playlist opslaan', + created: '{{name}} aangemaakt', + updated: '{{name}} bijgewerkt', + createFailed: 'Smart playlist kon niet worden aangemaakt.', + updateFailed: 'Smart playlist kon niet worden bijgewerkt.', + navidromeOnly: 'Smart playlists kunnen alleen op Navidrome-servers worden aangemaakt.', + loadFailed: 'Smart playlists konden niet vanuit Navidrome worden geladen.', + sortRandom: 'Sorteren: willekeurig', + sortTitleAsc: 'Sorteren: titel A-Z', + sortTitleDesc: 'Sorteren: titel Z-A', + sortYearDesc: 'Sorteren: jaar aflopend', + sortYearAsc: 'Sorteren: jaar oplopend', + sortPlayCountDesc: 'Sorteren: afspeeltelling aflopend', + }, mostPlayed: { title: 'Meest gespeeld', topArtists: 'Topkunstenaars', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 434edd82..60410969 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -1383,8 +1383,6 @@ export const ruTranslation = { csvImportDownloadError: 'Не удалось скачать отчёт', }, smartPlaylists: { - title: 'Смарт-плейлисты', - empty: 'Смарт-плейлистов пока нет.', sectionBasic: '1. База', sectionGenres: '2. Жанры', sectionYearsAndFilters: '3. Годы и фильтры', @@ -1398,7 +1396,6 @@ export const ruTranslation = { yearModeInclude: 'Включить диапазон', yearModeExclude: 'Исключить диапазон', name: 'Название (без префикса)', - anyGenre: 'Любой жанр', limit: 'Лимит', limitHint: 'Сколько треков включать в плейлист (1-{{max}}, обычно 50).', artistContains: 'Исполнитель содержит…', @@ -1412,13 +1409,13 @@ export const ruTranslation = { excludeUnrated: 'Исключить треки без рейтинга', compilationOnly: 'Только сборники', create: 'Создать смарт-плейлист', + save: 'Сохранить смарт-плейлист', created: 'Создан {{name}}', + updated: 'Обновлён {{name}}', createFailed: 'Не удалось создать смарт-плейлист.', - deleted: 'Удалён {{name}}', - deleteFailed: 'Не удалось удалить смарт-плейлист.', + updateFailed: 'Не удалось обновить смарт-плейлист.', navidromeOnly: 'Смарт-плейлисты можно создавать только на серверах Navidrome.', loadFailed: 'Не удалось загрузить смарт-плейлисты из Navidrome.', - liveBadge: 'live', sortRandom: 'Сортировка: случайно', sortTitleAsc: 'Сортировка: название А-Я', sortTitleDesc: 'Сортировка: название Я-А', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index e2f436b6..267ca776 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -1295,6 +1295,47 @@ export const zhTranslation = { csvImportDownloadSuccess: '报告下载成功', csvImportDownloadError: '报告下载失败', }, + smartPlaylists: { + sectionBasic: '1. 基础', + sectionGenres: '2. 流派', + sectionYearsAndFilters: '3. 年份与筛选', + genreMode: '流派模式', + genreModeInclude: '包含流派', + genreModeExclude: '排除流派', + genreSearchPlaceholder: '筛选流派...', + availableGenres: '可用', + selectedGenres: '已选', + yearMode: '年份模式', + yearModeInclude: '包含范围', + yearModeExclude: '排除范围', + name: '名称(不含前缀)', + limit: '数量上限', + limitHint: '要包含在播放列表中的曲目数量(1-{{max}},通常为 50)。', + artistContains: '艺术家包含…', + albumContains: '专辑包含…', + titleContains: '标题包含…', + minRating: '最低评分', + minRatingAria: '智能播放列表的最低评分', + minRatingHint: '0 表示关闭最低阈值;1-5 仅保留评分高于所选值的曲目。', + fromYear: '起始年份', + toYear: '结束年份', + excludeUnrated: '排除未评分曲目', + compilationOnly: '仅合集', + create: '新建智能播放列表', + save: '保存智能播放列表', + created: '已创建 {{name}}', + updated: '已更新 {{name}}', + createFailed: '无法创建智能播放列表。', + updateFailed: '无法更新智能播放列表。', + navidromeOnly: '仅 Navidrome 服务器支持创建智能播放列表。', + loadFailed: '无法从 Navidrome 加载智能播放列表。', + sortRandom: '排序:随机', + sortTitleAsc: '排序:标题 A-Z', + sortTitleDesc: '排序:标题 Z-A', + sortYearDesc: '排序:年份降序', + sortYearAsc: '排序:年份升序', + sortPlayCountDesc: '排序:播放次数降序', + }, mostPlayed: { title: '最常播放', topArtists: '热门艺术家', diff --git a/src/pages/Playlists.tsx b/src/pages/Playlists.tsx index cc3970fe..65ad6ceb 100644 --- a/src/pages/Playlists.tsx +++ b/src/pages/Playlists.tsx @@ -10,7 +10,7 @@ import StarRating from '../components/StarRating'; import { useTranslation } from 'react-i18next'; import { formatHumanHoursMinutes } from '../utils/formatHumanDuration'; import { showToast } from '../utils/toast'; -import { ndCreateSmartPlaylist } from '../api/navidromeSmart'; +import { ndCreateSmartPlaylist, ndGetSmartPlaylist, ndListSmartPlaylists, ndUpdateSmartPlaylist } from '../api/navidromeSmart'; function formatDuration(seconds: number): string { return formatHumanHoursMinutes(seconds); @@ -48,6 +48,8 @@ type PendingSmartPlaylist = { attempts: number; }; +type NdSmartRuleNode = Record; + const defaultSmartFilters: SmartFilters = { name: '', limit: '50', @@ -79,6 +81,90 @@ function displayPlaylistName(name: string): string { return n; } +function asRecord(v: unknown): Record | null { + return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : null; +} + +function parseSmartRulesToFilters( + rules: Record | undefined, + playlistName: string, +): SmartFilters { + const next: SmartFilters = { + ...defaultSmartFilters, + name: displayPlaylistName(playlistName), + }; + if (!rules) return next; + + if (typeof rules.limit === 'number' && Number.isFinite(rules.limit)) { + next.limit = String(Math.max(1, Math.min(LIMIT_MAX, Number(rules.limit)))); + } + if (typeof rules.sort === 'string' && rules.sort.trim()) next.sort = rules.sort; + + const includeGenres: string[] = []; + const excludeGenres: string[] = []; + const all = Array.isArray(rules.all) ? rules.all : []; + for (const node of all) { + const obj = asRecord(node); + if (!obj) continue; + + const contains = asRecord(obj.contains); + if (contains) { + if (typeof contains.artist === 'string') next.artistContains = contains.artist; + if (typeof contains.album === 'string') next.albumContains = contains.album; + if (typeof contains.title === 'string') next.titleContains = contains.title; + } + + const gt = asRecord(obj.gt); + if (gt && typeof gt.rating === 'number') { + if (gt.rating > 0) next.minRating = Math.max(0, Math.min(5, Math.floor(gt.rating))); + else if (gt.rating === 0) next.excludeUnrated = true; + } + + const is = asRecord(obj.is); + if (is?.compilation === true) next.compilationOnly = true; + + const notContains = asRecord(obj.notContains); + if (notContains && typeof notContains.genre === 'string') excludeGenres.push(notContains.genre); + + const inTheRange = asRecord(obj.inTheRange); + if (inTheRange && Array.isArray(inTheRange.year) && inTheRange.year.length === 2) { + const from = Number(inTheRange.year[0]); + const to = Number(inTheRange.year[1]); + if (Number.isFinite(from) && Number.isFinite(to)) { + next.yearMode = 'include'; + next.yearFrom = clampYear(Math.min(from, to)); + next.yearTo = clampYear(Math.max(from, to)); + } + } + + const any = Array.isArray(obj.any) ? (obj.any as NdSmartRuleNode[]) : []; + if (any.length > 0) { + const parsedGenreIncludes = any + .map((item) => asRecord(asRecord(item)?.contains)?.genre) + .filter((v): v is string => typeof v === 'string'); + if (parsedGenreIncludes.length > 0) includeGenres.push(...parsedGenreIncludes); + + const ltYear = any.map((item) => asRecord(asRecord(item)?.lt)?.year).find((v) => typeof v === 'number'); + const gtYear = any.map((item) => asRecord(asRecord(item)?.gt)?.year).find((v) => typeof v === 'number'); + if (typeof ltYear === 'number' && typeof gtYear === 'number') { + next.yearMode = 'exclude'; + next.yearFrom = clampYear(Math.min(ltYear, gtYear)); + next.yearTo = clampYear(Math.max(ltYear, gtYear)); + } + } + } + + if (includeGenres.length > 0) { + next.genreMode = 'include'; + next.selectedGenres = [...new Set(includeGenres)]; + } else if (excludeGenres.length > 0) { + next.genreMode = 'exclude'; + next.selectedGenres = [...new Set(excludeGenres)]; + } + + return next; +} + export default function Playlists() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -102,6 +188,7 @@ export default function Playlists() { const [genres, setGenres] = useState([]); const [genreQuery, setGenreQuery] = useState(''); const [creatingSmartBusy, setCreatingSmartBusy] = useState(false); + const [editingSmartId, setEditingSmartId] = useState(null); const [pendingSmart, setPendingSmart] = useState([]); const [smartCoverIdsByPlaylist, setSmartCoverIdsByPlaylist] = useState>({}); const [playingId, setPlayingId] = useState(null); @@ -236,6 +323,57 @@ export default function Playlists() { return rules; }; + const handleOpenSmartEditor = async (pl: SubsonicPlaylist) => { + if (!isNavidromeServer || !isSmartPlaylistName(pl.name)) return; + setCreatingSmartBusy(true); + try { + let target: { id: string; name: string; rules?: Record } | null = null; + try { + // Prefer direct endpoint for this playlist: returns freshest rules. + const direct = await ndGetSmartPlaylist(pl.id); + if (direct.id && (direct.rules || isSmartPlaylistName(direct.name))) target = direct; + } catch { + // Fallback to list endpoint below. + } + if (!target) { + const smart = await ndListSmartPlaylists(); + target = smart.find((v) => + v.id === pl.id || + v.name === pl.name || + displayPlaylistName(v.name) === displayPlaylistName(pl.name), + ) ?? null; + } + if (target) { + setSmartFilters(parseSmartRulesToFilters(target.rules, target.name)); + setEditingSmartId(target.id); + } else { + // Fallback: allow editing even if Navidrome smart list endpoint + // doesn't return this playlist (shared/migrated/legacy edge cases). + setSmartFilters({ + ...defaultSmartFilters, + name: displayPlaylistName(pl.name), + }); + setEditingSmartId(pl.id); + } + setGenreQuery(''); + setCreating(false); + setCreatingSmart(true); + } catch { + // Degrade gracefully instead of blocking the editor on transient/API errors. + setSmartFilters({ + ...defaultSmartFilters, + name: displayPlaylistName(pl.name), + }); + setGenreQuery(''); + setEditingSmartId(pl.id); + setCreating(false); + setCreatingSmart(true); + showToast(t('smartPlaylists.loadFailed'), 3500, 'warning'); + } finally { + setCreatingSmartBusy(false); + } + }; + const handleCreateSmart = async () => { if (!isNavidromeServer) { showToast(t('smartPlaylists.navidromeOnly'), 3500, 'error'); @@ -243,31 +381,48 @@ export default function Playlists() { } setCreatingSmartBusy(true); try { - const baseName = smartFilters.name.trim() || `mix-${new Date().toISOString().slice(0, 10)}`; + 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(); - await ndCreateSmartPlaylist(`${SMART_PREFIX}${baseName}`, rules, true); + const fullName = `${SMART_PREFIX}${baseName}`; + if (editingSmartId) { + await ndUpdateSmartPlaylist(editingSmartId, fullName, rules, true); + } else { + await ndCreateSmartPlaylist(fullName, rules, true); + } await fetchPlaylists(); - const createdName = `${SMART_PREFIX}${baseName}`; + const createdName = fullName; + const updatedId = editingSmartId; setPendingSmart(prev => { - const existing = prev.find(p => p.name === createdName); + const existing = prev.find(p => p.id === updatedId || p.name === createdName); if (existing) return prev; - const created = usePlaylistStore.getState().playlists.find(p => p.name === createdName); + const created = usePlaylistStore.getState().playlists.find((p) => p.id === updatedId || p.name === createdName); return [ ...prev, { name: createdName, - id: created?.id, + id: updatedId ?? created?.id, firstSeenCoverArt: created?.coverArt, attempts: 0, }, ]; }); setCreatingSmart(false); + setEditingSmartId(null); setSmartFilters(defaultSmartFilters); setGenreQuery(''); - showToast(t('smartPlaylists.created', { name: createdName }), 3500, 'success'); + if (updatedId) showToast(t('smartPlaylists.updated', { name: createdName }), 3500, 'success'); + else showToast(t('smartPlaylists.created', { name: createdName }), 3500, 'success'); } catch { - showToast(t('smartPlaylists.createFailed'), 3500, 'error'); + showToast(editingSmartId ? t('smartPlaylists.updateFailed') : t('smartPlaylists.createFailed'), 3500, 'error'); } finally { setCreatingSmartBusy(false); } @@ -522,7 +677,13 @@ export default function Playlists() { )} {!creating && isNavidromeServer && ( - )} @@ -625,9 +786,19 @@ export default function Playlists() {
- +
@@ -668,16 +839,22 @@ export default function Playlists() { > {!selectionMode && (
- + {isPlaylistDeletable(pl) && ( + + )} {isPlaylistDeletable(pl) && (