Skip to content

Commit e35dcb7

Browse files
dannyvfilmsclaude
andcommitted
Add optional episode score param to watch/scrobble APIs
- MediaEpisodeWatchView.post and ScrobbleView.post (action=stop, episode) now accept an optional 0-10 `score`, so a player can set the user's episode rating in the same call that marks it watched - Extract validate_episode_score/get_tracked_season/apply_episode_score into api/helpers.py, shared with the existing episode-score PATCH view - Seasons-page rating popup gains a decimal "exact rating" input next to the whole-star picker (episode ratings only) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 9c96667 commit e35dcb7

7 files changed

Lines changed: 297 additions & 73 deletions

File tree

src/api/contracts/openapi.yaml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1560,7 +1560,12 @@ paths:
15601560
/api/v1/media/{media_type}/{source}/{media_id}/{season_number}/episodes/{episode_number}/watch/:
15611561
post:
15621562
operationId: api_v1_media_episodes_watch_create
1563-
description: Record a watch for the episode.
1563+
description: |-
1564+
Record a watch for the episode.
1565+
1566+
Optional body field `score` (0-10, or null to clear) sets the
1567+
episode's rating in the same call, mirroring
1568+
MediaEpisodeScoreView.patch.
15641569
parameters:
15651570
- in: path
15661571
name: episode_number

src/api/fork_views_metadata.py

Lines changed: 13 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,31 @@
11
# FORK: metadata-management and score endpoints mirroring web-only actions
22
# (metadata_sync_views, score_views). URL wiring lives in fork_urls.py.
33
import logging
4-
from decimal import Decimal, InvalidOperation
54
from http import HTTPStatus as HTTP # noqa: N814
65

76
from django.apps import apps
87
from drf_spectacular.utils import extend_schema
98
from rest_framework import views as drf_views
109
from rest_framework.response import Response
1110

12-
from app import custom_metadata, history_cache
11+
from app import custom_metadata
1312
from app import metadata_sync_views as web_metadata_views
1413
from app.models import (
15-
Episode,
1614
Item,
1715
MediaTypes,
1816
MetadataProviderPreference,
19-
Season,
2017
Sources,
2118
)
2219
from app.services import metadata_resolution
2320

2421
from .contract_serializers import DetailErrorSerializer
25-
from .helpers import check_valid_type, resolve_episode_coordinate_for_request
22+
from .helpers import (
23+
apply_episode_score,
24+
check_valid_type,
25+
get_tracked_season,
26+
resolve_episode_coordinate_for_request,
27+
validate_episode_score,
28+
)
2629
from .schema import MEDIA_TYPE_PARAM, MEDIA_TYPE_TV_ONLY_PARAM
2730

2831
logger = logging.getLogger(__name__)
@@ -255,17 +258,7 @@ def patch(
255258
if coordinate_error:
256259
return coordinate_error
257260

258-
season = (
259-
Season.objects.filter(
260-
item__media_id=media_id,
261-
item__source=source,
262-
item__season_number=season_number,
263-
item__episode_number=None,
264-
user=request.user,
265-
)
266-
.order_by("id")
267-
.first()
268-
)
261+
season = get_tracked_season(request.user, media_id, source, season_number)
269262
if season is None:
270263
return Response(
271264
{"detail": "Season not found or not tracked."},
@@ -277,49 +270,16 @@ def patch(
277270
{"detail": "'score' is required (number or null)."},
278271
status=HTTP.BAD_REQUEST,
279272
)
280-
raw_score = request.data.get("score")
281-
score = None
282-
if raw_score is not None:
283-
try:
284-
score = Decimal(str(raw_score))
285-
except (InvalidOperation, TypeError, ValueError):
286-
return Response(
287-
{"detail": "Invalid score."},
288-
status=HTTP.BAD_REQUEST,
289-
)
290-
if not (Decimal(0) <= score <= Decimal(10)):
291-
return Response(
292-
{"detail": "Score must be between 0 and 10."},
293-
status=HTTP.BAD_REQUEST,
294-
)
273+
score, error = validate_episode_score(request.data.get("score"))
274+
if error:
275+
return error
295276

296-
episodes = Episode.objects.filter(
297-
related_season=season,
298-
item__episode_number=int(episode_number),
299-
)
300-
if not episodes.exists():
277+
if not apply_episode_score(season, episode_number, score):
301278
return Response(
302279
{"detail": "Episode not tracked."},
303280
status=HTTP.NOT_FOUND,
304281
)
305282

306-
episodes.update(score=score)
307-
308-
# episodes.update() runs raw SQL and skips post_save, so invalidate
309-
# the affected history days like the web view does.
310-
day_keys = [
311-
history_cache.history_day_key(end_date)
312-
for end_date in episodes.values_list("end_date", flat=True)
313-
]
314-
day_keys = [day_key for day_key in day_keys if day_key]
315-
if day_keys:
316-
history_cache.invalidate_history_days(
317-
request.user.id,
318-
day_keys=day_keys,
319-
logging_styles=("sessions", "repeats"),
320-
reason="episode_score_change",
321-
)
322-
323283
return Response(
324284
{"score": str(score) if score is not None else None},
325285
status=HTTP.OK,

src/api/fork_views_scrobble.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@
1717
from integrations.webhooks.generic_scrobble import GenericScrobbleProcessor, is_played
1818

1919
from . import fork_views_playback
20-
from .helpers import try_parse_datetime_input
20+
from .helpers import (
21+
apply_episode_score,
22+
get_tracked_season,
23+
try_parse_datetime_input,
24+
validate_episode_score,
25+
)
2126

2227
logger = logging.getLogger(__name__)
2328

@@ -76,6 +81,11 @@ def _scrobble_request_error(data):
7681
status=HTTP.BAD_REQUEST,
7782
)
7883

84+
if "score" in data:
85+
_, score_error = validate_episode_score(data.get("score"))
86+
if score_error:
87+
return score_error
88+
7989
return None
8090

8191

@@ -183,6 +193,13 @@ class ScrobbleView(drf_views.APIView):
183193
"nullable": True,
184194
"description": "Only used by 'stop'. Defaults to now.",
185195
},
196+
"score": {
197+
"type": "number",
198+
"nullable": True,
199+
"description": "Only used by 'stop', for media_type "
200+
"'episode'. Sets the user's rating (0-10) on the "
201+
"episode.",
202+
},
186203
},
187204
},
188205
},
@@ -256,6 +273,9 @@ def _execute():
256273
completed=is_played(payload),
257274
)
258275

