Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 36 additions & 12 deletions website/thaliedje/api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,18 +120,42 @@ class PlayerTrackSearchAPIView(APIView):
"properties": {
"query": {"type": "string", "example": "string"},
"results": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string", "example": "string"},
"artists": {
"type": "array",
"items": {"type": "string", "example": "string"},
},
"id": {
"type": "string",
"example": "6tcCTgpI1JWsgReB9ttSUD",
"type": "object",
"description": (
"Spotify ``search`` response, keyed by category "
"(``tracks``, ``albums``, ``playlists``). Each "
"category is a list of result dicts."
),
"additionalProperties": {
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {"type": "string"},
"name": {"type": "string"},
"artists": {
"type": "array",
"items": {"type": "string"},
},
"id": {
"type": "string",
"example": "6tcCTgpI1JWsgReB9ttSUD",
},
"uri": {"type": "string"},
"image": {"type": "string", "nullable": True},
"album": {"type": "string", "nullable": True},
"album_release_date": {
"type": "string",
"nullable": True,
"description": (
"Spotify's release-date string "
"(YYYY, YYYY-MM, or YYYY-MM-DD)."
),
},
"duration_ms": {
"type": "integer",
"nullable": True,
},
},
},
},
Expand Down
68 changes: 62 additions & 6 deletions website/thaliedje/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ def search_tracks(player: Player, query: str, maximum: int = 5) -> list[dict]:
- ``MarietjePlayer`` returns ``None`` (no search support).

Normalise both to the flat list of track dicts the MCP tool promises.
The shape mirrors what the REST API returns so the MCP surface is a
strict subset of the REST surface (no MCP-only data leaks). Per-track
fields: ``id`` (Spotify track ID), ``name``, ``artists``, ``album``,
``album_release_date``, ``duration_ms``, ``image`` (album cover URL,
or None).
"""
maximum = max(1, min(int(maximum), 25))
raw = player.search(query, maximum=maximum, query_type="track")
Expand All @@ -51,6 +56,10 @@ def search_tracks(player: Player, query: str, maximum: int = 5) -> list[dict]:
"id": r.get("id"),
"name": r.get("name"),
"artists": r.get("artists", []),
"album": r.get("album"),
"album_release_date": r.get("album_release_date"),
"duration_ms": r.get("duration_ms"),
"image": r.get("image"),
}
for r in tracks
if isinstance(r, dict)
Expand Down Expand Up @@ -110,11 +119,51 @@ def get_player_state(self, venue_slug: str) -> dict:
}

def search_tracks(self, venue_slug: str, query: str, maximum: int = 5) -> dict:
"""Search the music catalog for tracks via a venue's player.

