Skip to content

Commit b667d1d

Browse files
committed
Merge remote-tracking branch 'origin/latest' into latest
2 parents bc8d4e7 + 8a197b2 commit b667d1d

37 files changed

Lines changed: 2950 additions & 193 deletions

src/app/cache_utils.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@
1515
TIME_LEFT_CACHE_PREFIX = "time_left_sorted_v19"
1616
_REGISTRY_TEMPLATE = f"{TIME_LEFT_CACHE_PREFIX}_registry_{{user_id}}"
1717

18-
MEDIA_LIST_CACHE_PREFIX = "media_list_v1"
18+
# v2: payload changed from a pickled full MediaListEntry list to a compact
19+
# {media_pk, model, item_pk} order hydrated per page (issue #865). Bumped so a
20+
# rolling deploy can't hand the new code an old pickled entry (or vice versa).
21+
MEDIA_LIST_CACHE_PREFIX = "media_list_v2"
1922
MEDIA_LIST_CACHE_TTL = 60 # seconds — enough for "navigate away and back" use case
2023
# v2: filter payloads include per-tag media-list usage counts.
2124
MEDIA_LIST_FILTER_CACHE_PREFIX = "media_list_filters_v2"

src/app/helpers.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1094,3 +1094,15 @@ def parse_completion_datetime(value):
10941094
parsed = make_aware(parsed)
10951095

10961096
return parsed
1097+
1098+
def split_pass_history(tracked_plays, season):
1099+
"""Return (plays in the season's current rewatch pass, every play).
1100+
1101+
Outside a pass the two are the same, so callers can key display off the
1102+
first and lifetime counts off the second without special-casing.
1103+
"""
1104+
if season is None:
1105+
return tracked_plays, tracked_plays
1106+
return [
1107+
play for play in tracked_plays if season.play_counts_for_pass(play)
1108+
], tracked_plays

src/app/history_cache.py

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1019,9 +1019,7 @@ def build_history_days(
10191019
)
10201020
)
10211021
process_movies = (
1022-
process_all
1023-
or has_person_filter
1024-
or MediaTypes.MOVIE.value in media_type_filter
1022+
process_all or has_person_filter or MediaTypes.MOVIE.value in media_type_filter
10251023
)
10261024
process_reading = (
10271025
process_all
@@ -1037,19 +1035,15 @@ def build_history_days(
10371035
)
10381036
)
10391037
process_music = (
1040-
process_all
1041-
or has_music_filter
1042-
or MediaTypes.MUSIC.value in media_type_filter
1038+
process_all or has_music_filter or MediaTypes.MUSIC.value in media_type_filter
10431039
)
10441040
process_podcasts = (
10451041
process_all
10461042
or has_podcast_filter
10471043
or MediaTypes.PODCAST.value in media_type_filter
10481044
)
10491045
process_games = process_all or MediaTypes.GAME.value in media_type_filter
1050-
process_boardgames = (
1051-
process_all or MediaTypes.BOARDGAME.value in media_type_filter
1052-
)
1046+
process_boardgames = process_all or MediaTypes.BOARDGAME.value in media_type_filter
10531047

10541048
# --- Fetch querysets ---
10551049
episodes_start = time.perf_counter()
@@ -1399,11 +1393,7 @@ def entry_matches_implied_genre(entry):
13991393
entries.append(entry)
14001394
entry_counts["episodes"] += 1
14011395

1402-
if (
1403-
process_all
1404-
or has_person_filter
1405-
or MediaTypes.MOVIE.value in media_type_filter
1406-
):
1396+
if process_all or has_person_filter or MediaTypes.MOVIE.value in media_type_filter:
14071397
for movie in movies:
14081398
if genre_filters and not matches_genre(movie, MediaTypes.MOVIE.value):
14091399
continue
@@ -1449,11 +1439,7 @@ def entry_matches_implied_genre(entry):
14491439
elif mt == MediaTypes.ANIME.value:
14501440
entry_counts["anime"] += 1
14511441

