diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index baec6304..e4a39196 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -565,6 +565,121 @@ 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)) +} + +/// 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( + 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 +3839,11 @@ pub fn run() { nd_delete_user, nd_list_libraries, nd_set_user_libraries, + nd_list_playlists, + nd_create_playlist, + nd_update_playlist, + nd_get_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..23ecc6c0 --- /dev/null +++ b/src/api/navidromeSmart.ts @@ -0,0 +1,129 @@ +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; +} + +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; + 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) => parseNdSmartPlaylist(v)); +} + +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 }, + }); + 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 { + 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 ac9baec4..f29f33ff 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(includeOrbit = false): Promise { - const data = await api<{ playlists: { playlist: SubsonicPlaylist[] } }>('getPlaylists.view'); + const data = await api<{ playlists: { playlist: SubsonicPlaylist[] } }>('getPlaylists.view', { _t: Date.now() }); const all = data.playlists?.playlist ?? []; // Orbit session + outbox playlists are technical internals. They're `public` // so guests can reach them, which means they leak into every UI picker and 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/de.ts b/src/locales/de.ts index 984a54b3..7de4bd56 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -1318,6 +1318,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 54704a93..4336a810 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', @@ -1320,6 +1321,47 @@ export const enTranslation = { csvImportDownloadSuccess: 'Report downloaded successfully', csvImportDownloadError: 'Failed to download report', }, + smartPlaylists: { + 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)', + 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', + save: 'Save Smart Playlist', + created: 'Created {{name}}', + updated: 'Updated {{name}}', + createFailed: 'Could not create 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.', + 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/es.ts b/src/locales/es.ts index e6993bae..2a525823 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -1311,6 +1311,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 f7478fab..0e651e88 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -1306,6 +1306,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 df5200d3..863b6a88 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -1318,6 +1318,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 5d6dc516..a732adec 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -1305,6 +1305,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 1bb9644f..1ca30e29 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: 'Браузер папок', @@ -1384,6 +1385,47 @@ export const ruTranslation = { 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: 'Сортировка: название А-Я', + sortTitleDesc: 'Сортировка: название Я-А', + sortYearDesc: 'Сортировка: год по убыванию', + sortYearAsc: 'Сортировка: год по возрастанию', + sortPlayCountDesc: 'Сортировка: прослушивания по убыванию', + }, mostPlayed: { title: 'Популярное', topArtists: 'Топ исполнителей', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 1c14208b..c55f0360 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -1298,6 +1298,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/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 2b03d1f4..ec2bdca5 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -1,13 +1,13 @@ 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 { useParams, useNavigate, useLocation } 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, Sparkles } from 'lucide-react'; import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns'; import { AddToPlaylistSubmenu } from '../components/ContextMenu'; import { getPlaylist, updatePlaylist, updatePlaylistMeta, uploadPlaylistCoverArt, search, setRating, star, unstar, - getRandomSongs, buildDownloadUrl, SubsonicPlaylist, SubsonicSong, + getRandomSongs, buildDownloadUrl, filterSongsToActiveLibrary, SubsonicPlaylist, SubsonicSong, } from '../api/subsonic'; import { usePlayerStore, songToTrack } from '../store/playerStore'; import { useShallow } from 'zustand/react/shallow'; @@ -56,6 +56,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()); @@ -226,6 +238,7 @@ export default function PlaylistDetail() { const { id } = useParams<{ id: string }>(); const { t } = useTranslation(); const navigate = useNavigate(); + const location = useLocation(); const { playTrack, enqueue, openContextMenu, currentTrack, isPlaying, starredOverrides, setStarredOverride, userRatingOverrides } = usePlayerStore( useShallow(s => ({ playTrack: s.playTrack, @@ -403,6 +416,14 @@ export default function PlaylistDetail() { if (!contextMenuOpen) setContextMenuSongId(null); }, [contextMenuOpen]); + useEffect(() => { + const state = (location.state as { openEditMeta?: boolean } | null) ?? null; + if (state?.openEditMeta) { + setEditingMeta(true); + navigate(location.pathname, { replace: true, state: null }); + } + }, [location.state, location.pathname, navigate]); + // ── Load ───────────────────────────────────────────────────── const lastModified = usePlaylistStore(s => (id ? s.lastModified[id] : undefined)); @@ -410,13 +431,14 @@ export default function PlaylistDetail() { if (!id) return; setLoading(true); getPlaylist(id) - .then(({ playlist, songs }) => { + .then(async ({ playlist, songs }) => { + const filteredSongs = await filterSongsToActiveLibrary(songs); setPlaylist(playlist); - setSongs(songs); + setSongs(filteredSongs); if (playlist.coverArt) setCustomCoverId(playlist.coverArt); const init: Record = {}; const starred = new Set(); - songs.forEach(s => { + filteredSongs.forEach(s => { if (s.userRating) init[s.id] = s.userRating; if (s.starred) starred.add(s.id); }); @@ -1148,7 +1170,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 +876,41 @@ 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) && ( + + )} + {isPlaylistDeletable(pl) && ( + + )} +
+ )} {selectionMode && (
{selectedIds.has(pl.id) && } @@ -258,7 +918,24 @@ export default function Playlists() { )} {/* Cover area — server collage or fallback icon */}
- {pl.coverArt ? ( + {isSmartPlaylistName(pl.name) && (smartCoverIdsByPlaylist[pl.id]?.length ?? 0) > 0 ? ( +
+ {Array.from({ length: 4 }, (_, i) => { + const id = smartCoverIdsByPlaylist[pl.id][i % smartCoverIdsByPlaylist[pl.id].length]; + return id ? ( + + ) : ( +
+ ); + })} +
+ ) : pl.coverArt ? (
)} + {pendingSmart.some(p => p.id === pl.id || p.name === pl.name) && ( +
+ +
+ )} {/* Play overlay — same pattern as AlbumCard */}
@@ -288,16 +988,22 @@ export default function Playlists() {
-
{pl.name}
+
+ {isSmartPlaylistName(pl.name) && } + {displayPlaylistName(pl.name)} +
- {t('playlists.songs', { n: pl.songCount })} - {pl.duration > 0 && <> · {formatDuration(pl.duration)}} + {t('playlists.songs', { n: filteredSongCountByPlaylist[pl.id] ?? pl.songCount })} + {(filteredDurationByPlaylist[pl.id] ?? pl.duration) > 0 && ( + <> · {formatDuration(filteredDurationByPlaylist[pl.id] ?? pl.duration)} + )}
))}
)} + ); } diff --git a/src/styles/components.css b/src/styles/components.css index 8a823650..81e3cd37 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -1051,6 +1051,14 @@ box-shadow: var(--shadow-lg); } +/* In playlist cards, the 2x2 grid must fill the full cover area. */ +.album-card-cover > .playlist-cover-grid { + width: 100%; + height: 100%; + border-radius: 0; + box-shadow: none; +} + .playlist-cover-cell { width: 100%; height: 100%; @@ -7300,43 +7308,51 @@ html.no-compositing .fs-seekbar-played { opacity: 0.6; } -/* Delete button — top-right corner of card cover, hidden until hover */ -.playlist-card-delete { +/* Playlist card actions (edit/delete) — visible on hover */ +.playlist-card-actions { position: absolute; - top: 6px; - right: 6px; + top: 8px; + right: 8px; + display: flex; + gap: 6px; + opacity: 0; + transform: translateY(-2px); + transition: opacity var(--transition-fast), transform var(--transition-fast); + z-index: 6; +} + +.album-card:hover .playlist-card-actions { + opacity: 1; + transform: translateY(0); +} + +.playlist-card-action { width: 24px; height: 24px; border-radius: 50%; - border: none; + border: 1px solid rgba(255, 255, 255, 0.18); background: rgba(0, 0, 0, 0.55); color: rgba(255, 255, 255, 0.75); display: flex; align-items: center; justify-content: center; cursor: pointer; - opacity: 0; transition: opacity var(--transition-fast), background var(--transition-fast), color var(--transition-fast); - z-index: 2; } -.album-card:hover .playlist-card-delete { - opacity: 1; +.playlist-card-action--edit:hover { + background: rgba(90, 120, 255, 0.85); + color: #fff; } -.playlist-card-delete:hover { +.playlist-card-action--delete:hover { background: rgba(220, 60, 60, 0.85); color: #fff; } -.playlist-card-delete--confirm { +.playlist-card-action--delete-confirm { background: #dc3c3c !important; color: #fff !important; - opacity: 1 !important; - width: 30px !important; - height: 30px !important; - top: 3px !important; - right: 3px !important; box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.45); animation: delete-confirm-pulse 0.7s ease-in-out infinite alternate; }