``venue_slug`` selects the player (each venue has its own backend —
Spotify or Marietje). ``query`` is a free-text search. ``maximum``
caps the number of results (default 5, hard ceiling 25).
"""Search the Spotify catalog for tracks playable on a venue's player.

The catalog is **Spotify's** — results are real Spotify tracks and
the ``id`` field on each result is a **Spotify track ID** (an
opaque base62 string, e.g. ``"7D5vAulNfrQV6xEwzgH0OF"``). Pass it
through verbatim to ``request_song``; do not parse, hash, or
invent it.

``venue_slug`` selects the player. Search currently works only
for Spotify-backed venues; Marietje-backed venues return an
empty list (Marietje does not expose a catalog search). The
agent should not interpret an empty result on a Marietje venue
as "no songs match" — say so explicitly to the user.
``query`` is a free-text search — Spotify will accept
``track:name artist:artistname`` for more precise matching if you
already know both. ``maximum`` caps the number of results
(default 5, hard ceiling 25).

**Result order is Spotify's relevance/popularity ranking** — the
first entry is the best match Spotify could find, the last is the
weakest. A few important consequences:

- Lower-position entries may not be exact name matches; Spotify
sometimes surfaces other songs by the same artist. Trust
``name`` + ``artists`` for relevance, not just position.
- The same song often exists under multiple Spotify IDs (album
version, single, live recording, remaster, regional release).
Use ``album``, ``album_release_date`` and ``duration_ms`` to
tell them apart when ``name`` and ``artists`` match. If the
user said "the original" prefer the earliest
``album_release_date``; if they said "the album version"
prefer the entry whose ``album`` is the studio album rather
than a compilation or single.
- ``image`` is the album cover URL — surface it to the user if
your client renders inline images so they can confirm by sight.
You can also point the user at
``https://open.spotify.com/track/{id}`` for a visual confirm.
- When in doubt with multiple plausible matches, ask the user
rather than guessing — every result here is genuinely a
distinct Spotify recording.

Per-track fields: ``id`` (Spotify track ID), ``name``,
``artists`` (list of names), ``album`` (album name),
``album_release_date``, ``duration_ms``, ``image`` (album cover
URL or ``null``).
"""
player = get_player_for_venue(venue_slug)
if player is None:
Expand All @@ -132,8 +181,15 @@ def search_tracks(self, venue_slug: str, query: str, maximum: int = 5) -> dict:
def request_song(self, venue_slug: str, track_id: str) -> dict:
"""Request a song to be added to a venue's player queue.

``track_id`` is the ``id`` returned by ``search_tracks``.
``track_id`` is a **Spotify track ID** (the opaque ``id`` field
returned by ``search_tracks``) — pass it through verbatim.
Do not pass a URI (``spotify:track:...``), URL, ISRC, or made-up
identifier; only the bare ID Spotify returned in the search.
Requires the ``thaliedje:request`` OAuth2 scope.

Confirm with the user which specific result they want before
calling this — two search hits with the same ``name`` and
``artists`` can be entirely different recordings.
"""
scope_error = require_scope(self.request, "thaliedje:request")
if scope_error:
Expand Down
42 changes: 31 additions & 11 deletions website/thaliedje/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -874,6 +874,28 @@ def get_artists_for_spotify_track(spotify_track):
except KeyError:
return []

def _project_track(self, raw):
"""Trim a raw Spotify track payload to the shape ``search`` returns.

Covers the case Spotify occasionally hands back a track with no
album art (``album.images`` is ``[]``) — fall back to ``None``
instead of raising. Existing callers already render ``image``
conditionally.
"""
album = raw.get("album") or {}
images = album.get("images") or []
return {
"type": raw["type"],
"name": raw["name"],
"artists": self.get_artists_for_spotify_track(raw),
"id": raw["id"],
"uri": raw["uri"],
"image": images[0]["url"] if images else None,
"album": album.get("name"),
"album_release_date": album.get("release_date"),
"duration_ms": raw.get("duration_ms"),
}

