Skip to content

Commit 5c759d4

Browse files
authored
Add summary variants of the media item models (#296)
1 parent 6fdd882 commit 5c759d4

4 files changed

Lines changed: 334 additions & 1 deletion

File tree

music_assistant_models/media_items/__init__.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,39 +42,65 @@
4242
MediaItemPalette,
4343
)
4444
from .provider_mapping import ProviderMapping
45+
from .summary import (
46+
AlbumSummary,
47+
ArtistSummary,
48+
AudiobookSummary,
49+
GenreSummary,
50+
ItemMappingSummary,
51+
MediaItemMetadataSummary,
52+
MediaItemSummaryType,
53+
PlaylistSummary,
54+
PodcastSummary,
55+
RadioSummary,
56+
SummaryDialect,
57+
TrackSummary,
58+
)
4559

4660
__all__ = [
4761
"Album",
62+
"AlbumSummary",
4863
"Artist",
64+
"ArtistSummary",
4965
"AudioFormat",
5066
"AudioMetadata",
5167
"AudioSource",
5268
"Audiobook",
69+
"AudiobookSummary",
5370
"BrowseFolder",
5471
"Genre",
72+
"GenreSummary",
5573
"ItemMapping",
74+
"ItemMappingSummary",
5675
"MediaItem",
5776
"MediaItemChapter",
5877
"MediaItemCollection",
5978
"MediaItemImage",
6079
"MediaItemLink",
6180
"MediaItemMetadata",
81+
"MediaItemMetadataSummary",
6282
"MediaItemPalette",
83+
"MediaItemSummaryType",
6384
"MediaItemType",
6485
"Metadata",
6586
"MetadataProvider",
6687
"MetadataProviderStatus",
6788
"MetadataProviderType",
6889
"PlayableMediaItemType",
6990
"Playlist",
91+
"PlaylistSummary",
7092
"PlaylistTrack",
7193
"Podcast",
7294
"PodcastEpisode",
95+
"PodcastSummary",
7396
"ProviderMapping",
7497
"Radio",
98+
"RadioSummary",
7599
"RecommendationFolder",
76100
"SoundEffect",
101+
"SummaryDialect",
77102
"Track",
103+
"TrackSummary",
78104
"UniqueList",
79105
]
80106

music_assistant_models/media_items/media_item.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,7 @@ def __post_serialize__(self, d: dict[str, Any]) -> dict[str, Any]:
407407
"""Adjust dict object after it has been serialized."""
408408
self._resolve_translation(d)
409409
# TEMP 2025-03-14: convert None duration to fake number for backwards compatibility
410-
d["duration"] = 0 if d["duration"] is None else d["duration"]
410+
d["duration"] = d.get("duration") or 0
411411
return d
412412

