Skip to content
Draft
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
37 changes: 30 additions & 7 deletions backend/src/librarysync/core/next_episode.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,32 @@ def select_next_episode(
released_episodes: Iterable[EpisodeItem],
watched_episode_ids: set[str],
) -> EpisodeItem | None:
"""Return the first episode not in ``watched_episode_ids``.
"""Return the next episode after the user's latest watched point.

``released_episodes`` must already be ordered by season/episode number.
This avoids recommending an earlier, previously-unwatched episode from an
older season when the user has already progressed to a later season.
"""
for episode in released_episodes:
ordered_episodes = list(released_episodes)
if not ordered_episodes:
return None

last_watched = None
for episode in ordered_episodes:
if episode.id in watched_episode_ids:
last_watched = episode

if last_watched is None:
for episode in ordered_episodes:
if episode.id not in watched_episode_ids:
return episode
return None

last_key = (last_watched.season_number, last_watched.episode_number)
for episode in ordered_episodes:
episode_key = (episode.season_number, episode.episode_number)
if episode_key <= last_key:
continue
if episode.id not in watched_episode_ids:
return episode
return None
Expand Down Expand Up @@ -106,12 +127,14 @@ async def find_next_episodes_bulk(
released = await _released_episodes(db, show_ids, now_date)
watched_ids = await _watched_episode_ids(db, user_id, show_ids, now_date)
next_episodes: dict[str, EpisodeItem] = {}
per_show_episodes: dict[str, list[EpisodeItem]] = {}
for episode in released:
if episode.show_media_item_id in next_episodes:
continue
if episode.id in watched_ids:
continue
next_episodes[episode.show_media_item_id] = episode
per_show_episodes.setdefault(episode.show_media_item_id, []).append(episode)

for show_id, episodes in per_show_episodes.items():
next_episode = select_next_episode(episodes, watched_ids)
if next_episode is not None:
next_episodes[show_id] = next_episode
return next_episodes


Expand Down
38 changes: 38 additions & 0 deletions backend/tests/test_next_episode.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ def test_returns_first_episode_when_nothing_watched(self):
episodes = [_episode("e1", 1, 1), _episode("e2", 1, 2)]
assert select_next_episode(episodes, set()) is episodes[0]

def test_uses_latest_watched_episode_instead_of_earliest_unwatched(self):
episodes = [
_episode("s1e1", 1, 1),
_episode("s1e2", 1, 2),
_episode("s1e3", 1, 3),
_episode("s2e1", 2, 1),
_episode("s2e2", 2, 2),
_episode("s2e3", 2, 3),
]
assert select_next_episode(episodes, {"s2e2"}) is episodes[5]

def test_returns_none_when_all_watched(self):
episodes = [_episode("e1", 1, 1), _episode("e2", 1, 2)]
assert select_next_episode(episodes, {"e1", "e2"}) is None
Expand Down Expand Up @@ -253,6 +264,33 @@ async def test_find_next_episode_and_bulk_return_first_released_unwatched(db_ses
assert bulk_next[second_show.id].id == second_show_ep2.id


@pytest.mark.asyncio
async def test_find_next_episode_uses_last_watched_episode_across_seasons(db_session: AsyncSession):
user = await _create_user(db_session)
show = await _create_show(db_session, "Season Hopping Show")

await _create_episode(db_session, show.id, 1, 1, date(2024, 1, 1), title="S1E1")
await _create_episode(db_session, show.id, 1, 2, date(2024, 1, 2), title="S1E2")
season_two_ep1 = await _create_episode(db_session, show.id, 2, 1, date(2024, 2, 1), title="S2E1")
season_two_ep2 = await _create_episode(db_session, show.id, 2, 2, date(2024, 2, 2), title="S2E2")
season_two_ep3 = await _create_episode(db_session, show.id, 2, 3, date(2024, 2, 3), title="S2E3")

db_session.add(
WatchedItem(
user_id=user.id,
media_item_id=None,
episode_item_id=season_two_ep2.id,
watched_at=datetime(2024, 2, 10, tzinfo=timezone.utc),
source="manual",
)
)
await db_session.flush()

next_episode = await find_next_episode(db_session, user.id, show.id, date(2024, 2, 10))
assert next_episode is not None
assert next_episode.id == season_two_ep3.id


@pytest.mark.asyncio
async def test_mark_next_episode_watched_creates_watched_event_and_internal_outbox_job(
db_session: AsyncSession,
Expand Down