@volume.setter
def volume(self, volume_percent):
"""Set the volume of the playback device of a Player."""
Expand Down Expand Up @@ -924,8 +946,14 @@ def search(self, query, maximum=5, query_type="track"):
:param query: the search query
:param maximum: the maximum number of results to search for
:param query_type: the type of the spotify instance to search
:return: a list of tracks [{"name": the trackname, "artists": [a list of artist names],
"id": the Spotify track id}]
:return: a list of tracks. Each track dict contains ``type``,
``name``, ``artists``, ``id`` (the Spotify track id), ``uri``,
``image`` (album cover URL or None when Spotify omits it),
``album`` (album name), ``album_release_date`` (Spotify's
``YYYY``/``YYYY-MM``/``YYYY-MM-DD`` string), and ``duration_ms``.
The album/duration fields exist so non-visual consumers (MCP
agents, headless tooling) can tell two same-name results apart
when the website normally relies on the album cover.
"""
results = self.do_spotify_request(
self.spotify.search, q=query, limit=maximum, type=query_type
Expand All @@ -944,15 +972,7 @@ def search(self, query, maximum=5, query_type="track"):
trimmed_result_for_key, key=lambda x: -x["popularity"]
)
trimmed_result_for_key = [
{
"type": x["type"],
"name": x["name"],
"artists": self.get_artists_for_spotify_track(x),
"id": x["id"],
"uri": x["uri"],
"image": x["album"]["images"][0]["url"],
}
for x in trimmed_result_for_key
self._project_track(x) for x in trimmed_result_for_key
]
trimmed_result["tracks"] = trimmed_result_for_key
elif key == "albums":
Expand Down
15 changes: 13 additions & 2 deletions website/thaliedje/templates/thaliedje/search.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,18 @@ <h5 class="text-uppercase">Tracks</h5>
<img height="50" v-if="track.image !== null" :src="track.image" :alt="'Album art for ' + track.name"/>
</div>
<div class="flex-equal-width d-flex flex-column">
<div>
${ track.name }$
<div class="d-flex justify-content-between">
<span>${ track.name }$</span>
<span v-if="track.duration_ms" class="text-muted small ms-2">${ formatDuration(track.duration_ms) }$</span>
</div>
<div style="font-style: italic;">
<template v-for="(artist, art_index) in track.artists">
${ artist }$<template v-if="art_index !== track.artists.length - 1">, </template>
</template>
</div>
<div v-if="track.album" class="text-muted small text-truncate">
${ track.album }$<template v-if="track.album_release_date"> (${ track.album_release_date.slice(0, 4) }$)</template>
</div>
</div>
<button class="btn btn-primary" style="height: 40px" v-on:click="add_to_queue(track.id)"><i class="fa-solid fa-plus"></i></button>
</div>
Expand Down Expand Up @@ -121,6 +125,13 @@ <h5 class="text-uppercase">Albums</h5>
}
},
methods: {
formatDuration(ms) {
if (typeof ms !== 'number') return '';
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = String(totalSeconds % 60).padStart(2, '0');
return `${minutes}:${seconds}`;
},
tryUrl() {
let urlToTry = null;
try {
Expand Down
42 changes: 37 additions & 5 deletions website/thaliedje/tests/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,34 @@ def _player(self, raw):
player.search.return_value = raw
return player

def _expected(self, **fields):
"""Build the full per-track shape ``search_tracks`` returns, with
every disambiguator defaulting to None — the test only has to
specify the fields that vary.
"""
return {
"id": None,
"name": None,
"artists": [],
"album": None,
"album_release_date": None,
"duration_ms": None,
"image": None,
**fields,
}

def test_unwraps_spotify_dict_shape(self):
raw = {
"tracks": [
{"id": "1", "name": "Song A", "artists": ["Artist"]},
{
"id": "1",
"name": "Song A",
"artists": ["Artist"],
"album": "Album X",
"album_release_date": "2005-01-01",
"duration_ms": 222000,
"image": "https://i.scdn.co/x.jpg",
},
{"id": "2", "name": "Song B", "artists": ["Artist 2"]},
],
# other categories should be ignored by ``search_tracks``
Expand All @@ -119,16 +143,24 @@ def test_unwraps_spotify_dict_shape(self):
self.assertEqual(
result,
[
{"id": "1", "name": "Song A", "artists": ["Artist"]},
{"id": "2", "name": "Song B", "artists": ["Artist 2"]},
self._expected(
id="1",
name="Song A",
artists=["Artist"],
album="Album X",
album_release_date="2005-01-01",
duration_ms=222000,
image="https://i.scdn.co/x.jpg",
),
self._expected(id="2", name="Song B", artists=["Artist 2"]),
],
)

def test_passes_through_flat_list_shape(self):
raw = [{"id": "1", "name": "Song A", "artists": ["Artist"]}]
self.assertEqual(
search_tracks_service(self._player(raw), "anything"),
[{"id": "1", "name": "Song A", "artists": ["Artist"]}],
[self._expected(id="1", name="Song A", artists=["Artist"])],
)

def test_none_return_is_empty_list(self):
Expand All @@ -143,4 +175,4 @@ def test_missing_tracks_key_is_empty_list(self):
def test_skips_non_dict_entries(self):
raw = {"tracks": [{"id": "1"}, "stray-string", None]}
result = search_tracks_service(self._player(raw), "x")
self.assertEqual(result, [{"id": "1", "name": None, "artists": []}])
self.assertEqual(result, [self._expected(id="1")])
Loading