diff --git a/core/radio/__init__.py b/core/radio/__init__.py new file mode 100644 index 00000000..eb4f86d5 --- /dev/null +++ b/core/radio/__init__.py @@ -0,0 +1,22 @@ +"""Radio / auto-play recommendation logic. + +Pure, DB-agnostic helpers that decide *what* radio should play. The SQL +execution stays in ``database.music_database.get_radio_tracks``; this package +owns the decisions (tag parsing, tier caps, dedup/collection, LIKE-condition +building) so they're unit-testable without a live DB — the seam Phase 2's +smarter ranking will plug into. +""" + +from core.radio.selection import ( + RadioCollector, + build_like_conditions, + parse_tags, + same_artist_cap, +) + +__all__ = [ + "RadioCollector", + "build_like_conditions", + "parse_tags", + "same_artist_cap", +] diff --git a/core/radio/selection.py b/core/radio/selection.py new file mode 100644 index 00000000..17d008d1 --- /dev/null +++ b/core/radio/selection.py @@ -0,0 +1,139 @@ +"""Pure radio-selection decisions, lifted out of the DB layer. + +``database.music_database.get_radio_tracks`` used to inline all of this between +``cursor.execute`` calls, so the algorithm couldn't be tested without a live DB +(which also happens to throw in the dev sandbox). These helpers carry the same +behavior as before — they're a faithful extraction, not a rewrite — but as +plain functions they're unit-testable and give Phase 2 (smart ranking) a clean +place to evolve the logic. + +Nothing here touches sqlite; callers pass already-fetched rows (as dicts) and +get back decisions. +""" + +from __future__ import annotations + +import json +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple + + +def parse_tags(raw_val: Any) -> List[str]: + """Parse a genre/mood/style field into a list of tags. + + The field may be a JSON array (canonical) or a legacy comma-separated + string. Mirrors the inline ``_parse_tags`` the DB method used. + """ + if not raw_val: + return [] + try: + parsed = json.loads(raw_val) + return parsed if isinstance(parsed, list) else [str(parsed)] + except (json.JSONDecodeError, ValueError, TypeError): + return [t.strip() for t in str(raw_val).split(",") if t.strip()] + + +def same_artist_cap(limit: int) -> int: + """How many same-artist tracks tier 1 may contribute. + + Capped so radio doesn't become an all-one-artist playlist: 30% of the + limit, floored at 5 (matches the original ``max(5, limit * 3 // 10)``). + """ + return max(5, limit * 3 // 10) + + +def merge_tags(*tag_groups: Iterable[str]) -> List[str]: + """Concatenate tag lists, dedupe, preserve first-seen order. + + Mirrors ``list(dict.fromkeys(a + b))`` used for genre/mood/style merges. + """ + merged: List[str] = [] + for group in tag_groups: + for tag in group: + merged.append(tag) + return list(dict.fromkeys(merged)) + + +def build_like_conditions( + tags: Sequence[str], columns: Sequence[str] +) -> Tuple[str, List[str]]: + """Build an OR-of-LIKEs SQL fragment + params for matching ``tags`` + against each of ``columns``. + + Returns ``(sql_fragment, params)`` where the fragment is + ``"col1 LIKE ? OR col1 LIKE ? OR col2 LIKE ? ..."`` (one LIKE per + column per tag) and params are the ``%tag%`` wildcards in matching + order. Returns ``("", [])`` when there are no tags or no columns, so + callers can skip the tier cleanly. + + This reproduces the original per-tier condition building, which paired + every tag against album-level and artist-level columns. + """ + if not tags or not columns: + return "", [] + conditions: List[str] = [] + params: List[str] = [] + # Group by column (all tags for column A, then all tags for column B) to + # match the original ordering: it emitted every ``al. LIKE ?`` then + # every ``ar. LIKE ?``, with params being ``[%tag%...] * 2``. + for col in columns: + for tag in tags: + conditions.append(f"{col} LIKE ?") + params.append(f"%{tag}%") + return " OR ".join(conditions), params + + +class RadioCollector: + """Accumulates radio candidates across tiers with dedup + cap logic. + + Replaces the inline ``collected`` list + ``seen_ids`` set + ``_collect`` + closure the DB method used. Construct with the overall ``limit`` and the + set of IDs to exclude up front (seed track + caller-supplied), then feed + each tier's fetched rows through :meth:`collect`. + """ + + def __init__(self, limit: int, exclude_ids: Optional[Iterable[Any]] = None): + self.limit = limit + self._collected: List[Dict[str, Any]] = [] + # seen_ids seeds with the exclude set so excluded tracks never collect + # AND so the placeholders/values used in WHERE ... NOT IN stay in sync. + self._seen: set[str] = {str(e) for e in (exclude_ids or [])} + + @property + def tracks(self) -> List[Dict[str, Any]]: + return self._collected + + @property + def filled(self) -> bool: + """True once we've reached the overall limit.""" + return len(self._collected) >= self.limit + + def exclude_placeholders(self) -> str: + """SQL ``?,?,...`` placeholder string sized to the current seen set.""" + return ",".join("?" * len(self._seen)) + + def exclude_values(self) -> List[str]: + """Param values for the placeholders above (current seen set).""" + return list(self._seen) + + def remaining(self) -> int: + """How many more tracks are needed to hit the limit.""" + return max(0, self.limit - len(self._collected)) + + def collect(self, rows: Iterable[Dict[str, Any]], cap: Optional[int] = None) -> bool: + """Append ``rows`` (dict-like) to the result, skipping already-seen IDs. + + ``cap`` bounds how many THIS call may add (on top of what's already + collected); ``None`` means bounded only by the overall limit. Returns + True once the overall limit is reached. Mirrors the original + ``_collect`` closure exactly. + """ + target = min(self.limit, len(self._collected) + cap) if cap else self.limit + for row in rows: + r = dict(row) + rid = str(r["id"]) + if rid not in self._seen: + self._seen.add(rid) + self._collected.append(r) + if len(self._collected) >= target: + return True + return self.filled diff --git a/database/music_database.py b/database/music_database.py index 1394719f..46f87d88 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -12796,19 +12796,24 @@ class MusicDatabase: seed = dict(seed) artist_name = seed['artist_name'] - # Build the set of IDs to exclude (seed + caller-supplied) - excluded = {str(track_id)} - if exclude_ids: - excluded.update(str(eid) for eid in exclude_ids) - - collected: list[dict] = [] - seen_ids: set[str] = set(excluded) - - def _exclude_placeholders(): - return ','.join('?' * len(seen_ids)) + # Selection decisions (dedup, caps, tag parsing, condition + # building) live in core.radio.selection so they're unit- + # testable without a live DB. The cursor work stays here. + from core.radio.selection import ( + RadioCollector, + build_like_conditions, + merge_tags, + parse_tags, + same_artist_cap, + ) - def _exclude_values(): - return list(seen_ids) + # Seed + caller-supplied IDs to exclude (seeds the collector's + # seen-set so excluded tracks never collect and the NOT IN + # placeholders/values stay in sync). + exclude_seed = [str(track_id)] + if exclude_ids: + exclude_seed.extend(str(eid) for eid in exclude_ids) + collector = RadioCollector(limit, exclude_ids=exclude_seed) _track_select = """ SELECT t.id, t.title, t.track_number, t.duration, @@ -12824,98 +12829,71 @@ class MusicDatabase: # Only return tracks that have actual files on disk _file_filter = "t.file_path IS NOT NULL AND t.file_path != ''" - def _collect(rows, cap=None): - """Append rows to collected. Stop at cap or limit.""" - target = min(limit, (len(collected) + cap)) if cap else limit - for row in rows: - r = dict(row) - rid = str(r['id']) - if rid not in seen_ids: - seen_ids.add(rid) - collected.append(r) - if len(collected) >= target: - return True - return len(collected) >= limit - - def _parse_tags(raw_val): - """Parse a JSON array or comma-separated string into a list.""" - if not raw_val: - return [] - try: - parsed = json.loads(raw_val) - return parsed if isinstance(parsed, list) else [str(parsed)] - except (json.JSONDecodeError, ValueError): - return [t.strip() for t in raw_val.split(',') if t.strip()] - # --- 1. Same artist, different albums (capped at 30% of limit) --- - same_artist_cap = max(5, limit * 3 // 10) + artist_cap = same_artist_cap(limit) cursor.execute(f""" {_track_select} - WHERE {_file_filter} AND ar.name = ? AND t.album_id != ? AND t.id NOT IN ({_exclude_placeholders()}) + WHERE {_file_filter} AND ar.name = ? AND t.album_id != ? AND t.id NOT IN ({collector.exclude_placeholders()}) ORDER BY RANDOM() LIMIT ? - """, [artist_name, seed['album_id']] + _exclude_values() + [same_artist_cap]) - _collect(cursor.fetchall(), cap=same_artist_cap) + """, [artist_name, seed['album_id']] + collector.exclude_values() + [artist_cap]) + collector.collect(cursor.fetchall(), cap=artist_cap) - if len(collected) >= limit: - return {'success': True, 'tracks': collected} + if collector.filled: + return {'success': True, 'tracks': collector.tracks} # --- 2. Same genre (album genres + artist genres, other artists) --- - genre_list = _parse_tags(seed.get('album_genres')) - artist_genre_list = _parse_tags(seed.get('artist_genres')) - all_genres = list(dict.fromkeys(genre_list + artist_genre_list)) # dedupe, preserve order - - if all_genres: - genre_conditions = ' OR '.join( - ['al.genres LIKE ?' for _ in all_genres] + - ['ar.genres LIKE ?' for _ in all_genres] - ) - genre_params = [f'%{g}%' for g in all_genres] * 2 + all_genres = merge_tags( + parse_tags(seed.get('album_genres')), + parse_tags(seed.get('artist_genres')), + ) + genre_conditions, genre_params = build_like_conditions( + all_genres, ('al.genres', 'ar.genres') + ) + if genre_conditions: cursor.execute(f""" {_track_select} WHERE {_file_filter} AND ({genre_conditions}) AND ar.name != ? - AND t.id NOT IN ({_exclude_placeholders()}) + AND t.id NOT IN ({collector.exclude_placeholders()}) ORDER BY RANDOM() LIMIT ? - """, genre_params + [artist_name] + _exclude_values() + [limit - len(collected)]) - if _collect(cursor.fetchall()): - return {'success': True, 'tracks': collected} + """, genre_params + [artist_name] + collector.exclude_values() + [collector.remaining()]) + if collector.collect(cursor.fetchall()): + return {'success': True, 'tracks': collector.tracks} # --- 3. Same mood / style (album + artist level) --- for field_name in ('mood', 'style'): - album_tags = _parse_tags(seed.get(f'album_{field_name}')) - artist_tags = _parse_tags(seed.get(f'artist_{field_name}')) - all_tags = list(dict.fromkeys(album_tags + artist_tags)) - - if all_tags: - tag_conditions = ' OR '.join( - [f'al.{field_name} LIKE ?' for _ in all_tags] + - [f'ar.{field_name} LIKE ?' for _ in all_tags] - ) - tag_params = [f'%{t}%' for t in all_tags] * 2 + all_tags = merge_tags( + parse_tags(seed.get(f'album_{field_name}')), + parse_tags(seed.get(f'artist_{field_name}')), + ) + tag_conditions, tag_params = build_like_conditions( + all_tags, (f'al.{field_name}', f'ar.{field_name}') + ) + if tag_conditions: cursor.execute(f""" {_track_select} WHERE {_file_filter} AND ({tag_conditions}) AND ar.name != ? - AND t.id NOT IN ({_exclude_placeholders()}) + AND t.id NOT IN ({collector.exclude_placeholders()}) ORDER BY RANDOM() LIMIT ? - """, tag_params + [artist_name] + _exclude_values() + [limit - len(collected)]) - if _collect(cursor.fetchall()): - return {'success': True, 'tracks': collected} + """, tag_params + [artist_name] + collector.exclude_values() + [collector.remaining()]) + if collector.collect(cursor.fetchall()): + return {'success': True, 'tracks': collector.tracks} # --- 4. Random library tracks --- - if len(collected) < limit: + if not collector.filled: cursor.execute(f""" {_track_select} - WHERE {_file_filter} AND t.id NOT IN ({_exclude_placeholders()}) + WHERE {_file_filter} AND t.id NOT IN ({collector.exclude_placeholders()}) ORDER BY RANDOM() LIMIT ? - """, _exclude_values() + [limit - len(collected)]) - _collect(cursor.fetchall()) + """, collector.exclude_values() + [collector.remaining()]) + collector.collect(cursor.fetchall()) - return {'success': True, 'tracks': collected} + return {'success': True, 'tracks': collector.tracks} except Exception as e: logger.error(f"Error getting radio tracks for track {track_id}: {e}") diff --git a/revamp_plan.md b/revamp_plan.md new file mode 100644 index 00000000..6aa952a1 --- /dev/null +++ b/revamp_plan.md @@ -0,0 +1,37 @@ +# Stream / Player / Radio Revamp — Plan + +Goal: bring the audio stream + media-player + radio system to Spotify/Apple-level polish and feature set. Target stack: **plain JS** (`webui/static/media-player.js`), not the React migration. Intended architecture direction: **multi-listener** (final call deferred to Phase 3; Phases 0–2 stay compatible either way). + +Rule for every phase: kettui standard — importable/testable logic, seam-level + differential tests, break nothing, ship one reviewable phase at a time. + +--- + +## Phase 0 — Make it provable (foundation, no user-visible change) + +- [ ] **0a. Extract radio selection logic into testable `core/radio/`.** The algorithm (tier orchestration, cap math, dedup, tag parsing, SQL-condition building) is currently tangled with `cursor.execute` inside `database/music_database.py:get_radio_tracks` (~12756) — untestable without a live DB. Pull the pure decisions into `core/radio/selection.py`; the DB method keeps SQL execution but delegates the decisions. Differential-test: same inputs → same output as today. +- [ ] **0b. Centralize frontend player state.** ~10 scattered `np*` globals in `media-player.js` → one `PlayerState` object. Seam for every later frontend phase. No behavior change. + +## Phase 1 — Polish / feel (frontend) + +- [ ] Persistent queue across refresh (localStorage first; server-side in P3) +- [ ] Drag-to-reorder queue; duration + art per queue item +- [ ] Seek tooltip (hover timestamp); smoother progress +- [ ] Crossfade via dual-`