/
/
/
1"""
2Tests for applying per-user resume info to a provider's podcast episode listing.
3
4Resume info for a listing is read from the playlog table in one batched query, no matter
5how many episodes the podcast has. The integration tests use the ``mass`` fixture from
6``tests/conftest.py``, which creates a full MusicAssistant instance with a real SQLite
7database in a temporary directory.
8"""
9
10from __future__ import annotations
11
12from collections.abc import AsyncGenerator, Callable
13from typing import TYPE_CHECKING, Any
14from unittest.mock import AsyncMock, MagicMock, patch
15
16import pytest
17from music_assistant_models.enums import MediaType
18from music_assistant_models.media_items import Podcast, PodcastEpisode, ProviderMapping
19
20from music_assistant.constants import DB_TABLE_PLAYLOG
21from music_assistant.mass import MusicAssistant
22from music_assistant.models.music_provider import MusicProvider
23
24if TYPE_CHECKING:
25 from music_assistant_models.auth import User
26
27PROVIDER_ID = "test_podcast_prov"
28PODCAST_ID = "show-001"
29
30
31class _StubPodcastProvider(MusicProvider):
32 """Minimal music provider yielding a prepared list of podcast episodes."""
33
34 def __init__(self, episodes: list[PodcastEpisode]) -> None:
35 """
36 Initialize the stub provider.
37
38 :param episodes: The episodes to yield from get_podcast_episodes.
39 """
40 self.episodes = episodes
41 self.config = MagicMock()
42 self.config.instance_id = PROVIDER_ID
43 self.manifest = MagicMock()
44 self.manifest.domain = PROVIDER_ID
45 self.logger = MagicMock()
46
47 async def get_podcast_episodes(self, prov_podcast_id: str) -> AsyncGenerator[PodcastEpisode]:
48 """Yield the prepared episodes for the given podcast id."""
49 for episode in self.episodes:
50 yield episode
51
52 async def get_podcast_episode(self, prov_episode_id: str) -> PodcastEpisode:
53 """Return the prepared episode matching the given item id."""
54 return next(x for x in self.episodes if x.item_id == prov_episode_id)
55
56
57def _episode(index: int, **kwargs: Any) -> PodcastEpisode:
58 """
59 Build a provider podcast episode.
60
61 :param index: Episode number, also used to derive its item id.
62 :param kwargs: Extra PodcastEpisode attributes (e.g. resume info).
63 """
64 item_id = f"ep-{index:03d}"
65 return PodcastEpisode(
66 item_id=item_id,
67 provider=PROVIDER_ID,
68 name=f"Episode {index}",
69 provider_mappings={
70 ProviderMapping(
71 item_id=item_id,
72 provider_domain=PROVIDER_ID,
73 provider_instance=PROVIDER_ID,
74 )
75 },
76 position=index,
77 podcast=Podcast(
78 item_id=PODCAST_ID,
79 provider=PROVIDER_ID,
80 name="My Podcast",
81 provider_mappings={
82 ProviderMapping(
83 item_id=PODCAST_ID,
84 provider_domain=PROVIDER_ID,
85 provider_instance=PROVIDER_ID,
86 )
87 },
88 ),
89 **kwargs,
90 )
91
92
93async def _add_playlog_row(
94 mass: MusicAssistant,
95 item_id: str,
96 userid: str,
97 seconds_played: int,
98 fully_played: bool,
99 timestamp: int = 1000,
100) -> None:
101 """Seed one playlog row for a podcast episode."""
102 await mass.music.database.insert(
103 DB_TABLE_PLAYLOG,
104 {
105 "item_id": item_id,
106 "provider": PROVIDER_ID,
107 "media_type": MediaType.PODCAST_EPISODE.value,
108 "name": item_id,
109 "userid": userid,
110 "seconds_played": seconds_played,
111 "fully_played": fully_played,
112 "timestamp": timestamp,
113 },
114 allow_replace=True,
115 )
116
117
118@pytest.fixture(name="count_playlog_queries")
119def count_playlog_queries_fixture(
120 mass: MusicAssistant, monkeypatch: pytest.MonkeyPatch
121) -> Callable[[], int]:
122 """Count every database read of the playlog table, single row or batched."""
123 calls = 0
124
125 def _counted(name: str) -> Callable[..., Any]:
126 original = getattr(mass.music.database, name)
127
128 async def _wrapper(table: str, *args: Any, **kwargs: Any) -> Any:
129 nonlocal calls
130 if table == DB_TABLE_PLAYLOG:
131 calls += 1
132 return await original(table, *args, **kwargs)
133
134 return _wrapper
135
136 for name in ("get_row", "get_rows"):
137 monkeypatch.setattr(mass.music.database, name, _counted(name))
138 return lambda: calls
139
140
141async def _list_episodes(
142 mass: MusicAssistant,
143 episodes: list[PodcastEpisode],
144 user: User | None = None,
145) -> list[PodcastEpisode]:
146 """
147 Run the controller's provider listing for a stub provider.
148
149 :param mass: The MusicAssistant instance to run against.
150 :param episodes: The episodes the stub provider yields.
151 :param user: The session user the request is made for, if any.
152 """
153 provider = _StubPodcastProvider(episodes)
154 with (
155 patch.object(mass, "get_provider", return_value=provider),
156 patch(
157 "music_assistant.controllers.music.media.podcasts.get_current_user",
158 return_value=user,
159 ),
160 ):
161 return [
162 x
163 async for x in mass.music.podcasts._get_provider_podcast_episodes(
164 PODCAST_ID, PROVIDER_ID
165 )
166 ]
167
168
169async def _get_episode(
170 mass: MusicAssistant,
171 episodes: list[PodcastEpisode],
172 item_id: str,
173 user: User | None = None,
174) -> PodcastEpisode:
175 """
176 Run the controller's single-episode lookup for a stub provider.
177
178 :param mass: The MusicAssistant instance to run against.
179 :param episodes: The episodes the stub provider can resolve by item id.
180 :param item_id: The item id of the episode to fetch.
181 :param user: The session user the request is made for, if any.
182 """
183 provider = _StubPodcastProvider(episodes)
184 with (
185 patch.object(mass, "get_provider", return_value=provider),
186 patch(
187 "music_assistant.controllers.music.media.podcasts.get_current_user",
188 return_value=user,
189 ),
190 ):
191 return await mass.music.podcasts.episode(item_id, PROVIDER_ID)
192
193
194async def _explain_resume_query(mass: MusicAssistant, user: User | None) -> list[str]:
195 """
196 Return the query plan rows for the batched resume query as the controller emits it.
197
198 :param mass: The MusicAssistant instance to run against.
199 :param user: The session user the request is made for, if any.
200 """
201 database = mass.music.database
202 # drop the planner statistics so SQLite assumes its default (large) table size instead of
203 # the handful of rows seeded here, where scanning the table really is the cheaper plan
204 await database.execute("ANALYZE")
205 await database.execute("DELETE FROM sqlite_stat1")
206 await database.execute("ANALYZE sqlite_master")
207
208 captured: dict[str, Any] = {}
209 original = database._db.execute_fetchall
210
211 async def _spy(sql: str, params: Any = None) -> Any:
212 if DB_TABLE_PLAYLOG in sql and sql.startswith("SELECT"):
213 captured["sql"], captured["params"] = sql, params
214 return await original(sql, params)
215
216 with patch.object(database._db, "execute_fetchall", _spy):
217 await _list_episodes(mass, [_episode(1)], user=user)
218
219 # the emitted query is explained rather than a copy of it, so the plan cannot drift
220 # away from the query this test claims to cover
221 assert "sql" in captured, "no playlog select was emitted"
222 plan = await database.get_rows_from_query(
223 f"EXPLAIN QUERY PLAN {captured['sql']}", captured["params"], limit=0
224 )
225 return [row["detail"] for row in plan]
226
227
228async def test_resume_info_is_scoped_to_the_requesting_user(mass: MusicAssistant) -> None:
229 """The requesting user's progress is applied; another user's progress is ignored."""
230 user = await mass.webserver.auth.create_user("podcastresume")
231 other_user = await mass.webserver.auth.create_user("podcastresumeother")
232 await _add_playlog_row(mass, "ep-001", user.user_id, seconds_played=90, fully_played=False)
233 await _add_playlog_row(mass, "ep-002", other_user.user_id, seconds_played=30, fully_played=True)
234
235 result = await _list_episodes(mass, [_episode(1), _episode(2)], user=user)
236
237 assert result[0].resume_position_ms == 90000
238 assert result[0].fully_played is False
239 assert result[1].resume_position_ms is None
240 assert result[1].fully_played is None
241
242
243async def test_native_resume_info_is_not_overwritten(mass: MusicAssistant) -> None:
244 """An episode that arrives with resume info from its provider is left untouched."""
245 user = await mass.webserver.auth.create_user("podcastnative")
246 await _add_playlog_row(mass, "ep-001", user.user_id, seconds_played=90, fully_played=True)
247
248 episode = _episode(1, fully_played=False, resume_position_ms=5000)
249 result = await _list_episodes(mass, [episode], user=user)
250
251 assert result[0].fully_played is False
252 assert result[0].resume_position_ms == 5000
253
254
255async def test_listing_uses_a_single_playlog_query(
256 mass: MusicAssistant, count_playlog_queries: Callable[[], int]
257) -> None:
258 """Resume info for an entire listing costs one query, not one query per episode."""
259 user = await mass.webserver.auth.create_user("podcastbatched")
260 await _add_playlog_row(mass, "ep-005", user.user_id, seconds_played=120, fully_played=False)
261
262 episodes = [_episode(index) for index in range(1, 26)]
263 result = await _list_episodes(mass, episodes, user=user)
264
265 assert count_playlog_queries() == 1
266 assert len(result) == 25
267 assert result[4].resume_position_ms == 120000
268
269
270async def test_no_playlog_query_when_provider_supplies_resume_info(
271 mass: MusicAssistant, count_playlog_queries: Callable[[], int]
272) -> None:
273 """A provider that reports resume info natively triggers no playlog query at all."""
274 user = await mass.webserver.auth.create_user("podcastnativeonly")
275 episodes = [_episode(index, fully_played=False, resume_position_ms=1000) for index in (1, 2)]
276
277 result = await _list_episodes(mass, episodes, user=user)
278
279 assert count_playlog_queries() == 0
280 assert all(x.resume_position_ms == 1000 for x in result)
281
282
283async def test_resume_info_is_not_capped_by_the_default_row_limit(mass: MusicAssistant) -> None:
284 """
285 Resume info survives a playlog holding more rows than the default query limit.
286
287 get_rows caps at 500 rows unless limit=0 is passed, and the batched query sorts by
288 ascending timestamp - so the cap keeps the *oldest* rows and drops the newest, which are
289 exactly the episodes a listener is part way through. The playlog keeps 90 days of history
290 across every podcast of a provider, so busy households cross 500 rows.
291 """
292 user = await mass.webserver.auth.create_user("podcastrowlimit")
293 row_count = 501
294 for index in range(1, row_count + 1):
295 # ascending timestamps: the newest row sorts last and is the first one a cap drops
296 await _add_playlog_row(
297 mass,
298 f"ep-{index:03d}",
299 user.user_id,
300 seconds_played=index,
301 fully_played=False,
302 timestamp=1000 + index,
303 )
304
305 # the oldest row doubles as a control: it survives either way, so a failure below
306 # points at the cap rather than at resume lookup being broken altogether
307 result = await _list_episodes(mass, [_episode(1), _episode(row_count)], user=user)
308
309 assert result[0].resume_position_ms == 1000
310 assert result[1].resume_position_ms == row_count * 1000
311
312
313async def test_resume_query_uses_the_provider_media_type_index(mass: MusicAssistant) -> None:
314 """
315 The batched resume query is served by an index instead of scanning the playlog.
316
317 It filters on provider/media_type/userid, which the item_id-first unique index cannot
318 serve, so without a dedicated index SQLite falls back to the userid index and reads every
319 row that user ever played, of every media type. The playlog holds 90 days of history for
320 the whole household, so that cost grows with listening activity rather than podcast size.
321 """
322 user = await mass.webserver.auth.create_user("podcastindexplan")
323 await _add_playlog_row(mass, "ep-001", user.user_id, seconds_played=10, fully_played=False)
324
325 details = await _explain_resume_query(mass, user)
326
327 assert any(f"USING INDEX {DB_TABLE_PLAYLOG}_provider_media_type_idx" in x for x in details), (
328 details
329 )
330 # with userid in the filter the equality prefix reaches timestamp, so the index satisfies
331 # the ORDER BY on its own. That part does not survive userid dropping out of the filter -
332 # the test below covers what still holds there
333 assert not any("TEMP B-TREE" in x for x in details), details
334
335
336async def test_resume_query_uses_the_index_without_a_session_user(mass: MusicAssistant) -> None:
337 """
338 The batched resume query still avoids a playlog scan when no user can be resolved.
339
340 Without a userid to filter on, the usable equality prefix stops at provider/media_type and
341 SQLite sorts the matched rows rather than reading them already ordered. The lookup itself
342 still has to go through the index: that sort covers one provider's episodes, where a table
343 scan would cover the whole household's 90 days of history.
344 """
345 await _add_playlog_row(mass, "ep-001", "user-a", seconds_played=10, fully_played=False)
346
347 with patch.object(
348 mass.music, "_get_user_for_provider", new_callable=AsyncMock, return_value=None
349 ):
350 details = await _explain_resume_query(mass, None)
351
352 assert any(f"USING INDEX {DB_TABLE_PLAYLOG}_provider_media_type_idx" in x for x in details), (
353 details
354 )
355
356
357async def test_without_a_session_user_the_newest_progress_wins(mass: MusicAssistant) -> None:
358 """With no user to scope to, the most recently played row is applied."""
359 # the newest row is deliberately both inserted first and owned by the alphabetically
360 # first userid, so neither insertion order nor the index's (userid, timestamp) key order
361 # can leave it last. The map the controller builds keeps whichever row it sees last, so
362 # only an explicit sort on timestamp lands on the 240s one
363 await _add_playlog_row(mass, "ep-001", "user-a", 240, fully_played=False, timestamp=2000)
364 await _add_playlog_row(mass, "ep-001", "user-b", 30, fully_played=False, timestamp=1000)
365
366 with patch.object(
367 mass.music, "_get_user_for_provider", new_callable=AsyncMock, return_value=None
368 ):
369 result = await _list_episodes(mass, [_episode(1)], user=None)
370
371 assert result[0].resume_position_ms == 240000
372
373
374async def test_single_episode_lookup_fills_resume_info_from_playlog(
375 mass: MusicAssistant,
376) -> None:
377 """The single-episode endpoint applies resume info the provider does not report."""
378 user = await mass.webserver.auth.create_user("podcastsingleepisode")
379 await _add_playlog_row(mass, "ep-001", user.user_id, seconds_played=45, fully_played=False)
380
381 episode = await _get_episode(mass, [_episode(1)], "ep-001", user=user)
382
383 assert episode.resume_position_ms == 45000
384 assert episode.fully_played is False
385
386
387async def test_single_episode_lookup_without_a_session_user_uses_newest_row(
388 mass: MusicAssistant,
389) -> None:
390 """With no user to scope to, the single-episode lookup applies the newest playlog row."""
391 # the older row is inserted first so an unordered lookup would surface it
392 await _add_playlog_row(
393 mass, "ep-001", "user-a", seconds_played=30, fully_played=False, timestamp=1000
394 )
395 await _add_playlog_row(
396 mass, "ep-001", "user-b", seconds_played=240, fully_played=False, timestamp=2000
397 )
398
399 with patch.object(
400 mass.music, "_get_user_for_provider", new_callable=AsyncMock, return_value=None
401 ):
402 episode = await _get_episode(mass, [_episode(1)], "ep-001", user=None)
403
404 assert episode.resume_position_ms == 240000
405