You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/core/personalized/specs.py

122 lines
4.0 KiB

"""Per-kind specifications for the personalized-playlist subsystem.
A ``PlaylistKindSpec`` declares everything the manager needs to know
about one playlist type:
- The kind identifier (stable string used in URLs / configs / DB).
- Human-readable display name template (with optional ``{variant}``
substitution).
- Whether the kind supports / requires variants and what valid
variants look like.
- The default user-tweakable config for this kind.
- A generator callable that produces a fresh track list given
``(deps, variant, config)``.
Generators live in ``core/personalized/generators/`` (added in
later commits as each kind is migrated). For commit 1 the registry
ships empty — schema + manager land first; generators arrive
incrementally with their per-kind tests.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional
from core.personalized.types import PlaylistConfig, Track
# Generator callable signature.
# Args:
# deps: opaque object the generator may need (DB, service handle,
# config_manager). Each generator declares what it pulls.
# variant: e.g. '1980s' for time machine, 'halloween' for seasonal.
# Empty string '' for singleton kinds.
# config: PlaylistConfig with the user's per-playlist overrides
# merged onto the kind's defaults.
# Returns:
# List[Track] — the fresh snapshot to persist as the playlist's
# current track list.
GeneratorFn = Callable[[Any, str, PlaylistConfig], List[Track]]
# Variant resolver: returns the list of currently-valid variant
# identifiers for a kind that supports multiple instances. Used by
# the manager when auto-creating playlist rows for newly available
# variants (e.g. a new decade, a new season). Singletons return [''].
VariantResolver = Callable[[Any], List[str]]
@dataclass
class PlaylistKindSpec:
"""Declaration of one playlist kind.
See module docstring for the contract.
"""
kind: str
name_template: str # e.g. 'Time Machine — {variant}', 'Hidden Gems'
description: str
default_config: PlaylistConfig
generator: GeneratorFn
variant_resolver: Optional[VariantResolver] = None
requires_variant: bool = False
# Tags for UI grouping ('curated' / 'discovery' / 'time' / 'genre').
tags: List[str] = field(default_factory=list)
def display_name(self, variant: str) -> str:
"""Render the human-readable playlist name for a given variant."""
if not variant:
return self.name_template.replace('{variant}', '').strip(' —-')
return self.name_template.format(variant=variant)
class PlaylistKindRegistry:
"""Module-level registry of every kind the manager knows about.
Populated at import time as each generator module is loaded. The
manager queries the registry at runtime to dispatch refresh
requests, list available kinds for the UI, and resolve variants.
"""
def __init__(self) -> None:
self._kinds: Dict[str, PlaylistKindSpec] = {}
def register(self, spec: PlaylistKindSpec) -> None:
if spec.kind in self._kinds:
raise ValueError(f"Kind {spec.kind!r} already registered")
self._kinds[spec.kind] = spec
def get(self, kind: str) -> Optional[PlaylistKindSpec]:
return self._kinds.get(kind)
def all(self) -> List[PlaylistKindSpec]:
return list(self._kinds.values())
def kinds(self) -> List[str]:
return list(self._kinds.keys())
def reset_for_tests(self) -> None:
"""Drop every registration. Tests only — production runs
register at module import and never reset."""
self._kinds.clear()
# Module-level singleton. Generators register against this on import.
_registry = PlaylistKindRegistry()
def get_registry() -> PlaylistKindRegistry:
"""Public accessor for the module-level registry. Tests can reset
via ``get_registry().reset_for_tests()``."""
return _registry
__all__ = [
'PlaylistKindSpec',
'PlaylistKindRegistry',
'GeneratorFn',
'VariantResolver',
'get_registry',
]