1452-
if (
1453-
process_all
1454-
or has_music_filter
1455-
or MediaTypes.MUSIC.value in media_type_filter
1456-
):
1442+
if process_all or has_music_filter or MediaTypes.MUSIC.value in media_type_filter:
14571443
music_entry_list, music_history_records_scanned, music_album_day_groups = (
14581444
_build_music_entries(
14591445
user, music_entries, genre_filters, start_date, end_date
@@ -1492,9 +1478,7 @@ def entry_matches_implied_genre(entry):
14921478
)
14931479

14941480
process_games = process_all or MediaTypes.GAME.value in media_type_filter
1495-
process_boardgames = (
1496-
process_all or MediaTypes.BOARDGAME.value in media_type_filter
1497-
)
1481+
process_boardgames = process_all or MediaTypes.BOARDGAME.value in media_type_filter
14981482
if process_games or process_boardgames:
14991483
for entry in _build_game_entries(
15001484
games,
@@ -1528,7 +1512,14 @@ def entry_matches_implied_genre(entry):
15281512
undated_entries = [e for e in entries if e["played_at_local"] is None]
15291513
entries = [e for e in entries if e["played_at_local"] is not None]
15301514

1531-
entries.sort(key=lambda e: e["played_at_local"], reverse=True)
1515+
entries.sort(
1516+
key=lambda e: (
1517+
e["played_at_local"],
1518+
e.get("season_number") or 0,
1519+
e.get("episode_number") or 0,
1520+
),
1521+
reverse=True,
1522+
)
15321523

15331524
grouped_entries = defaultdict(list)
15341525
for entry in entries:
@@ -1538,7 +1529,14 @@ def entry_matches_implied_genre(entry):
15381529
for _, day_entries in sorted(
15391530
grouped_entries.items(), key=lambda x: x[0], reverse=True
15401531
):
1541-
day_entries.sort(key=lambda e: e["played_at_local"], reverse=True)
1532+
day_entries.sort(
1533+
key=lambda e: (
1534+
e["played_at_local"],
1535+
e.get("season_number") or 0,
1536+
e.get("episode_number") or 0,
1537+
),
1538+
reverse=True,
1539+
)
15421540
first_entry_time = day_entries[0]["played_at_local"]
15431541
total_minutes = sum(e["runtime_minutes"] or 0 for e in day_entries)
15441542
history_days.append(

src/app/history_cache_day_builder.py

Lines changed: 56 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,7 @@
5656
logger = logging.getLogger(__name__)
5757

5858

59-
def build_history_day(
60-
user, day_key, logging_style_override=None, media_types=None
61-
):
59+
def build_history_day(user, day_key, logging_style_override=None, media_types=None):
6260
"""Build a single history day payload for a user."""
6361
if not day_key:
6462
return None
@@ -71,12 +69,18 @@ def build_history_day(
7169
if not day_date:
7270
return None
7371
requested_media_types = expand_history_media_types(media_types)
74-
include_episode = requested_media_types is None or "episode" in requested_media_types
72+
include_episode = (
73+
requested_media_types is None or "episode" in requested_media_types
74+
)
7575
include_movie = requested_media_types is None or "movie" in requested_media_types
7676
include_music = requested_media_types is None or "music" in requested_media_types
77-
include_podcast = requested_media_types is None or "podcast" in requested_media_types
77+
include_podcast = (
78+
requested_media_types is None or "podcast" in requested_media_types
79+
)
7880
include_game = requested_media_types is None or "game" in requested_media_types
79-
include_boardgame = requested_media_types is None or "boardgame" in requested_media_types
81+
include_boardgame = (
82+
requested_media_types is None or "boardgame" in requested_media_types
83+
)
8084

8185
day_start = timezone.make_aware(
8286
datetime.combine(day_date, datetime.min.time()),
@@ -203,7 +207,10 @@ def build_history_day(
203207
(MediaTypes.MANGA.value, Manga),
204208
(MediaTypes.ANIME.value, Anime),
205209
):
206-
if requested_media_types is not None and media_type_value not in requested_media_types:
210+
if (
211+
requested_media_types is not None
212+
and media_type_value not in requested_media_types
213+
):
207214
continue
208215
records = (
209216
model.objects.filter(user=user)
@@ -511,21 +518,25 @@ def build_history_day(
511518
# Games / Boardgames
512519
if logging_style == "sessions":
513520
games = (
514-
Game.objects.filter(user=user)
515-
.filter(
516-
models.Q(end_date__gte=day_start, end_date__lt=day_end)
517-
| (
518-
models.Q(end_date__isnull=True)
519-
& models.Q(start_date__gte=day_start, start_date__lt=day_end)
520-
)
521-
| (
522-
models.Q(end_date__isnull=True)
523-
& models.Q(start_date__isnull=True)
524-
& models.Q(created_at__gte=day_start, created_at__lt=day_end)
521+
(
522+
Game.objects.filter(user=user)
523+
.filter(
524+
models.Q(end_date__gte=day_start, end_date__lt=day_end)
525+
| (
526+
models.Q(end_date__isnull=True)
527+
& models.Q(start_date__gte=day_start, start_date__lt=day_end)
528+
)
529+
| (
530+
models.Q(end_date__isnull=True)
531+
& models.Q(start_date__isnull=True)
532+
& models.Q(created_at__gte=day_start, created_at__lt=day_end)
533+
)
525534
)
535+
.select_related("item")
526536
)
527-
.select_related("item")
528-
) if include_game else Game.objects.none()
537+
if include_game
538+
else Game.objects.none()
539+
)
529540
for game in games:
530541
activity_dt = game.end_date or game.start_date or game.created_at
531542
played_at_local = _localize_datetime(activity_dt)
@@ -568,21 +579,25 @@ def build_history_day(
568579
entries.append(entry)
569580

570581
boardgames = (
571-
BoardGame.objects.filter(user=user)
572-
.filter(
573-
models.Q(end_date__gte=day_start, end_date__lt=day_end)
574-
| (
575-
models.Q(end_date__isnull=True)
576-
& models.Q(start_date__gte=day_start, start_date__lt=day_end)
577-
)
578-
| (
579-
models.Q(end_date__isnull=True)
580-
& models.Q(start_date__isnull=True)
581-
& models.Q(created_at__gte=day_start, created_at__lt=day_end)
582+
(
583+
BoardGame.objects.filter(user=user)
584+
.filter(
585+
models.Q(end_date__gte=day_start, end_date__lt=day_end)
586+
| (
587+
models.Q(end_date__isnull=True)
588+
& models.Q(start_date__gte=day_start, start_date__lt=day_end)
589+
)
590+
| (
591+
models.Q(end_date__isnull=True)
592+
& models.Q(start_date__isnull=True)
593+
& models.Q(created_at__gte=day_start, created_at__lt=day_end)
594+
)
582595
)
596+
.select_related("item")
583597
)
584-
.select_related("item")
585-
) if include_boardgame else BoardGame.objects.none()
598+
if include_boardgame
599+
else BoardGame.objects.none()
600+
)
586601
for boardgame in boardgames:
587602
activity_dt = (
588603
boardgame.end_date or boardgame.start_date or boardgame.created_at
@@ -748,7 +763,14 @@ def build_history_day(
748763
if not entries:
749764
return None
750765

751-
entries.sort(key=lambda entry: entry["played_at_local"], reverse=True)
766+
entries.sort(
767+
key=lambda entry: (
768+
entry["played_at_local"],
769+
entry.get("season_number") or 0,
770+
entry.get("episode_number") or 0,
771+
),
772+
reverse=True,
773+
)
752774
total_minutes = sum(entry["runtime_minutes"] or 0 for entry in entries)
753775
first_entry_time = entries[0]["played_at_local"]
754776

src/app/history_entry_builders.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,12 @@ def _build_episode_entry(episode, episode_title_map=None):
225225
"display_title": display_title,
226226
"episode_label": episode_label,
227227
"episode_code": episode_code,
228+
# Tiebreaker for entries.sort()'s played_at_local key: plays logged
229+
# in one bulk action often share an exact timestamp, so the season
230+
# order they actually play in decides ties rather than whatever
231+
# arbitrary order the query happened to return them in.
232+
"season_number": episode_item.season_number if episode_item else None,
233+
"episode_number": episode_item.episode_number if episode_item else None,
228234
"played_at_local": played_at_local,
229235
"runtime_minutes": runtime_minutes,
230236
"runtime_display": helpers.minutes_to_hhmm(runtime_minutes)

src/app/history_processor.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ def process_history_entry(entry, media_type, user):
2626
new_record, old_record = entry
2727
processed_entry = {
2828
"id": new_record.history_id,
29+
# The tracked row this entry describes, so the timeline can offer to
30+
# edit the play itself rather than only delete the record of it.
31+
"instance_id": new_record.id,
2932
"date": new_record.history_date,
3033
"changes": [],
3134
}

src/app/management/commands/benchmark_perf.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@
2121
("GET", "/medialist/tv"),
2222
("GET", "/medialist/season"),
2323
("GET", "/medialist/anime"),
24+
("GET", "/medialist/movie"),
25+
("GET", "/medialist/game"),
26+
("GET", "/medialist/book"),
27+
("GET", "/medialist/manga"),
28+
("GET", "/medialist/music"),
29+
("GET", "/medialist/podcast"),
2430
("GET", "/statistics"),
2531
("GET", "/history"),
2632
("GET", "/discover"),
@@ -29,6 +35,16 @@
2935
("GET", "/health/"),
3036
]
3137

38+
# medialist paths whose "scroll" (pagination) requests we also benchmark
39+
# separately from first-load, matching how issue #865 was reported: a slow
40+
# first render followed by additional slow requests while scrolling further
41+
# pages of the same list.
42+
MEDIALIST_SCROLL_PATHS = [
43+
"/medialist/tv",
44+
"/medialist/movie",
45+
]
46+
SCROLL_PAGES = 3
47+
3248
RUNS = 3
3349

3450

@@ -136,5 +152,28 @@ def handle(self, *args, **options):
136152
)
137153
self.stdout.write("")
138154

155+
# Reproduce the issue #865 report: a cold first load followed by
156+
# scrolling through several more pages of the same list. Each row
157+
# here is ONE request (not a median), in request order, so a
158+
# regression that only shows up after the cache warms (or only on
159+
# page >= 2) is visible instead of averaged away.
160+
if MEDIALIST_SCROLL_PATHS:
161+
self.stdout.write("Scroll reproduction (issue #865): cold load + N page requests")
162+
self.stdout.write(divider)
163+
for path in MEDIALIST_SCROLL_PATHS:
164+
for page in range(1, SCROLL_PAGES + 2):
165+
reset_queries()
166+
t0 = time.perf_counter()
167+
client.get(path, {"page": page} if page > 1 else {})
168+
wall_ms = (time.perf_counter() - t0) * 1000
169+
n_queries = len(connection.queries)
170+
label = f"{path} page={page}" + (" (first load)" if page == 1 else "")
171+
self.stdout.write(
172+
f"{label:<{col_w[0]}} | {n_queries:>{col_w[1]}} | "
173+
f"{'':>{col_w[2]}} | {wall_ms:>{col_w[3]}.1f}"
174+
)
175+
self.stdout.write(divider)
176+
self.stdout.write("")
177+
139178
conf.settings.DEBUG = original_debug
140179
conf.settings.ALLOWED_HOSTS = original_allowed_hosts

0 commit comments

Comments
 (0)