music-assistant-server

15.6 KBPY
test_podcast_episode_resume.py
15.6 KB405 lines • python
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