413413

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"""
2+
Summary (slim) variants of the media item models, used for listings.
3+
4+
These are drop-in subclasses of the regular media item models — same wire shape, same
5+
``media_type`` discriminator, deserializable with the regular classes — but carry only the
6+
fields a list view renders: identity, name(s), artist/album mappings, a single (thumb) image
7+
and a handful of per-type scalars. All unset (None) fields are omitted from the serialized
8+
output, provider mappings are empty and ``available`` is a plain (pre-computed) field instead
9+
of a derived property.
10+
11+
The Music Assistant server returns these from the ``library_items`` API commands when the
12+
``summary`` flag is set; full items (with provider mappings, complete metadata, etc.) remain
13+
available through the regular ``get`` commands.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from dataclasses import dataclass, field
19+
20+
from mashumaro.config import BaseConfig
21+
from mashumaro.dialect import Dialect
22+
23+
from music_assistant_models.unique_list import UniqueList
24+
25+
from .media_item import (
26+
Album,
27+
Artist,
28+
Audiobook,
29+
Genre,
30+
ItemMapping,
31+
Playlist,
32+
Podcast,
33+
Radio,
34+
Track,
35+
)
36+
from .metadata import MediaItemMetadata
37+
from .provider_mapping import ProviderMapping
38+
39+
40+
class _OmitNoneDialect(Dialect):
41+
omit_none = True
42+
43+
44+
@dataclass(kw_only=True, eq=False)
45+
class MediaItemMetadataSummary(MediaItemMetadata):
46+
"""Metadata of a summary item, with None-valued fields omitted from serialized output."""
47+
48+
class Config(BaseConfig):
49+
"""Mashumaro serialization config."""
50+
51+
dialect = _OmitNoneDialect
52+
53+
54+
@dataclass(kw_only=True, eq=False)
55+
class ItemMappingSummary(ItemMapping):
56+
"""ItemMapping of a summary item, with None-valued fields omitted from serialized output."""
57+
58+
class Config(BaseConfig):
59+
"""Mashumaro serialization config."""
60+
61+
dialect = _OmitNoneDialect
62+
63+
64+
class SummaryDialect(Dialect):
65+
"""
66+
Serialization dialect for summary items.
67+
68+
Omits None-valued fields and (de)serializes nested metadata as its summary variant.
69+
"""
70+
71+
omit_none = True
72+
serialization_strategy = { # noqa: RUF012
73+
MediaItemMetadata: {
74+
"serialize": lambda v: v.to_dict(),
75+
"deserialize": lambda v: MediaItemMetadataSummary.from_dict(v),
76+
},
77+
}
78+
79+
80+
@dataclass(kw_only=True, eq=False)
81+
class _SummaryBase:
82+
"""
83+
Mixin that turns a regular media item subclass into its summary variant.
84+
85+
Serialization omits None-valued fields, ``available`` is a plain field (filled by the
86+
server from the — not serialized — provider mappings) and metadata is reduced to the
87+
slim summary variant.
88+
"""
89+
90+
class Config(BaseConfig):
91+
"""Mashumaro serialization config."""
92+
93+
dialect = SummaryDialect
94+
95+
provider_mappings: set[ProviderMapping] = field(default_factory=set)
96+
available: bool = True
97+
metadata: MediaItemMetadata = field(default_factory=MediaItemMetadataSummary)
98+
99+
100+
@dataclass(kw_only=True, eq=False)
101+
class ArtistSummary(_SummaryBase, Artist):
102+
"""Summary variant of Artist, used for listings."""
103+
104+
105+
@dataclass(kw_only=True, eq=False)
106+
class AlbumSummary(_SummaryBase, Album):
107+
"""Summary variant of Album, used for listings."""
108+
109+
artists: UniqueList[ItemMappingSummary] = field( # type: ignore[assignment]
110+
default_factory=UniqueList
111+
)
112+
113+
114+
@dataclass(kw_only=True, eq=False)
115+
class TrackSummary(_SummaryBase, Track):
116+
"""Summary variant of Track, used for listings."""
117+
118+
artists: UniqueList[ItemMappingSummary] = field( # type: ignore[assignment]
119+
default_factory=UniqueList
120+
)
121+
album: ItemMappingSummary | None = None
122+
123+
124+
@dataclass(kw_only=True, eq=False)
125+
class PlaylistSummary(_SummaryBase, Playlist):
126+
"""Summary variant of Playlist, used for listings."""
127+
128+
129+
@dataclass(kw_only=True, eq=False)
130+
class RadioSummary(_SummaryBase, Radio):
131+
"""Summary variant of Radio, used for listings."""
132+
133+
134+
@dataclass(kw_only=True, eq=False)
135+
class AudiobookSummary(_SummaryBase, Audiobook):
136+
"""Summary variant of Audiobook, used for listings."""
137+
138+
139+
@dataclass(kw_only=True, eq=False)
140+
class PodcastSummary(_SummaryBase, Podcast):
141+
"""Summary variant of Podcast, used for listings."""
142+
143+
144+
@dataclass(kw_only=True, eq=False)
145+
class GenreSummary(_SummaryBase, Genre):
146+
"""Summary variant of Genre, used for listings."""
147+
148+
149+
MediaItemSummaryType = (
150+
ArtistSummary
151+
| AlbumSummary
152+
| TrackSummary
153+
| PlaylistSummary
154+
| RadioSummary
155+
| AudiobookSummary
156+
| PodcastSummary
157+
| GenreSummary
158+
)

tests/test_summary_items.py

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""Tests for the summary (slim) media item models."""
2+
3+
from music_assistant_models.enums import ImageType, MediaType
4+
from music_assistant_models.media_items import (
5+
AlbumSummary,
6+
ArtistSummary,
7+
AudiobookSummary,
8+
GenreSummary,
9+
ItemMappingSummary,
10+
MediaItemImage,
11+
MediaItemMetadataSummary,
12+
Playlist,
13+
PlaylistSummary,
14+
PodcastSummary,
15+
Radio,
16+
RadioSummary,
17+
Track,
18+
TrackSummary,
19+
)
20+
from music_assistant_models.media_items.metadata import IMAGE_PROXY_ID_RESOLVER
21+
from music_assistant_models.unique_list import UniqueList
22+
23+
24+
def _track_summary() -> TrackSummary:
25+
return TrackSummary(
26+
item_id="123",
27+
provider="library",
28+
name="Test Track",
29+
duration=185,
30+
favorite=True,
31+
artists=UniqueList(
32+
[
33+
ItemMappingSummary(
34+
media_type=MediaType.ARTIST,
35+
item_id="1",
36+
provider="library",
37+
name="Test Artist",
38+
)
39+
]
40+
),
41+
album=ItemMappingSummary(
42+
media_type=MediaType.ALBUM,
43+
item_id="2",
44+
provider="library",
45+
name="Test Album",
46+
year=2001,
47+
),
48+
metadata=MediaItemMetadataSummary(
49+
images=UniqueList(
50+
[MediaItemImage(type=ImageType.THUMB, path="/cover.jpg", provider="library")]
51+
),
52+
explicit=True,
53+
),
54+
)
55+
56+
57+
def test_summary_serialization_omits_none_fields() -> None:
58+
"""Serialized summary items must not contain any None-valued keys, at any level."""
59+
d = _track_summary().to_dict()
60+
assert not [k for k, v in d.items() if v is None]
61+
assert not [k for k, v in d["metadata"].items() if v is None]
62+
assert not [k for k, v in d["artists"][0].items() if v is None]
63+
assert not [k for k, v in d["album"].items() if v is None]
64+
65+
66+
def test_summary_keeps_client_facing_fields() -> None:
67+
"""Fields clients switch on must survive serialization even when default-valued."""
68+
d = _track_summary().to_dict()
69+
assert d["media_type"] == "track"
70+
assert d["available"] is True
71+
assert d["provider_mappings"] == []
72+
assert d["metadata"] == {
73+
"images": [
74+
{
75+
"type": "thumb",
76+
"path": "/cover.jpg",
77+
"provider": "library",
78+
"remotely_accessible": False,
79+
"proxy_id": None,
80+
}
81+
],
82+
"explicit": True,
83+
}
84+
85+
86+
def test_summary_roundtrips_through_regular_model() -> None:
87+
"""A serialized summary item must deserialize cleanly with the regular class."""
88+
summary = _track_summary()
89+
track = Track.from_dict(summary.to_dict())
90+
assert track.name == summary.name
91+
assert track.duration == summary.duration
92+
assert track.favorite is True
93+
assert track.artists[0].name == "Test Artist"
94+
assert track.album is not None
95+
assert track.album.name == "Test Album"
96+
assert track.provider_mappings == set()
97+
98+
99+
def test_summary_is_instance_of_regular_model() -> None:
100+
"""Summary items are subtypes of their regular counterparts."""
101+
assert isinstance(_track_summary(), Track)
102+
assert isinstance(
103+
PlaylistSummary(item_id="1", provider="library", name="pl"),
104+
Playlist,
105+
)
106+
107+
108+
def test_summary_equality_matches_regular_semantics() -> None:
109+
"""Summary items compare by identity (uri/external ids), like regular items."""
110+
one = TrackSummary(item_id="123", provider="library", name="A")
111+
other = TrackSummary(item_id="123", provider="library", name="B")
112+
assert one == other
113+
assert hash(one) == hash(other)
114+
115+
116+
def test_summary_image_proxy_id_injection() -> None:
117+
"""The image proxy_id resolver hook must fire for images nested in summary items."""
118+
token = IMAGE_PROXY_ID_RESOLVER.set(lambda provider, _path: f"{provider}-proxy")
119+
try:
120+
d = _track_summary().to_dict()
121+
finally:
122+
IMAGE_PROXY_ID_RESOLVER.reset(token)
123+
assert d["metadata"]["images"][0]["proxy_id"] == "library-proxy"
124+
125+
126+
def test_radio_summary_duration_backcompat() -> None:
127+
"""Radio's None->0 duration shim must not break on the omitted duration key."""
128+
d = RadioSummary(item_id="1", provider="library", name="radio").to_dict()
129+
assert d["duration"] == 0
130+
# regular Radio keeps the same behavior
131+
radio = Radio(item_id="1", provider="tunein", name="radio", provider_mappings=set())
132+
assert radio.to_dict()["duration"] == 0
133+
134+
135+
def test_per_type_summaries_serialize_sparse() -> None:
136+
"""Every summary type serializes without None-valued keys."""
137+
items = [
138+
ArtistSummary(item_id="1", provider="library", name="artist"),
139+
AlbumSummary(item_id="1", provider="library", name="album", year=2001),
140+
PlaylistSummary(item_id="1", provider="library", name="playlist", owner="MA"),
141+
RadioSummary(item_id="1", provider="library", name="radio"),
142+
AudiobookSummary(item_id="1", provider="library", name="book", publisher="pub"),
143+
PodcastSummary(item_id="1", provider="library", name="podcast"),
144+
GenreSummary(item_id="1", provider="library", name="genre"),
145+
]
146+
for item in items:
147+
d = item.to_dict()
148+
assert not [k for k, v in d.items() if v is None], type(item).__name__
149+
assert d["media_type"] == item.media_type.value

0 commit comments

Comments
 (0)