diff --git a/core/automation/handlers/personalized_pipeline.py b/core/automation/handlers/personalized_pipeline.py index aa2f9190d..3fa1e8225 100644 --- a/core/automation/handlers/personalized_pipeline.py +++ b/core/automation/handlers/personalized_pipeline.py @@ -60,6 +60,9 @@ def auto_personalized_pipeline(config: Dict[str, Any], deps: AutomationDeps) -> try: kinds_config = config.get('kinds') or [] + + manager = deps.build_personalized_manager() + if not isinstance(kinds_config, list) or not kinds_config: deps.state.set_pipeline_running(False) return { @@ -70,13 +73,11 @@ def auto_personalized_pipeline(config: Dict[str, Any], deps: AutomationDeps) -> refresh_first = bool(config.get('refresh_first', False)) skip_wishlist = bool(config.get('skip_wishlist', False)) - manager = deps.build_personalized_manager() - deps.update_progress( automation_id, progress=2, - phase=f'Personalized pipeline: {len(kinds_config)} playlist(s)', - log_line=f'Starting pipeline for {len(kinds_config)} playlist(s)', + phase=f'Auto-playlist pipeline: {len(kinds_config)} playlist(s)', + log_line=f'Starting auto-playlist pipeline for {len(kinds_config)} playlist(s)', log_type='info', ) @@ -91,19 +92,19 @@ def auto_personalized_pipeline(config: Dict[str, Any], deps: AutomationDeps) -> ) profile_id = deps.get_current_profile_id() - playload_payloads = _build_payloads_for_kinds( + payloads = _build_payloads_for_kinds( deps, manager, kinds_config, profile_id, automation_id=automation_id, refresh_first=refresh_first, ) - if not playload_payloads: + if not payloads: deps.state.set_pipeline_running(False) deps.update_progress( automation_id, status='finished', progress=100, phase='No playlists to sync', - log_line='No personalized playlists had tracks to sync', + log_line='No auto-playlists had tracks to sync', log_type='warning', ) return { @@ -118,7 +119,7 @@ def auto_personalized_pipeline(config: Dict[str, Any], deps: AutomationDeps) -> automation_id, progress=50, phase='Phase 1/2: Snapshot complete', - log_line=f'Phase 1 done: {len(playload_payloads)} playlist(s) ready to sync', + log_line=f'Phase 1 done: {len(payloads)} playlist(s) ready to sync', log_type='success', ) @@ -126,7 +127,7 @@ def auto_personalized_pipeline(config: Dict[str, Any], deps: AutomationDeps) -> sync_summary = run_sync_and_wishlist( deps, automation_id, - playload_payloads, + payloads, sync_one_fn=lambda pl: _sync_personalized_playlist(deps, pl), sync_id_for_fn=lambda pl: pl['sync_id'], skip_wishlist=skip_wishlist, @@ -144,7 +145,7 @@ def auto_personalized_pipeline(config: Dict[str, Any], deps: AutomationDeps) -> automation_id, status='finished', progress=100, phase='Pipeline complete', - log_line=f'Personalized pipeline finished in {duration // 60}m {duration % 60}s', + log_line=f'Auto-playlist pipeline finished in {duration // 60}m {duration % 60}s', log_type='success', ) @@ -152,7 +153,7 @@ def auto_personalized_pipeline(config: Dict[str, Any], deps: AutomationDeps) -> return { 'status': 'completed', '_manages_own_progress': True, - 'playlists_synced': str(len(playload_payloads)), + 'playlists_synced': str(len(payloads)), 'tracks_synced': str(sync_summary['synced']), 'sync_skipped': str(sync_summary['skipped']), 'wishlist_queued': str(sync_summary['wishlist_queued']), @@ -165,7 +166,7 @@ def auto_personalized_pipeline(config: Dict[str, Any], deps: AutomationDeps) -> automation_id, status='error', progress=100, phase='Pipeline error', - log_line=f'Personalized pipeline failed: {e}', + log_line=f'Auto-playlist pipeline failed: {e}', log_type='error', ) return {'status': 'error', 'error': str(e), '_manages_own_progress': True} diff --git a/core/automation_engine.py b/core/automation_engine.py index 5f2df5374..c5e98908d 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -615,6 +615,7 @@ def ensure_system_automations(self): self._fix_airing_automation_schedule() self._fix_deep_scan_schedules() self._fix_wishlist_processor_rename() + self._migrate_auto_refresh_to_automations() def _fix_video_scan_default(self): """Remove the obsolete standalone 'Scan Video Library' SYSTEM automation — it's @@ -710,6 +711,103 @@ def _fix_deep_scan_schedules(self): except Exception: logger.exception("deep-scan schedule migration failed for %s", action_type) + def _migrate_auto_refresh_to_automations(self): + """One-time migration: convert legacy ``auto_refresh``/``refresh_interval_hours`` + stored in ``config_json.extra`` into proper per-playlist automation rows. + + Each playlist with auto_refresh=True becomes its own automation row with + action_type='personalized_pipeline', owned_by='auto_playlist', and the + correct profile_id. Removes the old system 'Auto-Playlist Refresh' row.""" + try: + # Remove the old system automation if it still exists + old_system = self.db.get_system_automation_by_action('personalized_pipeline') + if old_system: + self.db.update_automation(old_system['id'], is_system=0) + self.db.delete_automation(old_system['id']) + logger.info("Removed legacy system 'Auto-Playlist Refresh' automation (id=%s)", + old_system.get('id')) + + # Find all personalized playlists with auto_refresh in their extra config + with self.db._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT id, profile_id, kind, variant, name, config_json " + "FROM personalized_playlists" + ) + rows = [dict(r) for r in cursor.fetchall()] + + migrated = 0 + existing_pipeline = self.db.get_automations_by_action('personalized_pipeline') or [] + for row in rows: + try: + cfg = json.loads(row.get('config_json') or '{}') + extra = cfg.get('extra') or {} + if not extra.get('auto_refresh'): + continue + + # Skip if automation already exists for this playlist + already_exists = False + for auto in existing_pipeline: + if auto.get('owned_by') != 'auto_playlist': + continue + if auto.get('profile_id') != row['profile_id']: + continue + ac = json.loads(auto.get('action_config') or '{}') + kinds = ac.get('kinds') or [] + if any(k.get('kind') == row['kind'] + and k.get('variant', '') == (row.get('variant') or '') + for k in kinds): + already_exists = True + break + if already_exists: + continue + + interval_hours = max(1, int(extra.get('refresh_interval_hours', 24))) + kind_display = row['kind'].replace('_', ' ').title() + variant_display = f" ({row['variant']})" if row.get('variant') else '' + + from core.personalized.api import _interval_to_trigger + trigger_type, trigger_config = _interval_to_trigger(interval_hours) + + action_config = { + 'kinds': [{'kind': row['kind'], 'variant': row.get('variant') or ''}] + } + + aid = self.db.create_automation( + name=f"Auto-Refresh: {kind_display}{variant_display}", + trigger_type=trigger_type, + trigger_config=json.dumps(trigger_config), + action_type='personalized_pipeline', + action_config=json.dumps(action_config), + profile_id=row['profile_id'], + owned_by='auto_playlist', + ) + if aid: + migrated += 1 + logger.info("Migrated auto_refresh playlist '%s' → automation id=%s", + row['name'], aid) + + # Clean up legacy auto_refresh from config_json.extra + del extra['auto_refresh'] + if 'refresh_interval_hours' in extra: + del extra['refresh_interval_hours'] + cfg['extra'] = extra + with self.db._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "UPDATE personalized_playlists SET config_json = ? WHERE id = ?", + (json.dumps(cfg), row['id']), + ) + conn.commit() + + except Exception: + logger.exception("Failed to migrate playlist id=%s", row.get('id')) + + if migrated: + logger.info("Auto-refresh migration: converted %d playlists to automation rows", migrated) + except Exception: + logger.exception("auto_refresh → automation migration failed") + def get_system_automation_next_run_seconds(self, action_type): """Get seconds until next run for a system automation. Returns 0 if not found or disabled.""" auto = self.db.get_system_automation_by_action(action_type) diff --git a/core/personalized/api.py b/core/personalized/api.py index bfa9de606..ab5b75f8c 100644 --- a/core/personalized/api.py +++ b/core/personalized/api.py @@ -15,23 +15,98 @@ - POST /api/personalized/playlist///refresh — variant - PUT /api/personalized/playlist//config — singleton - PUT /api/personalized/playlist///config — variant +- POST /api/personalized/playlist//activate — activate + create automation +- PUT /api/personalized/playlist//auto-refresh — toggle automation enabled +- PUT /api/personalized/playlist//refresh-interval — change automation schedule +- DELETE /api/personalized/playlist/ — deactivate (delete playlist + automation) + +Auto-refresh is backed by per-playlist rows in the ``automations`` table +(owned_by='auto_playlist'). The handler creates / toggles / deletes those +rows rather than storing scheduling state in config_json.extra. The handlers themselves are pure functions returning Python dicts so they're testable without spinning up Flask. The wiring step in -web_server.py wraps them in `jsonify` + URL routing. +web_server.py wraps them in ``jsonify`` + URL routing. """ from __future__ import annotations +import json +import logging from typing import Any, Dict, List, Optional +logger = logging.getLogger(__name__) + from core.personalized.manager import PersonalizedPlaylistManager from core.personalized.specs import PlaylistKindRegistry, get_registry from core.personalized.types import PlaylistRecord, Track -def _record_to_dict(record: PlaylistRecord) -> Dict[str, Any]: - return { +# ── Automation helpers ──────────────────────────────────────────────── + +def _find_playlist_automation(engine, kind: str, variant: str, profile_id: int): + """Find the automation row for a specific playlist, or None.""" + if engine is None: + return None + all_auto = engine.db.get_automations_by_action('personalized_pipeline') + for auto in (all_auto or []): + if auto.get('owned_by') != 'auto_playlist': + continue + if auto.get('profile_id') != profile_id: + continue + if auto.get('is_system'): + continue + ac = json.loads(auto.get('action_config') or '{}') + kinds = ac.get('kinds') or [] + if any(k.get('kind') == kind and k.get('variant', '') == (variant or '') + for k in kinds): + return auto + return None + + +def _match_automation(automations: List[Dict[str, Any]], kind: str, variant: str): + """Match a kind/variant against a pre-filtered list of automations.""" + for auto in automations: + ac = json.loads(auto.get('action_config') or '{}') + kinds = ac.get('kinds') or [] + if any(k.get('kind') == kind and k.get('variant', '') == (variant or '') + for k in kinds): + return auto + return None + + +def _interval_to_trigger(interval_hours: int): + """Map refresh_interval_hours to (trigger_type, trigger_config).""" + interval_hours = max(1, interval_hours) + if interval_hours <= 12: + return 'schedule', {'interval': interval_hours, 'unit': 'hours'} + if interval_hours <= 48: + return 'daily_time', {'time': '01:00'} + return 'weekly_time', {'time': '01:00', 'days': ['mon']} + + +def _trigger_to_interval(trigger_type: str, trigger_config: dict) -> int: + """Reverse-map trigger config to approximate refresh_interval_hours.""" + if trigger_type == 'schedule': + interval = trigger_config.get('interval', 24) + unit = trigger_config.get('unit', 'hours') + if unit == 'minutes': + return max(1, interval // 60) + if unit == 'days': + return interval * 24 + return interval + if trigger_type == 'daily_time': + return 24 + if trigger_type == 'weekly_time': + return 168 + return 24 + + +# ── Serialisation ──────────────────────────────────────────────────── + +def _record_to_dict(record: PlaylistRecord, automation=None) -> Dict[str, Any]: + extra = record.config.extra or {} + result = { 'id': record.id, 'profile_id': record.profile_id, 'kind': record.kind, @@ -43,7 +118,20 @@ def _record_to_dict(record: PlaylistRecord) -> Dict[str, Any]: 'last_synced_at': record.last_synced_at, 'last_generation_source': record.last_generation_source, 'last_generation_error': record.last_generation_error, + 'is_stale': record.is_stale, } + if automation is not None: + result['automation_id'] = automation['id'] + result['auto_refresh'] = bool(automation.get('enabled', 0)) + result['refresh_interval_hours'] = _trigger_to_interval( + automation.get('trigger_type', 'schedule'), + json.loads(automation.get('trigger_config') or '{}'), + ) + else: + result['automation_id'] = None + result['auto_refresh'] = False + result['refresh_interval_hours'] = 24 + return result def _track_to_dict(track: Track) -> Dict[str, Any]: @@ -62,6 +150,8 @@ def _track_to_dict(track: Track) -> Dict[str, Any]: } +# ── Read handlers ──────────────────────────────────────────────────── + def list_kinds( registry: Optional[PlaylistKindRegistry] = None, manager: Optional[PersonalizedPlaylistManager] = None, @@ -94,12 +184,32 @@ def list_kinds( return {'success': True, 'kinds': out} -def list_playlists(manager: PersonalizedPlaylistManager, profile_id: int) -> Dict[str, Any]: - """List every persisted playlist for a profile.""" +def list_playlists( + manager: PersonalizedPlaylistManager, + profile_id: int, + engine=None, +) -> Dict[str, Any]: + """List every persisted playlist for a profile, enriched with + automation data (auto_refresh, interval) from the engine.""" records = manager.list_playlists(profile_id) + + # Fetch automations once, then match in Python (avoids N+1 queries) + relevant_auto: List[Dict[str, Any]] = [] + if engine is not None: + all_auto = engine.db.get_automations_by_action('personalized_pipeline') + relevant_auto = [ + a for a in (all_auto or []) + if a.get('owned_by') == 'auto_playlist' + and a.get('profile_id') == profile_id + and not a.get('is_system') + ] + return { 'success': True, - 'playlists': [_record_to_dict(r) for r in records], + 'playlists': [ + _record_to_dict(r, _match_automation(relevant_auto, r.kind, r.variant)) + for r in records + ], } @@ -108,33 +218,39 @@ def get_playlist_with_tracks( kind: str, variant: str, profile_id: int, + engine=None, ) -> Dict[str, Any]: """Get the playlist row + its current track snapshot. Auto-creates the row from default config if it doesn't exist (so the UI's first- paint of an unseen kind works without a separate ensure call).""" record = manager.ensure_playlist(kind, variant, profile_id) tracks = manager.get_playlist_tracks(record.id) + automation = _find_playlist_automation(engine, kind, variant, profile_id) return { 'success': True, - 'playlist': _record_to_dict(record), + 'playlist': _record_to_dict(record, automation), 'tracks': [_track_to_dict(t) for t in tracks], } +# ── Write handlers ─────────────────────────────────────────────────── + def refresh_playlist( manager: PersonalizedPlaylistManager, kind: str, variant: str, profile_id: int, config_overrides: Optional[Dict[str, Any]] = None, + engine=None, ) -> Dict[str, Any]: """Run the kind's generator and persist the snapshot. Returns the fresh row + tracks.""" record = manager.refresh_playlist(kind, variant, profile_id, config_overrides=config_overrides) tracks = manager.get_playlist_tracks(record.id) + automation = _find_playlist_automation(engine, kind, variant, profile_id) return { 'success': True, - 'playlist': _record_to_dict(record), + 'playlist': _record_to_dict(record, automation), 'tracks': [_track_to_dict(t) for t in tracks], } @@ -145,19 +261,151 @@ def update_config( variant: str, profile_id: int, overrides: Dict[str, Any], + engine=None, ) -> Dict[str, Any]: """Patch the playlist's config with the provided fields.""" record = manager.update_config(kind, variant, profile_id, overrides) + automation = _find_playlist_automation(engine, kind, variant, profile_id) + return { + 'success': True, + 'playlist': _record_to_dict(record, automation), + } + + +def activate_playlist( + manager: PersonalizedPlaylistManager, + kind: str, + variant: str, + profile_id: int, + engine, + refresh_interval_hours: int = 24, +) -> Dict[str, Any]: + """Activate a playlist: ensure it exists, create an automation row + for scheduling, and do an initial refresh.""" + record = manager.ensure_playlist(kind, variant, profile_id) + + existing = _find_playlist_automation(engine, kind, variant, profile_id) + if existing is None: + trigger_type, trigger_config = _interval_to_trigger(refresh_interval_hours) + kind_display = kind.replace('_', ' ').title() + variant_display = f" ({variant})" if variant else '' + aid = engine.db.create_automation( + name=f"Auto-Refresh: {kind_display}{variant_display}", + trigger_type=trigger_type, + trigger_config=json.dumps(trigger_config), + action_type='personalized_pipeline', + action_config=json.dumps({'kinds': [{'kind': kind, 'variant': variant or ''}]}), + profile_id=profile_id, + owned_by='auto_playlist', + ) + if aid: + engine.schedule_automation(aid) + + try: + record = manager.refresh_playlist(kind, variant, profile_id) + except Exception: # noqa: BLE001 + logger.debug("Initial refresh after activate failed (will retry on schedule)", exc_info=True) + + tracks = manager.get_playlist_tracks(record.id) + automation = _find_playlist_automation(engine, kind, variant, profile_id) return { 'success': True, - 'playlist': _record_to_dict(record), + 'playlist': _record_to_dict(record, automation), + 'tracks': [_track_to_dict(t) for t in tracks], + } + + +def toggle_auto_refresh( + manager: PersonalizedPlaylistManager, + kind: str, + variant: str, + profile_id: int, + engine, + enabled: Optional[bool] = None, +) -> Dict[str, Any]: + """Toggle the automation row's enabled status.""" + automation = _find_playlist_automation(engine, kind, variant, profile_id) + if automation is None: + return {'success': False, 'error': 'Playlist not activated'} + + if automation is not None and enabled is not None: + current = bool(automation.get('enabled', 0)) + if current != bool(enabled): + engine.db.toggle_automation(automation['id']) + if enabled: + engine.schedule_automation(automation['id']) + else: + engine.cancel_automation(automation['id']) + automation = _find_playlist_automation(engine, kind, variant, profile_id) + + record = manager.get_playlist(kind, variant, profile_id) or manager.ensure_playlist(kind, variant, profile_id) + return { + 'success': True, + 'playlist': _record_to_dict(record, automation), } +def update_refresh_interval( + manager: PersonalizedPlaylistManager, + kind: str, + variant: str, + profile_id: int, + engine, + refresh_interval_hours: int, +) -> Dict[str, Any]: + """Update the automation's trigger schedule for a playlist.""" + automation = _find_playlist_automation(engine, kind, variant, profile_id) + if automation is None: + return {'success': False, 'error': 'Playlist not activated'} + + if automation is not None: + trigger_type, trigger_config = _interval_to_trigger(refresh_interval_hours) + engine.db.update_automation( + automation['id'], + trigger_type=trigger_type, + trigger_config=json.dumps(trigger_config), + ) + engine.schedule_automation(automation['id']) + automation = _find_playlist_automation(engine, kind, variant, profile_id) + + record = manager.get_playlist(kind, variant, profile_id) or manager.ensure_playlist(kind, variant, profile_id) + return { + 'success': True, + 'playlist': _record_to_dict(record, automation), + } + + +def delete_playlist( + manager: PersonalizedPlaylistManager, + kind: str, + variant: str, + profile_id: int, + engine=None, +) -> Dict[str, Any]: + """Delete a playlist and its associated automation row. + + Deletes the playlist first so we don't orphan the automation if + the playlist deletion fails.""" + deleted = manager.delete_playlist(kind, variant, profile_id) + if not deleted: + return {'success': False, 'error': 'Playlist not found'} + + automation = _find_playlist_automation(engine, kind, variant, profile_id) + if automation is not None and engine is not None: + engine.cancel_automation(automation['id']) + engine.db.delete_automation(automation['id']) + + return {'success': True} + + __all__ = [ 'list_kinds', 'list_playlists', 'get_playlist_with_tracks', 'refresh_playlist', 'update_config', + 'activate_playlist', + 'toggle_auto_refresh', + 'update_refresh_interval', + 'delete_playlist', ] diff --git a/core/personalized/generators/__init__.py b/core/personalized/generators/__init__.py index 2e3c1aca0..80f1fdc7f 100644 --- a/core/personalized/generators/__init__.py +++ b/core/personalized/generators/__init__.py @@ -26,3 +26,5 @@ from core.personalized.generators import archives # noqa: F401 from core.personalized.generators import seasonal_mix # noqa: F401 from core.personalized.generators import listening_mix # noqa: F401 +from core.personalized.generators import recent_unheard # noqa: F401 +from core.personalized.generators import unplayed_tracks # noqa: F401 # deprecated, kept for DB compat diff --git a/core/personalized/generators/recent_unheard.py b/core/personalized/generators/recent_unheard.py new file mode 100644 index 000000000..027804049 --- /dev/null +++ b/core/personalized/generators/recent_unheard.py @@ -0,0 +1,118 @@ +"""Recent Unheard generator — recently added songs you haven't heard yet. + +Queries the local ``tracks`` table for tracks where ``play_count`` is +zero (or NULL), sorted newest-first, and returns the top N as a +personalized playlist. Supports filtering by age +(``max_days_since_added`` in ``config.extra``). + +Custom naming: set ``config.extra['name']`` to override the default +playlist name on first creation (e.g. ``{'name': 'Fresh & Silent'}``). +After creation the name can also be edited via the UI.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any, List, Optional + +from core.personalized.specs import PlaylistKindSpec, get_registry +from core.personalized.types import PlaylistConfig, Track + + +KIND = 'recent_unheard' + + +def _get_db(deps: Any): + """Extract the database handle from the deps object.""" + db = getattr(deps, 'database', None) + if db is not None: + return db + if isinstance(deps, dict): + return deps.get('database') + return None + + +def generate(deps: Any, variant: str, config: PlaylistConfig) -> List[Track]: + """Return recently added library tracks never played, newest-first, + trimmed to ``config.limit``. + + Config extras: + - ``max_days_since_added`` (int | None): only include tracks added + within this many days. ``None`` or ``0`` means no age filter. + """ + db = _get_db(deps) + if db is None: + raise RuntimeError('Recent Unheard generator deps missing database') + + max_days: Optional[int] = config.extra.get('max_days_since_added') + cutoff: Optional[str] = None + if max_days and max_days > 0: + cutoff = ( + datetime.now(timezone.utc) - timedelta(days=max_days) + ).strftime('%Y-%m-%d %H:%M:%S') + + where = ['(t.play_count IS NULL OR t.play_count = 0)'] + params: list = [] + if cutoff: + where.append('t.created_at >= ?') + params.append(cutoff) + + query = f""" + SELECT t.id, t.title, t.duration, + t.play_count, t.created_at, t.spotify_track_id, + t.deezer_id, t.itunes_track_id, + COALESCE(t.track_artist, ar.name, 'Unknown') AS artist_name, + a.title AS album_name + FROM tracks t + LEFT JOIN artists ar ON t.artist_id = ar.id + LEFT JOIN albums a ON t.album_id = a.id + WHERE {' AND '.join(where)} + ORDER BY t.created_at DESC + LIMIT ? + """ + + with db._get_connection() as conn: + cursor = conn.execute(query, params + [config.limit]) + rows = cursor.fetchall() + + tracks: List[Track] = [] + for row in rows: + tracks.append(Track( + track_name=row['title'] or 'Unknown', + artist_name=(row['artist_name'] or '').strip() or 'Unknown', + album_name=(row['album_name'] or '').strip() or '', + duration_ms=int(row['duration'] or 0), + spotify_track_id=row['spotify_track_id'], + deezer_track_id=row['deezer_id'], + itunes_track_id=row['itunes_track_id'], + source='library', + )) + + return tracks + + +def display_name_with_config(variant: str, config: PlaylistConfig) -> str: + """Resolve the playlist name, honouring ``config.extra['name']``.""" + custom = (config.extra or {}).get('name') + if custom: + return str(custom).strip() + return 'Recent Unheard' + + +SPEC = PlaylistKindSpec( + kind=KIND, + name_template='Recent Unheard', + description="Recently added songs you haven't played yet.", + default_config=PlaylistConfig( + limit=500, + max_per_album=5, + max_per_artist=3, + extra={'max_days_since_added': None, 'name': 'Recent Unheard'}, + ), + generator=generate, + requires_variant=False, + tags=['library'], +) + + +if get_registry().get(KIND) is None: + get_registry().register(SPEC) diff --git a/core/personalized/generators/unplayed_tracks.py b/core/personalized/generators/unplayed_tracks.py new file mode 100644 index 000000000..183d00941 --- /dev/null +++ b/core/personalized/generators/unplayed_tracks.py @@ -0,0 +1,174 @@ +"""Unplayed Tracks generator — library songs you haven't heard yet. + +Queries the local ``tracks`` table for tracks where ``play_count`` is +zero (or NULL) and returns them as a personalized playlist. Supports +filtering by age (``max_days_since_added`` in ``config.extra``) and +diversity caps (``max_per_album`` / ``max_per_artist``). + +Tracks are sorted newest-first so the freshest additions surface +first. Since these live in the user's library, the sync pipeline +matches them against the media-server library and creates a server- +side playlist — no downloads required. + +Custom naming: set ``config.extra['name']`` to override the default +playlist name on first creation (e.g. ``{'name': 'New & Unheard'}``). +After creation the name can also be edited via the UI.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone +from typing import Any, List, Optional + +from core.personalized.specs import PlaylistKindSpec, get_registry +from core.personalized.types import PlaylistConfig, Track + + +KIND = 'unplayed_tracks' + + +def _diversity_limit( + tracks: List[Track], + limit: int, + max_per_artist: int, + max_per_album: int, +) -> List[Track]: + """Enforce per-artist and per-album diversity caps. + + Greedy round-robin: picks one track per artist (up to + ``max_per_artist``) and one per album (up to ``max_per_album``), + then fills remaining slots from what's left, until ``limit`` + tracks are selected or the pool is exhausted.""" + + def _key(t: Track, field: str) -> str: + val = getattr(t, field, None) or '' + return val.strip().lower() or '_unknown' + + by_artist: dict[str, List[Track]] = {} + by_album: dict[str, List[Track]] = {} + for t in tracks: + by_artist.setdefault(_key(t, 'artist_name'), []).append(t) + by_album.setdefault(_key(t, 'album_name'), []).append(t) + + picked: List[Track] = [] + seen_ids: set = set() + + artist_budget: dict[str, int] = {k: max_per_artist for k in by_artist} + album_budget: dict[str, int] = {k: max_per_album for k in by_album} + + for t in tracks: + if len(picked) >= limit: + break + aid = id(t) + if aid in seen_ids: + continue + ak = _key(t, 'artist_name') + bk = _key(t, 'album_name') + if artist_budget.get(ak, 0) <= 0 and album_budget.get(bk, 0) <= 0: + continue + picked.append(t) + seen_ids.add(aid) + artist_budget[ak] = artist_budget.get(ak, 1) - 1 + album_budget[bk] = album_budget.get(bk, 1) - 1 + + return picked + + +def _get_db(deps: Any): + """Extract the database handle from the deps object.""" + db = getattr(deps, 'database', None) + if db is not None: + return db + if isinstance(deps, dict): + return deps.get('database') + return None + + +def generate(deps: Any, variant: str, config: PlaylistConfig) -> List[Track]: + """Return unplayed library tracks, newest-first, trimmed to ``config.limit``. + + Config extras: + - ``max_days_since_added`` (int | None): only include tracks added + within this many days. ``None`` or ``0`` means no age filter. + """ + db = _get_db(deps) + if db is None: + raise RuntimeError('Unplayed Tracks generator deps missing database') + + max_days: Optional[int] = config.extra.get('max_days_since_added') + cutoff: Optional[str] = None + if max_days and max_days > 0: + cutoff = ( + datetime.now(timezone.utc) - timedelta(days=max_days) + ).strftime('%Y-%m-%d %H:%M:%S') + + where = ['(t.play_count IS NULL OR t.play_count = 0)'] + params: list = [] + if cutoff: + where.append('t.created_at >= ?') + params.append(cutoff) + + query = f""" + SELECT t.id, t.title, t.duration, + t.play_count, t.created_at, t.spotify_track_id, + t.deezer_id, t.itunes_track_id, + COALESCE(t.track_artist, ar.name, 'Unknown') AS artist_name, + a.title AS album_name + FROM tracks t + LEFT JOIN artists ar ON t.artist_id = ar.id + LEFT JOIN albums a ON t.album_id = a.id + WHERE {' AND '.join(where)} + ORDER BY t.created_at DESC + """ + + with db._get_connection() as conn: + cursor = conn.execute(query, params) + rows = cursor.fetchall() + + tracks: List[Track] = [] + for row in rows: + tracks.append(Track( + track_name=row['title'] or 'Unknown', + artist_name=(row['artist_name'] or '').strip() or 'Unknown', + album_name=(row['album_name'] or '').strip() or '', + duration_ms=int(row['duration'] or 0), + spotify_track_id=row['spotify_track_id'], + deezer_track_id=row['deezer_id'], + itunes_track_id=row['itunes_track_id'], + source='library', + )) + + tracks = _diversity_limit( + tracks, + limit=config.limit, + max_per_artist=config.max_per_artist, + max_per_album=config.max_per_album, + ) + return tracks + + +def display_name_with_config(variant: str, config: PlaylistConfig) -> str: + """Resolve the playlist name, honouring ``config.extra['name']``.""" + custom = (config.extra or {}).get('name') + if custom: + return str(custom).strip() + return 'Unplayed Tracks' + + +SPEC = PlaylistKindSpec( + kind=KIND, + name_template='Unplayed Tracks', + description="Library songs you haven't played yet, newest additions first.", + default_config=PlaylistConfig( + limit=500, + max_per_album=5, + max_per_artist=3, + extra={'max_days_since_added': None, 'name': 'Unplayed Tracks'}, + ), + generator=generate, + requires_variant=False, + tags=['library'], +) + + +if get_registry().get(KIND) is None: + get_registry().register(SPEC) diff --git a/core/personalized/manager.py b/core/personalized/manager.py index 04eaa1594..4a058be91 100644 --- a/core/personalized/manager.py +++ b/core/personalized/manager.py @@ -94,6 +94,24 @@ def get_playlist(self, kind: str, variant: str = '', profile_id: int = 1) -> Opt """Return the playlist row if it exists. Does NOT auto-create.""" return self._fetch_playlist_row(kind, variant, profile_id) + def delete_playlist(self, kind: str, variant: str = '', profile_id: int = 1) -> bool: + """Delete a playlist and its tracks. Returns True if a row was deleted.""" + record = self._fetch_playlist_row(kind, variant, profile_id) + if record is None: + return False + with self.database._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "DELETE FROM personalized_playlist_tracks WHERE playlist_id = ?", + (record.id,), + ) + cursor.execute( + "DELETE FROM personalized_playlists WHERE id = ?", + (record.id,), + ) + conn.commit() + return True + def list_playlists(self, profile_id: int = 1) -> List[PlaylistRecord]: """List every persisted playlist for a profile, newest-first.""" with self.database._get_connection() as conn: diff --git a/database/music_database.py b/database/music_database.py index 761aeba17..12ee76d17 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -7,7 +7,7 @@ import re import threading import time -from datetime import datetime +from datetime import datetime, timezone from typing import List, Optional, Dict, Any, Tuple from dataclasses import dataclass from pathlib import Path @@ -7061,6 +7061,16 @@ def insert_or_update_media_track(self, track_obj, album_id: str, artist_id: str, # Extract MusicBrainz recording ID from server if available (Navidrome provides this) mbid = getattr(track_obj, 'musicBrainzId', None) or None + # Extract addedAt from the media server object for date_added tracking + added_at = getattr(track_obj, 'addedAt', None) + if added_at is not None: + if hasattr(added_at, 'strftime'): + added_at_str = added_at.strftime('%Y-%m-%d %H:%M:%S') + else: + added_at_str = str(added_at) + else: + added_at_str = None + # Check if track already exists — UPDATE to preserve enrichment columns, # INSERT only for genuinely new tracks cursor.execute("SELECT 1 FROM tracks WHERE id = ? LIMIT 1", (track_id,)) @@ -7069,26 +7079,40 @@ def insert_or_update_media_track(self, track_obj, album_id: str, artist_id: str, if is_new_track: cursor.execute(""" INSERT INTO tracks - (id, album_id, artist_id, title, track_number, disc_number, duration, file_path, bitrate, file_size, server_source, track_artist, musicbrainz_recording_id, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) - """, (track_id, album_id, artist_id, title, track_number, disc_number, duration, file_path, bitrate, file_size, server_source, track_artist, mbid)) + (id, album_id, artist_id, title, track_number, disc_number, duration, file_path, bitrate, file_size, server_source, track_artist, musicbrainz_recording_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + """, (track_id, album_id, artist_id, title, track_number, disc_number, duration, file_path, bitrate, file_size, server_source, track_artist, mbid, added_at_str or datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S'))) else: # Update server-provided fields only — preserves spotify_track_id, deezer_id, # isrc, bpm, and all other enrichment data. file_size uses # COALESCE(?, file_size) so a NULL from the server (e.g. # Jellyfin sometimes omits Size on first sync) doesn't wipe # an existing value. - cursor.execute(""" - UPDATE tracks - SET album_id = ?, artist_id = ?, title = ?, track_number = ?, disc_number = ?, - duration = ?, file_path = ?, bitrate = ?, - file_size = COALESCE(?, file_size), - server_source = ?, - track_artist = COALESCE(?, track_artist), - musicbrainz_recording_id = COALESCE(?, musicbrainz_recording_id), - updated_at = CURRENT_TIMESTAMP - WHERE id = ? - """, (album_id, artist_id, title, track_number, disc_number, duration, file_path, bitrate, file_size, server_source, track_artist, mbid, track_id)) + if added_at_str: + cursor.execute(""" + UPDATE tracks + SET album_id = ?, artist_id = ?, title = ?, track_number = ?, disc_number = ?, + duration = ?, file_path = ?, bitrate = ?, + file_size = COALESCE(?, file_size), + server_source = ?, + track_artist = COALESCE(?, track_artist), + musicbrainz_recording_id = COALESCE(?, musicbrainz_recording_id), + created_at = CASE WHEN created_at != ? THEN ? ELSE created_at END, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (album_id, artist_id, title, track_number, disc_number, duration, file_path, bitrate, file_size, server_source, track_artist, mbid, added_at_str, added_at_str, track_id)) + else: + cursor.execute(""" + UPDATE tracks + SET album_id = ?, artist_id = ?, title = ?, track_number = ?, disc_number = ?, + duration = ?, file_path = ?, bitrate = ?, + file_size = COALESCE(?, file_size), + server_source = ?, + track_artist = COALESCE(?, track_artist), + musicbrainz_recording_id = COALESCE(?, musicbrainz_recording_id), + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, (album_id, artist_id, title, track_number, disc_number, duration, file_path, bitrate, file_size, server_source, track_artist, mbid, track_id)) conn.commit() @@ -16207,6 +16231,20 @@ def get_automations(self, profile_id: int = 1): logger.error(f"Error getting automations: {e}") return [] + def get_automations_by_action(self, action_type: str): + """Get all automations matching an action_type. Returns list of dicts.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT * FROM automations WHERE action_type = ?", + (action_type,), + ) + return [dict(row) for row in cursor.fetchall()] + except Exception as e: + logger.error(f"Error getting automations by action {action_type}: {e}") + return [] + def get_system_automation_by_action(self, action_type: str): """Get a system automation by its action_type. Returns dict or None.""" try: diff --git a/web_server.py b/web_server.py index 706e29cdf..259fc8d62 100644 --- a/web_server.py +++ b/web_server.py @@ -32507,7 +32507,9 @@ def personalized_list_playlists(): """List every persisted personalized playlist for the active profile.""" try: manager = _build_personalized_manager() - return jsonify(_personalized_api.list_playlists(manager, get_current_profile_id())) + return jsonify(_personalized_api.list_playlists( + manager, get_current_profile_id(), engine=automation_engine, + )) except Exception as e: logger.error(f"Personalized playlists list error: {e}") return jsonify({"success": False, "error": str(e)}), 500 @@ -32522,7 +32524,7 @@ def personalized_get_playlist(kind, variant=''): try: manager = _build_personalized_manager() return jsonify(_personalized_api.get_playlist_with_tracks( - manager, kind, variant, get_current_profile_id(), + manager, kind, variant, get_current_profile_id(), engine=automation_engine, )) except ValueError as e: return jsonify({"success": False, "error": str(e)}), 400 @@ -32540,7 +32542,8 @@ def personalized_refresh_playlist(kind, variant=''): body = request.get_json(silent=True) or {} overrides = body.get('config_overrides') if isinstance(body.get('config_overrides'), dict) else None return jsonify(_personalized_api.refresh_playlist( - manager, kind, variant, get_current_profile_id(), config_overrides=overrides, + manager, kind, variant, get_current_profile_id(), + config_overrides=overrides, engine=automation_engine, )) except ValueError as e: return jsonify({"success": False, "error": str(e)}), 400 @@ -32558,6 +32561,7 @@ def personalized_update_config(kind, variant=''): body = request.get_json(silent=True) or {} return jsonify(_personalized_api.update_config( manager, kind, variant, get_current_profile_id(), body, + engine=automation_engine, )) except ValueError as e: return jsonify({"success": False, "error": str(e)}), 400 @@ -32566,6 +32570,82 @@ def personalized_update_config(kind, variant=''): return jsonify({"success": False, "error": str(e)}), 500 +@app.route('/api/personalized/playlist//activate', methods=['POST']) +@app.route('/api/personalized/playlist///activate', methods=['POST']) +def personalized_activate_playlist(kind, variant=''): + """Activate a playlist: create an automation row and do an initial refresh.""" + try: + manager = _build_personalized_manager() + body = request.get_json(silent=True) or {} + interval = max(1, min(168, int(body.get('refresh_interval_hours', 24)))) + return jsonify(_personalized_api.activate_playlist( + manager, kind, variant, get_current_profile_id(), + engine=automation_engine, refresh_interval_hours=interval, + )) + except ValueError as e: + return jsonify({"success": False, "error": str(e)}), 400 + except Exception as e: + logger.error(f"Personalized playlist activate error ({kind}/{variant}): {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/personalized/playlist//auto-refresh', methods=['PUT']) +@app.route('/api/personalized/playlist///auto-refresh', methods=['PUT']) +def personalized_toggle_auto_refresh(kind, variant=''): + """Toggle the automation row's enabled status.""" + try: + manager = _build_personalized_manager() + body = request.get_json(silent=True) or {} + enabled = body.get('enabled') + if enabled is not None: + enabled = bool(enabled) + return jsonify(_personalized_api.toggle_auto_refresh( + manager, kind, variant, get_current_profile_id(), + engine=automation_engine, enabled=enabled, + )) + except ValueError as e: + return jsonify({"success": False, "error": str(e)}), 400 + except Exception as e: + logger.error(f"Personalized playlist toggle error ({kind}/{variant}): {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/personalized/playlist//refresh-interval', methods=['PUT']) +@app.route('/api/personalized/playlist///refresh-interval', methods=['PUT']) +def personalized_update_refresh_interval(kind, variant=''): + """Update the automation's refresh interval for a playlist.""" + try: + manager = _build_personalized_manager() + body = request.get_json(silent=True) or {} + interval = max(1, min(168, int(body.get('refresh_interval_hours', 24)))) + return jsonify(_personalized_api.update_refresh_interval( + manager, kind, variant, get_current_profile_id(), + engine=automation_engine, refresh_interval_hours=interval, + )) + except ValueError as e: + return jsonify({"success": False, "error": str(e)}), 400 + except Exception as e: + logger.error(f"Personalized playlist refresh-interval error ({kind}/{variant}): {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + +@app.route('/api/personalized/playlist/', methods=['DELETE']) +@app.route('/api/personalized/playlist//', methods=['DELETE']) +def personalized_delete_playlist(kind, variant=''): + """Delete a playlist and its associated automation row.""" + try: + manager = _build_personalized_manager() + return jsonify(_personalized_api.delete_playlist( + manager, kind, variant, get_current_profile_id(), + engine=automation_engine, + )) + except ValueError as e: + return jsonify({"success": False, "error": str(e)}), 400 + except Exception as e: + logger.error(f"Personalized playlist delete error ({kind}/{variant}): {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + # ─── Unified blocklist (artist/album/track) — Phase 1 ─── # Distinct from /api/library/blacklist (download source skipping). Profile- # scoped. On add, the other metadata sources' IDs are resolved synchronously diff --git a/webui/package-lock.json b/webui/package-lock.json index e924707ff..358e4a499 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -1042,9 +1042,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1062,9 +1059,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1082,9 +1076,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1102,9 +1093,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1122,9 +1110,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1142,9 +1127,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1162,9 +1144,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1182,9 +1161,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1473,9 +1449,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1493,9 +1466,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1513,9 +1483,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1533,9 +1500,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1553,9 +1517,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1573,9 +1534,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1593,9 +1551,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1613,9 +1568,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1838,9 +1790,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1858,9 +1807,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1878,9 +1824,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1898,9 +1841,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1918,9 +1858,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1938,9 +1875,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/webui/src/platform/shell/route-manifest.test.ts b/webui/src/platform/shell/route-manifest.test.ts index f3a77d454..94853c9d9 100644 --- a/webui/src/platform/shell/route-manifest.test.ts +++ b/webui/src/platform/shell/route-manifest.test.ts @@ -54,7 +54,12 @@ describe('shellRouteManifest', () => { expect(getShellRouteByPageId('stats')?.kind).toBe('react'); expect(getShellRouteByPageId('import')?.kind).toBe('react'); expect(getShellRouteByPageId('discover')?.kind).toBe('legacy'); - expect(reactShellRoutes.map((route) => route.pageId)).toEqual(['import', 'stats', 'issues']); + expect(reactShellRoutes.map((route) => route.pageId)).toEqual([ + 'import', + 'playlists', + 'stats', + 'issues', + ]); expect(legacyShellRoutes.some((route) => route.pageId === 'dashboard')).toBe(true); }); diff --git a/webui/src/platform/shell/route-manifest.ts b/webui/src/platform/shell/route-manifest.ts index 778e9a1d7..1b172bb93 100644 --- a/webui/src/platform/shell/route-manifest.ts +++ b/webui/src/platform/shell/route-manifest.ts @@ -13,6 +13,7 @@ export const shellPageIds = [ 'artist-detail', 'stats', 'import', + 'playlists', 'settings', 'issues', 'help', @@ -40,6 +41,7 @@ export const shellRouteManifest: readonly ShellRouteDefinition[] = [ { pageId: 'automations', path: '/automations', kind: 'legacy' }, { pageId: 'active-downloads', path: '/active-downloads', kind: 'legacy' }, { pageId: 'import', path: '/import', kind: 'react' }, + { pageId: 'playlists', path: '/playlists', kind: 'react' }, { pageId: 'library', path: '/library', kind: 'legacy' }, { pageId: 'tools', path: '/tools', kind: 'legacy' }, { pageId: 'artist-detail', path: '/artist-detail', kind: 'legacy' }, diff --git a/webui/src/routeTree.gen.ts b/webui/src/routeTree.gen.ts index 7f935df1a..824bded83 100644 --- a/webui/src/routeTree.gen.ts +++ b/webui/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SplatRouteImport } from './routes/$' import { Route as StatsRouteRouteImport } from './routes/stats/route' +import { Route as PlaylistsRouteRouteImport } from './routes/playlists/route' import { Route as IssuesRouteRouteImport } from './routes/issues/route' import { Route as ImportRouteRouteImport } from './routes/import/route' import { Route as IndexRouteImport } from './routes/index' @@ -30,6 +31,11 @@ const StatsRouteRoute = StatsRouteRouteImport.update({ path: '/stats', getParentRoute: () => rootRouteImport, } as any) +const PlaylistsRouteRoute = PlaylistsRouteRouteImport.update({ + id: '/playlists', + path: '/playlists', + getParentRoute: () => rootRouteImport, +} as any) const IssuesRouteRoute = IssuesRouteRouteImport.update({ id: '/issues', path: '/issues', @@ -75,6 +81,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/import': typeof ImportRouteRouteWithChildren '/issues': typeof IssuesRouteRoute + '/playlists': typeof PlaylistsRouteRoute '/stats': typeof StatsRouteRoute '/$': typeof SplatRoute '/import/album': typeof ImportAlbumRoute @@ -86,6 +93,7 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/issues': typeof IssuesRouteRoute + '/playlists': typeof PlaylistsRouteRoute '/stats': typeof StatsRouteRoute '/$': typeof SplatRoute '/import/album': typeof ImportAlbumRoute @@ -99,6 +107,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/import': typeof ImportRouteRouteWithChildren '/issues': typeof IssuesRouteRoute + '/playlists': typeof PlaylistsRouteRoute '/stats': typeof StatsRouteRoute '/$': typeof SplatRoute '/import/album': typeof ImportAlbumRoute @@ -113,6 +122,7 @@ export interface FileRouteTypes { | '/' | '/import' | '/issues' + | '/playlists' | '/stats' | '/$' | '/import/album' @@ -124,6 +134,7 @@ export interface FileRouteTypes { to: | '/' | '/issues' + | '/playlists' | '/stats' | '/$' | '/import/album' @@ -136,6 +147,7 @@ export interface FileRouteTypes { | '/' | '/import' | '/issues' + | '/playlists' | '/stats' | '/$' | '/import/album' @@ -149,6 +161,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute ImportRouteRoute: typeof ImportRouteRouteWithChildren IssuesRouteRoute: typeof IssuesRouteRoute + PlaylistsRouteRoute: typeof PlaylistsRouteRoute StatsRouteRoute: typeof StatsRouteRoute SplatRoute: typeof SplatRoute ArtistDetailSourceIdRoute: typeof ArtistDetailSourceIdRoute @@ -170,6 +183,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof StatsRouteRouteImport parentRoute: typeof rootRouteImport } + '/playlists': { + id: '/playlists' + path: '/playlists' + fullPath: '/playlists' + preLoaderRoute: typeof PlaylistsRouteRouteImport + parentRoute: typeof rootRouteImport + } '/issues': { id: '/issues' path: '/issues' @@ -251,6 +271,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ImportRouteRoute: ImportRouteRouteWithChildren, IssuesRouteRoute: IssuesRouteRoute, + PlaylistsRouteRoute: PlaylistsRouteRoute, StatsRouteRoute: StatsRouteRoute, SplatRoute: SplatRoute, ArtistDetailSourceIdRoute: ArtistDetailSourceIdRoute, diff --git a/webui/src/routes/playlists/-playlists.api.ts b/webui/src/routes/playlists/-playlists.api.ts new file mode 100644 index 000000000..18c6cca0c --- /dev/null +++ b/webui/src/routes/playlists/-playlists.api.ts @@ -0,0 +1,116 @@ +import { queryOptions, type QueryClient } from '@tanstack/react-query'; + +import { apiClient, readJson } from '@/app/api-client'; + +import type { + ConfigUpdateResponse, + KindsResponse, + PlaylistDetailResponse, + PlaylistsResponse, + RefreshResponse, +} from './-playlists.types'; + +export const PLAYLISTS_QUERY_KEY = ['personalized'] as const; + +export async function fetchKinds(): Promise { + return await readJson(apiClient.get('personalized/kinds')); +} + +export async function fetchPlaylists(): Promise { + return await readJson(apiClient.get('personalized/playlists')); +} + +export async function fetchPlaylistDetail( + kind: string, + variant: string = '', +): Promise { + const path = variant + ? `personalized/playlist/${encodeURIComponent(kind)}/${encodeURIComponent(variant)}` + : `personalized/playlist/${encodeURIComponent(kind)}`; + return await readJson(apiClient.get(path)); +} + +export async function updatePlaylistConfig( + kind: string, + variant: string = '', + config: Record, +): Promise { + const path = variant + ? `personalized/playlist/${encodeURIComponent(kind)}/${encodeURIComponent(variant)}/config` + : `personalized/playlist/${encodeURIComponent(kind)}/config`; + return await readJson(apiClient.put(path, { json: config })); +} + +export async function refreshPlaylist( + kind: string, + variant: string = '', +): Promise { + const path = variant + ? `personalized/playlist/${encodeURIComponent(kind)}/${encodeURIComponent(variant)}/refresh` + : `personalized/playlist/${encodeURIComponent(kind)}/refresh`; + return await readJson(apiClient.post(path)); +} + +export async function activatePlaylist( + kind: string, + variant: string = '', + refreshIntervalHours: number = 24, +): Promise { + const path = variant + ? `personalized/playlist/${encodeURIComponent(kind)}/${encodeURIComponent(variant)}/activate` + : `personalized/playlist/${encodeURIComponent(kind)}/activate`; + return await readJson( + apiClient.post(path, { json: { refresh_interval_hours: refreshIntervalHours } }), + ); +} + +export async function updateRefreshInterval( + kind: string, + variant: string = '', + refreshIntervalHours: number, +): Promise { + const path = variant + ? `personalized/playlist/${encodeURIComponent(kind)}/${encodeURIComponent(variant)}/refresh-interval` + : `personalized/playlist/${encodeURIComponent(kind)}/refresh-interval`; + return await readJson( + apiClient.put(path, { json: { refresh_interval_hours: refreshIntervalHours } }), + ); +} + +export async function deletePlaylist( + kind: string, + variant: string = '', +): Promise<{ success: boolean; error?: string }> { + const path = variant + ? `personalized/playlist/${encodeURIComponent(kind)}/${encodeURIComponent(variant)}` + : `personalized/playlist/${encodeURIComponent(kind)}`; + return await readJson<{ success: boolean; error?: string }>(apiClient.delete(path)); +} + +export function kindsQueryOptions() { + return queryOptions({ + queryKey: [...PLAYLISTS_QUERY_KEY, 'kinds'], + queryFn: fetchKinds, + staleTime: 60_000, + }); +} + +export function playlistsQueryOptions() { + return queryOptions({ + queryKey: [...PLAYLISTS_QUERY_KEY, 'list'], + queryFn: fetchPlaylists, + staleTime: 10_000, + }); +} + +export function playlistDetailQueryOptions(kind: string, variant: string = '') { + return queryOptions({ + queryKey: [...PLAYLISTS_QUERY_KEY, 'detail', kind, variant], + queryFn: () => fetchPlaylistDetail(kind, variant), + staleTime: 10_000, + }); +} + +export function invalidatePlaylistsQueries(queryClient: QueryClient) { + return queryClient.invalidateQueries({ queryKey: PLAYLISTS_QUERY_KEY }); +} diff --git a/webui/src/routes/playlists/-playlists.types.ts b/webui/src/routes/playlists/-playlists.types.ts new file mode 100644 index 000000000..aa8becb17 --- /dev/null +++ b/webui/src/routes/playlists/-playlists.types.ts @@ -0,0 +1,88 @@ +import { z } from 'zod'; + +export const playlistsSearchSchema = z.object({}); + +export type PlaylistsSearch = z.infer; + +export interface PlaylistKind { + kind: string; + name_template: string; + description: string; + requires_variant: boolean; + tags: string[]; + variants: string[]; + default_config: PlaylistConfig; +} + +export interface PlaylistConfig { + limit: number; + max_per_album: number; + max_per_artist: number; + popularity_min: number | null; + popularity_max: number | null; + exclude_recent_days: number; + recency_days: number | null; + seed: number | null; + extra: Record; +} + +export interface PlaylistTrack { + position: number; + spotify_track_id: string | null; + itunes_track_id: string | null; + deezer_track_id: string | null; + track_name: string; + artist_name: string; + album_name: string; + album_cover_url: string | null; + duration_ms: number; + popularity: number; + source: string | null; + track_data_json: unknown; +} + +export interface PersonalizedPlaylist { + id: number; + profile_id: number; + kind: string; + variant: string; + name: string; + config: PlaylistConfig; + track_count: number; + last_generated_at: string | null; + last_synced_at: string | null; + last_generation_source: string | null; + last_generation_error: string | null; + is_stale: boolean; + automation_id: number | null; + auto_refresh: boolean; + refresh_interval_hours: number; +} + +export interface KindsResponse { + success: boolean; + kinds: PlaylistKind[]; +} + +export interface PlaylistsResponse { + success: boolean; + playlists: PersonalizedPlaylist[]; +} + +export interface PlaylistDetailResponse { + success: boolean; + playlist: PersonalizedPlaylist; + tracks: PlaylistTrack[]; +} + +export interface ConfigUpdateResponse { + success: boolean; + playlist: PersonalizedPlaylist; +} + +export interface RefreshResponse { + success: boolean; + playlist: PersonalizedPlaylist; + tracks?: PlaylistTrack[]; + error?: string; +} diff --git a/webui/src/routes/playlists/-ui/playlists-page.module.css b/webui/src/routes/playlists/-ui/playlists-page.module.css new file mode 100644 index 000000000..e040ada8a --- /dev/null +++ b/webui/src/routes/playlists/-ui/playlists-page.module.css @@ -0,0 +1,482 @@ +.container { + margin: 20px; + padding: 28px 24px 30px; + overflow: hidden; + background: linear-gradient(135deg, rgba(20, 20, 20, 0.55) 0%, rgba(12, 12, 12, 0.62) 100%); + border-radius: 24px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-top: 1px solid rgba(255, 255, 255, 0.12); + box-shadow: + 0 8px 32px rgba(0, 0, 0, 0.3), + 0 4px 16px rgba(0, 0, 0, 0.2), + inset 0 1px 0 rgba(255, 255, 255, 0.08); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin: -28px -24px 20px; + padding: 20px 24px; + min-height: 120px; + background: linear-gradient( + 180deg, + rgba(var(--accent-rgb, 139, 92, 246), 0.1) 0%, + rgba(var(--accent-rgb, 139, 92, 246), 0.04) 40%, + transparent 100% + ); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + border-top-left-radius: 24px; + border-top-right-radius: 24px; + position: relative; + overflow: hidden; + flex-wrap: wrap; +} + +.headerTitle { + min-width: 0; +} + +.title { + font-size: 1.6rem; + font-weight: 600; + color: #fff; + margin: 0; +} + +.subtitle { + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.5); + margin: 6px 0 0; +} + +.content { + display: flex; + flex-direction: column; + gap: 28px; +} + +.section { + display: flex; + flex-direction: column; + gap: 16px; +} + +.sectionTitle { + font-size: 1.1rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.85); + margin: 0; + padding-bottom: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 16px; +} + +.card { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + overflow: hidden; + transition: border-color 0.2s; +} + +.card:hover { + border-color: rgba(255, 255, 255, 0.14); +} + +.cardStale { + border-color: rgba(234, 179, 8, 0.3); +} + +.cardHeader { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 16px 18px; + cursor: pointer; + gap: 12px; +} + +.cardHeader:focus-visible { + outline: 2px solid rgba(139, 92, 246, 0.7); + outline-offset: -2px; + border-radius: 16px; +} + +.cardInfo { + flex: 1; + min-width: 0; +} + +.cardNameRow { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.cardTitle { + font-size: 1rem; + font-weight: 600; + color: #fff; + margin: 0; + cursor: pointer; +} + +.cardTitle:hover { + text-decoration: underline dotted rgba(255, 255, 255, 0.4); +} + +.cardDesc { + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.45); + margin: 4px 0 0; + line-height: 1.4; +} + +.cardMeta { + font-size: 0.78rem; + color: rgba(255, 255, 255, 0.4); + margin: 6px 0 0; +} + +.cardError { + font-size: 0.78rem; + color: rgba(239, 68, 68, 0.9); + margin: 4px 0 0; +} + +.cardTags { + display: flex; + gap: 6px; + margin-top: 6px; + flex-wrap: wrap; +} + +.tag { + font-size: 0.7rem; + padding: 2px 8px; + border-radius: 10px; + background: rgba(139, 92, 246, 0.15); + color: rgba(196, 167, 255, 0.9); + text-transform: capitalize; +} + +.staleBadge { + font-size: 0.7rem; + padding: 2px 8px; + border-radius: 10px; + background: rgba(234, 179, 8, 0.2); + color: rgba(250, 204, 21, 0.9); +} + +.autoRefreshBadge { + font-size: 0.7rem; + padding: 2px 8px; + border-radius: 10px; + background: rgba(34, 197, 94, 0.2); + color: rgba(74, 222, 128, 0.9); +} + +.cardActions { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; + flex-shrink: 0; +} + +.intervalSelect { + font-size: 0.75rem; + padding: 4px 8px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + color: rgba(255, 255, 255, 0.8); + cursor: pointer; +} + +.intervalSelect:focus-visible { + outline: 2px solid rgba(139, 92, 246, 0.7); + outline-offset: 1px; +} + +.intervalSelect option { + background: #1a1a2e; + color: #fff; +} + +.btn { + font-family: inherit; + font-size: 0.8rem; + padding: 6px 14px; + border-radius: 10px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.8); + cursor: pointer; + transition: background 0.15s; + white-space: nowrap; +} + +.btn:hover { + background: rgba(255, 255, 255, 0.12); +} + +.btn:focus-visible { + outline: 2px solid rgba(139, 92, 246, 0.7); + outline-offset: 1px; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btnPrimary { + background: rgba(139, 92, 246, 0.25); + border-color: rgba(139, 92, 246, 0.35); + color: rgba(196, 167, 255, 0.95); +} + +.btnPrimary:hover { + background: rgba(139, 92, 246, 0.35); +} + +.btnDanger { + background: rgba(239, 68, 68, 0.15); + border-color: rgba(239, 68, 68, 0.3); + color: rgba(252, 165, 165, 0.95); +} + +.btnDanger:hover { + background: rgba(239, 68, 68, 0.25); +} + +.cardBody { + padding: 0 18px 16px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.configSection { + padding-top: 14px; +} + +.configSectionTitle { + font-size: 0.82rem; + font-weight: 600; + color: rgba(255, 255, 255, 0.65); + margin: 0 0 10px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.configGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 10px; +} + +.configField { + display: flex; + flex-direction: column; + gap: 4px; +} + +.configLabel { + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.5); +} + +.configInput { + font-size: 0.82rem; + padding: 6px 10px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + color: #fff; + outline: none; + transition: border-color 0.15s; +} + +.configInput:focus { + border-color: rgba(139, 92, 246, 0.5); +} + +.trackSection { + padding-top: 14px; +} + +.trackList { + display: flex; + flex-direction: column; + gap: 1px; + max-height: 400px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.15) transparent; +} + +.trackRow { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 8px; + border-radius: 6px; + font-size: 0.8rem; + transition: background 0.1s; +} + +.trackRow:hover { + background: rgba(255, 255, 255, 0.04); +} + +.trackPos { + width: 28px; + text-align: right; + color: rgba(255, 255, 255, 0.3); + font-variant-numeric: tabular-nums; + flex-shrink: 0; +} + +.trackInfo { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.trackName { + color: rgba(255, 255, 255, 0.85); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.trackArtist { + font-size: 0.72rem; + color: rgba(255, 255, 255, 0.45); +} + +.trackAlbum { + font-size: 0.72rem; + color: rgba(255, 255, 255, 0.35); + max-width: 160px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.trackDuration { + font-size: 0.72rem; + color: rgba(255, 255, 255, 0.35); + flex-shrink: 0; + font-variant-numeric: tabular-nums; +} + +.trackOverflow { + font-size: 0.78rem; + color: rgba(255, 255, 255, 0.4); + text-align: center; + padding: 8px; +} + +.loadingText, +.emptyText, +.errorText { + font-size: 0.82rem; + color: rgba(255, 255, 255, 0.4); + padding: 12px 0; +} + +.errorText { + color: rgba(239, 68, 68, 0.9); +} + +.loadingState, +.errorState { + display: flex; + justify-content: center; + padding: 40px 0; +} + +.kindGroup { + display: flex; + flex-direction: column; + gap: 10px; +} + +.kindGroupTitle { + font-size: 0.85rem; + font-weight: 500; + color: rgba(255, 255, 255, 0.55); + margin: 0; +} + +.nameForm { + display: flex; +} + +.nameInput { + font-family: inherit; + font-size: 1rem; + font-weight: 600; + padding: 2px 6px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(139, 92, 246, 0.5); + color: #fff; + outline: none; + width: 100%; +} + +.nameInput:focus { + border-color: rgba(139, 92, 246, 0.8); + box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.2); +} + +@media (max-width: 768px) { + .container { + margin: 10px; + padding: 20px 16px; + } + + .header { + margin: -20px -16px 16px; + padding: 16px; + min-height: auto; + } + + .title { + font-size: 1.3rem; + } + + .grid { + grid-template-columns: 1fr; + gap: 12px; + } + + .cardHeader { + flex-direction: column; + padding: 14px 14px; + } + + .cardActions { + flex-direction: row; + align-items: center; + width: 100%; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + } + + .trackAlbum { + display: none; + } +} diff --git a/webui/src/routes/playlists/-ui/playlists-page.tsx b/webui/src/routes/playlists/-ui/playlists-page.tsx new file mode 100644 index 000000000..f4c34342e --- /dev/null +++ b/webui/src/routes/playlists/-ui/playlists-page.tsx @@ -0,0 +1,573 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { useReactPageShell } from '@/platform/shell/route-controllers'; + +import type { PersonalizedPlaylist, PlaylistKind, PlaylistTrack } from '../-playlists.types'; + +import { + activatePlaylist, + deletePlaylist, + invalidatePlaylistsQueries, + kindsQueryOptions, + playlistDetailQueryOptions, + playlistsQueryOptions, + refreshPlaylist, + updatePlaylistConfig, + updateRefreshInterval, +} from '../-playlists.api'; +import styles from './playlists-page.module.css'; + +const REFRESH_OPTIONS = [ + { value: 6, label: 'Every 6 hours' }, + { value: 12, label: 'Every 12 hours' }, + { value: 24, label: 'Every day' }, + { value: 168, label: 'Every week' }, +]; + +export function PlaylistsPage() { + useReactPageShell('playlists'); + + const kindsQuery = useQuery(kindsQueryOptions()); + const playlistsQuery = useQuery(playlistsQueryOptions()); + + const kinds = kindsQuery.data?.kinds ?? []; + const playlists = playlistsQuery.data?.playlists ?? []; + + const activeKinds = useMemo(() => new Set(playlists.map((pl) => pl.kind)), [playlists]); + + const libraryKinds = useMemo( + () => kinds.filter((k) => k.tags.includes('library') && !activeKinds.has(k.kind)), + [kinds, activeKinds], + ); + const discoveryKinds = useMemo( + () => kinds.filter((k) => k.tags.includes('discovery') && !activeKinds.has(k.kind)), + [kinds, activeKinds], + ); + const otherKinds = useMemo( + () => + kinds.filter( + (k) => + !k.tags.includes('library') && !k.tags.includes('discovery') && !activeKinds.has(k.kind), + ), + [kinds, activeKinds], + ); + + const sortedPlaylists = useMemo(() => { + return [...playlists].sort((a, b) => { + const aActive = a.auto_refresh ? 1 : 0; + const bActive = b.auto_refresh ? 1 : 0; + if (aActive !== bActive) return bActive - aActive; + const aTime = a.last_generated_at ? new Date(a.last_generated_at).getTime() : 0; + const bTime = b.last_generated_at ? new Date(b.last_generated_at).getTime() : 0; + return bTime - aTime; + }); + }, [playlists]); + + const isLoading = kindsQuery.isLoading || playlistsQuery.isLoading; + const isError = kindsQuery.isError || playlistsQuery.isError; + const errorMessage = + kindsQuery.error?.message || playlistsQuery.error?.message || 'Failed to load playlists'; + + return ( +
+
+
+

Auto-Playlists

+

+ Auto-generated playlists from your library and discovery pool. Activate a kind to start + receiving fresh tracks on a schedule. +

+
+
+ +
+ {isLoading && ( +
+

Loading playlists...

+
+ )} + + {isError && ( +
+

{errorMessage}

+ +
+ )} + + {!isLoading && !isError && ( + <> + {sortedPlaylists.length > 0 && ( +
+

Active Auto-Playlists

+
+ {sortedPlaylists.map((pl) => ( + + ))} +
+
+ )} + + {(libraryKinds.length > 0 || discoveryKinds.length > 0 || otherKinds.length > 0) && ( +
+

Create New

+ {libraryKinds.length > 0 && ( + + )} + {discoveryKinds.length > 0 && ( + + )} + {otherKinds.length > 0 && } +
+ )} + + )} +
+
+ ); +} + +function KindGroup({ title, kinds }: { title: string; kinds: PlaylistKind[] }) { + if (kinds.length === 0) return null; + return ( +
+

{title}

+
+ {kinds.map((kind) => ( + + ))} +
+
+ ); +} + +function KindCard({ kind }: { kind: PlaylistKind }) { + const queryClient = useQueryClient(); + + const activateMutation = useMutation({ + mutationFn: () => activatePlaylist(kind.kind, '', 24), + onSuccess: () => { + void invalidatePlaylistsQueries(queryClient); + window.showToast?.('Playlist activated', 'success'); + }, + onError: (err: Error) => { + window.showToast?.(err.message || 'Failed to activate playlist', 'error'); + }, + }); + + return ( +
+
+
+

{kind.name_template.replace('{variant}', '').trim()}

+

{kind.description}

+
+ {kind.tags.map((tag) => ( + + {tag} + + ))} +
+
+
+ +
+
+
+ ); +} + +function PlaylistCard({ + playlist, +}: { + playlist: PersonalizedPlaylist; +}) { + const queryClient = useQueryClient(); + const [expanded, setExpanded] = useState(false); + const [editingName, setEditingName] = useState(false); + const [nameValue, setNameValue] = useState(playlist.name); + + const detailQuery = useQuery({ + ...playlistDetailQueryOptions(playlist.kind, playlist.variant), + enabled: expanded, + }); + + const tracks = detailQuery.data?.tracks ?? []; + + const refreshMutation = useMutation({ + mutationFn: () => refreshPlaylist(playlist.kind, playlist.variant), + onSuccess: (result) => { + void invalidatePlaylistsQueries(queryClient); + if (result.error) { + window.showToast?.(result.error, 'warning'); + } else { + window.showToast?.('Playlist refreshed', 'success'); + } + }, + onError: (err: Error) => { + window.showToast?.(err.message || 'Failed to refresh playlist', 'error'); + }, + }); + + const deactivateMutation = useMutation({ + mutationFn: () => deletePlaylist(playlist.kind, playlist.variant), + onSuccess: (result) => { + if (!result.success) { + window.showToast?.(result.error || 'Failed to deactivate playlist', 'error'); + return; + } + void invalidatePlaylistsQueries(queryClient); + window.showToast?.('Playlist deactivated', 'success'); + }, + onError: (err: Error) => { + window.showToast?.(err.message || 'Failed to deactivate playlist', 'error'); + }, + }); + + const updateNameMutation = useMutation({ + mutationFn: async (newName: string) => { + return updatePlaylistConfig(playlist.kind, playlist.variant, { + extra: { ...playlist.config.extra, name: newName }, + }); + }, + onSuccess: () => { + void invalidatePlaylistsQueries(queryClient); + setEditingName(false); + }, + onError: (err: Error) => { + window.showToast?.(err.message || 'Failed to rename playlist', 'error'); + setEditingName(false); + setNameValue(playlist.name); + }, + }); + + const intervalMutation = useMutation({ + mutationFn: (hours: number) => + updateRefreshInterval(playlist.kind, playlist.variant, hours), + onSuccess: () => { + void invalidatePlaylistsQueries(queryClient); + }, + onError: (err: Error) => { + window.showToast?.(err.message || 'Failed to update interval', 'error'); + }, + }); + + const updateConfigMutation = useMutation({ + mutationFn: async (overrides: Record) => { + return updatePlaylistConfig(playlist.kind, playlist.variant, overrides); + }, + onSuccess: () => { + void invalidatePlaylistsQueries(queryClient); + }, + onError: (err: Error) => { + window.showToast?.(err.message || 'Failed to update config', 'error'); + }, + }); + + const getConfig = () => { + const cached = queryClient.getQueryData(playlistsQueryOptions().queryKey); + const list = (cached as { playlists?: PersonalizedPlaylist[] } | undefined)?.playlists; + return list?.find((p) => p.kind === playlist.kind && p.variant === playlist.variant)?.config ?? playlist.config; + }; + + useEffect(() => { + if (!editingName) { + setNameValue(playlist.name); + } + }, [playlist.name, editingName]); + + const lastGenerated = playlist.last_generated_at + ? new Date(playlist.last_generated_at).toLocaleString() + : 'Never'; + + const handleHeaderKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setExpanded(!expanded); + } + }; + + return ( +
+
setExpanded(!expanded)} + onKeyDown={handleHeaderKeyDown} + role="button" + tabIndex={0} + aria-expanded={expanded} + aria-label={`${playlist.name} playlist details`} + > +
+
+ {editingName ? ( +
{ + e.preventDefault(); + e.stopPropagation(); + if (nameValue.trim()) { + updateNameMutation.mutate(nameValue.trim()); + } + }} + > + setNameValue(e.target.value)} + onBlur={() => { + if (nameValue.trim() && nameValue !== playlist.name && !updateNameMutation.isPending) { + updateNameMutation.mutate(nameValue.trim()); + } else if (!updateNameMutation.isPending) { + setEditingName(false); + setNameValue(playlist.name); + } + }} + onKeyDown={(e) => { + if (e.key === 'Escape') { + setEditingName(false); + setNameValue(playlist.name); + } + }} + autoFocus + onClick={(e) => e.stopPropagation()} + aria-label="Playlist name" + /> +
+ ) : ( +

{ + e.stopPropagation(); + setEditingName(true); + }} + title="Click to rename" + > + {playlist.name} +