276+
if "score" in request.data and media_type == MediaTypes.EPISODE.value:
277+
self._apply_episode_score(request.user, payload, request.data["score"])
278+
259279
return Response({"detail": "accepted"}, status=HTTP.OK)
260280

261281
if client_event_id:
@@ -271,6 +291,31 @@ def _execute():
271291
return _execute()
272292

273293

294+
def _apply_episode_score(self, user, payload, raw_score):
295+
"""Set the episode's rating; failures are logged, never raised.
296+
297+
Validity was already checked in `_scrobble_request_error`; this just
298+
resolves the already-persisted episode and applies it.
299+
"""
300+
score, _ = validate_episode_score(raw_score)
301+
try:
302+
item = fork_views_playback.resolve_video_item(
303+
user,
304+
payload["media_type"],
305+
payload["ids"],
306+
payload.get("season_number"),
307+
payload.get("episode_number"),
308+
create=False,
309+
)
310+
if item is None:
311+
return
312+
season = get_tracked_season(user, item.media_id, item.source, item.season_number)
313+
if season is None:
314+
return
315+
apply_episode_score(season, item.episode_number, score)
316+
except Exception:
317+
logger.warning("Scrobble episode-score update failed", exc_info=True)
318+
274319
def _store_playback_progress(self, user, payload, *, completed):
275320
"""Persist the durable resume position; failures are never raised.
276321

src/api/fork_views_tracking.py

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,24 @@
1515
from app.fork_services_episode import drop_episode, resolve_or_create_season
1616
from app.fork_services_movie import resolve_or_create_movie
1717
from app.history_cache_utils import normalize_history_media_type_tokens
18-
from app.models import Episode, ItemTag, MediaTypes, Movie, Season, Tag
18+
from app.models import Episode, ItemTag, MediaTypes, Movie, Tag
1919
from app.services import metadata_resolution
2020
from app.tasks import bulk_episode_plays_task
2121
from app.templatetags.app_tags import media_url
2222

2323
from .contract_serializers import DetailErrorSerializer
2424
from .helpers import (
2525
MEDIA_TYPE_MODEL_MAP,
26+
apply_episode_score,
2627
check_source_type,
2728
check_valid_type,
29+
get_tracked_season,
2830
paginate_data,
2931
parse_limit_offset,
3032
resolve_episode_coordinate_for_request,
3133
resolve_item_queryset,
3234
try_parse_datetime_input,
35+
validate_episode_score,
3336
)
3437
from .schema import MEDIA_TYPE_PARAM, MEDIA_TYPE_TV_ONLY_PARAM
3538
from .serializers import HistorySerializer, serialize_data
@@ -52,21 +55,6 @@ def _tv_route_error(media_type, source):
5255
return None
5356

5457

55-
def _get_tracked_season(user, media_id, source, season_number):
56-
"""Return the user's tracked Season row or None."""
57-
return (
58-
Season.objects.filter(
59-
item__media_id=media_id,
60-
item__source=source,
61-
item__season_number=season_number,
62-
item__episode_number=None,
63-
user=user,
64-
)
65-
.order_by("id")
66-
.first()
67-
)
68-
69-
7058
# /api/v1/media/tv/[source]/[media_id]/[season_number]/episodes/[episode_number]/watch/
7159
class MediaEpisodeWatchView(drf_views.APIView):
7260
"""Add or remove a watch (play) for an episode.
@@ -89,11 +77,21 @@ def post(
8977
season_number,
9078
episode_number,
9179
):
92-
"""Record a watch for the episode."""
80+
"""Record a watch for the episode.
81+
82+
Optional body field `score` (0-10, or null to clear) sets the
83+
episode's rating in the same call, mirroring
84+
MediaEpisodeScoreView.patch.
85+
"""
9386
error = _tv_route_error(media_type, source)
9487
if error:
9588
return error
9689

90+
score_provided = "score" in request.data
91+
score, score_error = validate_episode_score(request.data.get("score"))
92+
if score_error:
93+
return score_error
94+
9795
raw_end_date = request.data.get("end_date")
9896
if raw_end_date in (None, ""):
9997
end_date = timezone.now()
@@ -140,6 +138,8 @@ def post(
140138
)
141139

142140
related_season.watch(int(episode_number), end_date)
141+
if score_provided:
142+
apply_episode_score(related_season, episode_number, score)
143143
episode = (
144144
Episode.objects.filter(
145145
related_season=related_season,
@@ -194,7 +194,7 @@ def delete(
194194
if coordinate_error:
195195
return coordinate_error
196196

197-
related_season = _get_tracked_season(
197+
related_season = get_tracked_season(
198198
request.user,
199199
media_id,
200200
source,

src/api/helpers.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import logging
22
from calendar import monthrange
33
from datetime import date
4+
from decimal import Decimal, InvalidOperation
45
from http import HTTPStatus as HTTP # noqa: N814
56

67
from django.db.models import Count, OuterRef, Subquery
78
from django.utils.dateparse import parse_date
89
from django.utils.timezone import localdate
910
from rest_framework.response import Response
1011

12+
from app import history_cache
1113
from app.helpers import parse_completion_datetime
1214
from app.models import (
1315
TV,
@@ -843,3 +845,69 @@ def build_game_lengths_summary(payload):
843845
if "hltb_summary" not in summary and "igdb_summary" not in summary:
844846
return None
845847
return summary
848+
849+
850+
def get_tracked_season(user, media_id, source, season_number):
851+
"""Return the user's tracked Season row for a show/season, or None."""
852+
return (
853+
Season.objects.filter(
854+
item__media_id=media_id,
855+
item__source=source,
856+
item__season_number=season_number,
857+
item__episode_number=None,
858+
user=user,
859+
)
860+
.order_by("id")
861+
.first()
862+
)
863+
864+
865+
def validate_episode_score(raw_score):
866+
"""Parse and range-check a 0-10 episode score.
867+
868+
Returns (score, None) on success, where score is a Decimal or None
869+
(explicit clear); returns (None, error_response) on failure.
870+
"""
871+
if raw_score is None:
872+
return None, None
873+
try:
874+
score = Decimal(str(raw_score))
875+
except (InvalidOperation, TypeError, ValueError):
876+
return None, Response({"detail": "Invalid score."}, status=HTTP.BAD_REQUEST)
877+
if not (Decimal(0) <= score <= Decimal(10)):
878+
return None, Response(
879+
{"detail": "Score must be between 0 and 10."},
880+
status=HTTP.BAD_REQUEST,
881+
)
882+
return score, None
883+
884+
885+
def apply_episode_score(season, episode_number, score):
886+
"""Set `score` on all plays of a tracked episode within `season`.
887+
888+
Returns True if the episode was found and updated, False otherwise.
889+
Uses queryset.update(), which skips post_save, so the affected
890+
history-cache days are invalidated explicitly like the web view does.
891+
"""
892+
episodes = Episode.objects.filter(
893+
related_season=season,
894+
item__episode_number=int(episode_number),
895+
)
896+
if not episodes.exists():
897+
return False
898+
899+
episodes.update(score=score)
900+
901+
day_keys = [
902+
history_cache.history_day_key(end_date)
903+
for end_date in episodes.values_list("end_date", flat=True)
904+
]
905+
day_keys = [day_key for day_key in day_keys if day_key]
906+
if day_keys:
907+
history_cache.invalidate_history_days(
908+
season.user_id,
909+
day_keys=day_keys,
910+
logging_styles=("sessions", "repeats"),
911+
reason="episode_score_change",
912+
)
913+
return True

0 commit comments

Comments
 (0)