Moves the typing app's lesson plan and progress from client-side YAML/localStorage into the backend, and adds a reward system that ties passing a lesson to unlocking part of the music library. Lock state is always recomputed live from curriculum x progress x the live library, never persisted separately. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
248 lines
9.0 KiB
Python
248 lines
9.0 KiB
Python
"""Turning a lesson's ``unlocks: <path>`` into which tracks/episodes are still locked.
|
|
|
|
Two shapes, one config key:
|
|
|
|
* A music album or audiobook (a "folder" section - one album, several tracks): the path
|
|
names one track, and unlocks every track up to and including it - tracks
|
|
``0..index`` inclusive, by ordinal position in ``Album.tracks``.
|
|
* A podcast show (an "episode" section - one album *per episode file*): the path names
|
|
one episode. Its show's episodes are sorted chronologically (oldest first, by the
|
|
``YYYYMMDD - Title`` filename convention ``library.sections`` documents - the same
|
|
fact ``MusicLibrary.latest_episode`` already relies on), and every episode up to and
|
|
including the target unlocks - the same "up to this one" shape, just over episodes
|
|
instead of tracks.
|
|
|
|
Lock state is never persisted: it is always recomputed fresh from (curriculum reward
|
|
config) x (current progress) x (the live library), matching how
|
|
``services/web/state.py``'s ``snapshot`` assembles player state on demand rather than
|
|
keeping a second copy in sync. An album or show no lesson ever names is simply never
|
|
locked - the reward system only ever restricts what it explicitly targets.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Literal
|
|
|
|
from musicmouse.library import MusicLibrary
|
|
from musicmouse.library.models import Album
|
|
from musicmouse.library.sections import SECTIONS
|
|
from musicmouse.tippen.curriculum import Curriculum, Lesson
|
|
from musicmouse.tippen.progress import TypingProgress
|
|
|
|
_log = logging.getLogger(__name__)
|
|
|
|
__all__ = [
|
|
"AlbumLock",
|
|
"LockState",
|
|
"ResolvedReward",
|
|
"TrackLock",
|
|
"UnlockHint",
|
|
"compute_lock_state",
|
|
"resolve_all",
|
|
"resolve_lesson_reward",
|
|
]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ResolvedReward:
|
|
"""Where a lesson's ``unlocks`` path landed in the live library."""
|
|
|
|
kind: Literal["tracks", "episode"]
|
|
#: The album a "tracks" reward lives in, or the specific episode-album an "episode"
|
|
#: reward names - either way, the concrete thing to show cover art/title for.
|
|
album_id: str
|
|
#: The show, for an "episode" reward - `None` for "tracks".
|
|
series: str | None
|
|
#: 0-based, inclusive: everything up to and including this index unlocks.
|
|
until_index: int
|
|
|
|
|
|
def _expand(raw: str, library_root: Path) -> Path:
|
|
path = Path(raw).expanduser()
|
|
if not path.is_absolute():
|
|
path = library_root / path
|
|
return path.resolve()
|
|
|
|
|
|
def _chronological_episodes(library: MusicLibrary, series: str | None) -> list[Album]:
|
|
"""A show's episode-albums, oldest first."""
|
|
candidates = [
|
|
album
|
|
for album in library.albums
|
|
if album.series == series
|
|
and (section := SECTIONS.get(album.section)) is not None
|
|
and section.album_unit == "episode"
|
|
]
|
|
return sorted(candidates, key=lambda album: album.tracks[0].path.name if album.tracks else "")
|
|
|
|
|
|
def resolve_lesson_reward(lesson: Lesson, library: MusicLibrary) -> ResolvedReward | None:
|
|
"""`None` (logged, not raised) when ``unlocks`` doesn't match anything on disk - a
|
|
moved or mistyped path must never take the whole app down, only that one reward."""
|
|
if lesson.unlocks is None:
|
|
return None
|
|
target = _expand(lesson.unlocks, library.root)
|
|
|
|
for album in library.albums:
|
|
section = SECTIONS.get(album.section)
|
|
if section is None:
|
|
continue
|
|
for index, track in enumerate(album.tracks):
|
|
if track.path.resolve() != target:
|
|
continue
|
|
if section.album_unit == "folder":
|
|
return ResolvedReward(
|
|
kind="tracks", album_id=album.id, series=None, until_index=index
|
|
)
|
|
# "episode": the reward's range is this episode's position among its show's
|
|
# episodes, chronological - not its (always 0) index within its own album.
|
|
episodes = _chronological_episodes(library, album.series)
|
|
for episode_index, episode_album in enumerate(episodes):
|
|
if episode_album.id == album.id:
|
|
return ResolvedReward(
|
|
kind="episode",
|
|
album_id=album.id,
|
|
series=album.series,
|
|
until_index=episode_index,
|
|
)
|
|
|
|
_log.warning(
|
|
"tippen: lesson %r unlocks %r, which matches no track in the library",
|
|
lesson.id,
|
|
lesson.unlocks,
|
|
)
|
|
return None
|
|
|
|
|
|
def resolve_all(curriculum: Curriculum, library: MusicLibrary) -> dict[str, ResolvedReward | None]:
|
|
return {
|
|
lesson.id: resolve_lesson_reward(lesson, library)
|
|
for lesson in curriculum.lessons
|
|
if lesson.unlocks is not None
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class UnlockHint:
|
|
lesson_id: str
|
|
lesson_title: str
|
|
world_number: int
|
|
world_title: str
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class TrackLock:
|
|
locked: bool
|
|
hint: UnlockHint | None
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class AlbumLock:
|
|
locked: bool
|
|
tracks: tuple[TrackLock, ...]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class LockState:
|
|
#: Only albums touched by at least one reward - see the module docstring.
|
|
albums: dict[str, AlbumLock] = field(default_factory=dict)
|
|
|
|
def get(self, album_id: str) -> AlbumLock | None:
|
|
return self.albums.get(album_id)
|
|
|
|
|
|
def _world_title(curriculum: Curriculum, world_number: int) -> str:
|
|
return next((world.title for world in curriculum.worlds if world.number == world_number), "")
|
|
|
|
|
|
def _hint_for(
|
|
curriculum: Curriculum, candidates: list[tuple[int, Lesson]], index: int
|
|
) -> UnlockHint | None:
|
|
"""The earliest lesson that would unlock `index`, for "mention when this unlocks"."""
|
|
eligible = [(until, lesson) for until, lesson in candidates if until >= index]
|
|
if not eligible:
|
|
return None
|
|
_until, lesson = min(eligible, key=lambda pair: pair[0])
|
|
return UnlockHint(
|
|
lesson_id=lesson.id,
|
|
lesson_title=lesson.title,
|
|
world_number=lesson.world,
|
|
world_title=_world_title(curriculum, lesson.world),
|
|
)
|
|
|
|
|
|
def compute_lock_state(
|
|
curriculum: Curriculum, library: MusicLibrary, progress: TypingProgress
|
|
) -> LockState:
|
|
"""Always recomputed fresh - see the module docstring."""
|
|
resolved = resolve_all(curriculum, library)
|
|
|
|
tracks_by_album: dict[str, list[tuple[int, Lesson]]] = {}
|
|
episodes_by_series: dict[str, list[tuple[int, Lesson]]] = {}
|
|
#: The best (highest) *earned* until_index, same two keys.
|
|
earned_album: dict[str, int] = {}
|
|
earned_series: dict[str, int] = {}
|
|
|
|
for lesson in curriculum.lessons:
|
|
reward = resolved.get(lesson.id)
|
|
if reward is None:
|
|
continue
|
|
progress_entry = progress.lessons.get(lesson.id)
|
|
is_earned = progress_entry.earned if progress_entry is not None else False
|
|
|
|
if reward.kind == "tracks":
|
|
tracks_by_album.setdefault(reward.album_id, []).append((reward.until_index, lesson))
|
|
if is_earned:
|
|
earned_album[reward.album_id] = max(
|
|
earned_album.get(reward.album_id, -1), reward.until_index
|
|
)
|
|
else:
|
|
series = reward.series
|
|
assert series is not None
|
|
episodes_by_series.setdefault(series, []).append((reward.until_index, lesson))
|
|
if is_earned:
|
|
earned_series[series] = max(earned_series.get(series, -1), reward.until_index)
|
|
|
|
albums: dict[str, AlbumLock] = {}
|
|
for album in library.albums:
|
|
section = SECTIONS.get(album.section)
|
|
if section is None:
|
|
continue
|
|
|
|
if section.album_unit == "folder":
|
|
if album.id not in tracks_by_album:
|
|
continue # never targeted by any lesson: never locked
|
|
best = earned_album.get(album.id, -1)
|
|
candidates = tracks_by_album[album.id]
|
|
track_locks = tuple(
|
|
TrackLock(
|
|
locked=index > best,
|
|
hint=_hint_for(curriculum, candidates, index) if index > best else None,
|
|
)
|
|
for index in range(len(album.tracks))
|
|
)
|
|
albums[album.id] = AlbumLock(locked=best < 0, tracks=track_locks)
|
|
else:
|
|
if album.series not in episodes_by_series:
|
|
continue
|
|
episodes = _chronological_episodes(library, album.series)
|
|
chronological_index = next(
|
|
(i for i, a in enumerate(episodes) if a.id == album.id), None
|
|
)
|
|
if chronological_index is None:
|
|
continue
|
|
best = earned_series.get(album.series, -1)
|
|
locked = chronological_index > best
|
|
hint = (
|
|
_hint_for(curriculum, episodes_by_series[album.series], chronological_index)
|
|
if locked
|
|
else None
|
|
)
|
|
episode_lock = TrackLock(locked=locked, hint=hint)
|
|
albums[album.id] = AlbumLock(locked=locked, tracks=(episode_lock,))
|
|
|
|
return LockState(albums=albums)
|