+ )} + {playlist.is_stale && Stale} + {playlist.auto_refresh && Auto} +
+

+ {playlist.track_count} tracks · Last generated: {lastGenerated} +

+ {playlist.last_generation_error && ( +

{playlist.last_generation_error}

+ )} +
+
+ + + {playlist.auto_refresh && ( + + )} +
+
+ + {expanded && ( +
+
+
Configuration
+
+ { + if (updateConfigMutation.isPending) return; + const cfg = getConfig(); + updateConfigMutation.mutate({ ...cfg, limit: Number(v) || 50 }); + }} + type="number" + min={1} + max={2000} + /> + { + if (updateConfigMutation.isPending) return; + const cfg = getConfig(); + updateConfigMutation.mutate({ + ...cfg, + extra: { + ...cfg.extra, + max_days_since_added: v ? Number(v) : null, + }, + }); + }} + type="number" + min={1} + max={3650} + placeholder="All time" + /> +
+
+ +
+
Tracks ({tracks.length})
+ {detailQuery.isLoading &&

Loading tracks...

} + {detailQuery.isError &&

Failed to load tracks

} + {tracks.length > 0 && ( +
+ {tracks.slice(0, 100).map((track, i) => ( + + ))} + {tracks.length > 100 && ( +

...and {tracks.length - 100} more

+ )} +
+ )} + {!detailQuery.isLoading && !detailQuery.isError && tracks.length === 0 && ( +

No tracks yet. Click Refresh to generate.

+ )} +
+
+ )} +
+ ); +} + +function ConfigField({ + label, + value, + onChange, + type, + min, + max, + placeholder, +}: { + label: string; + value: string | number; + onChange: (value: string) => void; + type: 'text' | 'number'; + min?: number; + max?: number; + placeholder?: string; +}) { + const [localValue, setLocalValue] = useState(String(value ?? '')); + const committedRef = useRef(String(value ?? '')); + + useEffect(() => { + const next = String(value ?? ''); + if (next !== committedRef.current) { + committedRef.current = next; + setLocalValue(next); + } + }, [value]); + + return ( + + ); +} + +function TrackRow({ track, position }: { track: PlaylistTrack; position: number }) { + const duration = track.duration_ms + ? `${Math.floor(track.duration_ms / 60000)}:${String(Math.floor((track.duration_ms % 60000) / 1000)).padStart(2, '0')}` + : ''; + + return ( +
+ {position} +
+ {track.track_name} + {track.artist_name} +
+ {track.album_name && {track.album_name}} + {duration && {duration}} +
+ ); +} diff --git a/webui/src/routes/playlists/route.tsx b/webui/src/routes/playlists/route.tsx new file mode 100644 index 000000000..df18f1978 --- /dev/null +++ b/webui/src/routes/playlists/route.tsx @@ -0,0 +1,25 @@ +import { createFileRoute, redirect } from '@tanstack/react-router'; + +import { getProfileHomePath } from '@/platform/shell/bridge'; + +import { kindsQueryOptions, playlistsQueryOptions } from './-playlists.api'; +import { playlistsSearchSchema } from './-playlists.types'; +import { PlaylistsPage } from './-ui/playlists-page'; + +export const Route = createFileRoute('/playlists')({ + validateSearch: playlistsSearchSchema, + beforeLoad: ({ context }) => { + const { bridge } = context.shell; + + if (!bridge.isPageAllowed('playlists')) { + throw redirect({ href: getProfileHomePath(bridge), replace: true }); + } + }, + loader: async ({ context }) => { + await Promise.all([ + context.queryClient.ensureQueryData(kindsQueryOptions()), + context.queryClient.ensureQueryData(playlistsQueryOptions()), + ]); + }, + component: PlaylistsPage, +});