[scrobblers] Allow scrobbling food from the top bar
All checks were successful
build & deploy / test (push) Successful in 1m49s
build & deploy / deploy (push) Successful in 21s

This commit is contained in:
2026-03-12 14:04:04 -04:00
parent 40617b77e2
commit f5675e5319
3 changed files with 67 additions and 57 deletions

View File

@ -55,6 +55,7 @@ MANUAL_SCROBBLE_FNS = {
"-p": "manual_scrobble_puzzle", "-p": "manual_scrobble_puzzle",
"-l": "manual_scrobble_brickset", "-l": "manual_scrobble_brickset",
"-c": "manual_scrobble_book", "-c": "manual_scrobble_book",
"-f": "manual_scrobble_food",
} }

View File

@ -21,7 +21,7 @@ class ScrobbleForm(forms.Form):
widget=forms.TextInput( widget=forms.TextInput(
attrs={ attrs={
"class": "form-control form-control-dark w-100", "class": "form-control form-control-dark w-100",
"placeholder": "Scrobble something (ttIMDB, -v Video Game title, -b Book title, -s TheSportsDB ID)", "placeholder": "Scrobble something (ttIMDB, -v Video Game title, -b Book title, -s TheSportsDB ID, -f Food name - calories)",
"aria-label": "Scrobble something", "aria-label": "Scrobble something",
} }
), ),
@ -56,9 +56,7 @@ def django_form_field_from_type(field_type, required=True):
if type(None) in args: if type(None) in args:
required = False required = False
non_none_type = [arg for arg in args if arg is not type(None)][0] non_none_type = [arg for arg in args if arg is not type(None)][0]
return django_form_field_from_type( return django_form_field_from_type(non_none_type, required=required)
non_none_type, required=required
)
# Determine actual type # Determine actual type
base_type = origin if origin else field_type base_type = origin if origin else field_type
@ -84,9 +82,7 @@ def form_from_dataclass(dataclass):
continue continue
required = f.default is None and f.default_factory is None required = f.default is None and f.default_factory is None
form_fields[f.name] = django_form_field_from_type( form_fields[f.name] = django_form_field_from_type(f.type, required=required)
f.type, required=required
)
if f.name in dataclass._excluded_fields: if f.name in dataclass._excluded_fields:
form_fields[f.name].disabled = True form_fields[f.name].disabled = True
@ -101,9 +97,7 @@ def form_from_dataclass(dataclass):
def clean_notes(self): def clean_notes(self):
notes_str = self.cleaned_data.get("notes", "") notes_str = self.cleaned_data.get("notes", "")
return [ return [line.strip() for line in notes_str.splitlines() if line.strip()]
line.strip() for line in notes_str.splitlines() if line.strip()
]
form_cls.clean_notes = clean_notes form_cls.clean_notes = clean_notes
return form_cls return form_cls

View File

@ -84,11 +84,7 @@ def mopidy_scrobble_media(post_data: dict, user_id: int) -> Scrobble:
run_time_seconds=post_data.get("run_time", 900000), run_time_seconds=post_data.get("run_time", 900000),
) )
try: try:
album_id = ( album_id = Album.objects.filter(name=post_data.get("album", "")).first().id
Album.objects.filter(name=post_data.get("album", ""))
.first()
.id
)
except Exception: except Exception:
pass pass
@ -108,17 +104,14 @@ def mopidy_scrobble_media(post_data: dict, user_id: int) -> Scrobble:
user_id, user_id,
source="Mopidy", source="Mopidy",
playback_position_seconds=int( playback_position_seconds=int(
post_data.get(MOPIDY_POST_KEYS.get("PLAYBACK_POSITION_TICKS"), 1) post_data.get(MOPIDY_POST_KEYS.get("PLAYBACK_POSITION_TICKS"), 1) / 1000
/ 1000
), ),
status=post_data.get(MOPIDY_POST_KEYS.get("STATUS"), ""), status=post_data.get(MOPIDY_POST_KEYS.get("STATUS"), ""),
log=log, log=log,
) )
def jellyfin_scrobble_media( def jellyfin_scrobble_media(post_data: dict, user_id: int) -> Optional[Scrobble]:
post_data: dict, user_id: int
) -> Optional[Scrobble]:
media_type = Scrobble.MediaType.VIDEO media_type = Scrobble.MediaType.VIDEO
if post_data.pop("ItemType", "") in JELLYFIN_AUDIO_ITEM_TYPES: if post_data.pop("ItemType", "") in JELLYFIN_AUDIO_ITEM_TYPES:
media_type = Scrobble.MediaType.TRACK media_type = Scrobble.MediaType.TRACK
@ -136,13 +129,12 @@ def jellyfin_scrobble_media(
) )
return return
timestamp = parse( timestamp = parse(post_data.get(JELLYFIN_POST_KEYS.get("TIMESTAMP"), "")).replace(
post_data.get(JELLYFIN_POST_KEYS.get("TIMESTAMP"), "") tzinfo=pytz.utc
).replace(tzinfo=pytz.utc) )
playback_position_seconds = int( playback_position_seconds = int(
post_data.get(JELLYFIN_POST_KEYS.get("PLAYBACK_POSITION_TICKS"), 1) post_data.get(JELLYFIN_POST_KEYS.get("PLAYBACK_POSITION_TICKS"), 1) / 10000000
/ 10000000
) )
album_id = None album_id = None
if media_type == Scrobble.MediaType.VIDEO: if media_type == Scrobble.MediaType.VIDEO:
@ -153,16 +145,10 @@ def jellyfin_scrobble_media(
title=post_data.get("Name", ""), title=post_data.get("Name", ""),
artist_name=post_data.get("Artist", ""), artist_name=post_data.get("Artist", ""),
album_name=post_data.get("Album", ""), album_name=post_data.get("Album", ""),
run_time_seconds=convert_to_seconds( run_time_seconds=convert_to_seconds(post_data.get("RunTime", 900000)),
post_data.get("RunTime", 900000)
),
) )
try: try:
album_id = ( album_id = Album.objects.filter(name=post_data.get("Album", "")).first().id
Album.objects.filter(name=post_data.get("Album", ""))
.first()
.id
)
except Exception: except Exception:
pass pass
# A hack because we don't worry about updating music ... we either finish it or we don't # A hack because we don't worry about updating music ... we either finish it or we don't
@ -328,9 +314,7 @@ def manual_scrobble_book(
if not page: if not page:
page = 1 page = 1
logger.info( logger.info("[scrobblers] Book page included in scrobble, should update!")
"[scrobblers] Book page included in scrobble, should update!"
)
source = READCOMICSONLINE_URL.replace("https://", "") source = READCOMICSONLINE_URL.replace("https://", "")
@ -447,9 +431,7 @@ def email_scrobble_board_game(
if not location.name: if not location.name:
location.name = location_dict.get("name") location.name = location_dict.get("name")
update_fields.append("name") update_fields.append("name")
geoloc = GeoLocation.objects.filter( geoloc = GeoLocation.objects.filter(title__icontains=location.name).first()
title__icontains=location.name
).first()
if geoloc: if geoloc:
location.geo_location = geoloc location.geo_location = geoloc
update_fields.append("geo_location") update_fields.append("geo_location")
@ -501,9 +483,7 @@ def email_scrobble_board_game(
log_data.pop("expansion_ids") log_data.pop("expansion_ids")
if play_dict.get("locationRefId", False): if play_dict.get("locationRefId", False):
log_data["location_id"] = locations[ log_data["location_id"] = locations[play_dict.get("locationRefId")].id
play_dict.get("locationRefId")
].id
if play_dict.get("rounds", False): if play_dict.get("rounds", False):
log_data["rounds"] = play_dict.get("rounds") log_data["rounds"] = play_dict.get("rounds")
if play_dict.get("board", False): if play_dict.get("board", False):
@ -526,9 +506,7 @@ def email_scrobble_board_game(
timestamp = parse(play_dict.get("playDate")) timestamp = parse(play_dict.get("playDate"))
if hour and minute: if hour and minute:
logger.info(f"Scrobble playDate has manual start time {timestamp}") logger.info(f"Scrobble playDate has manual start time {timestamp}")
timestamp = timestamp.replace( timestamp = timestamp.replace(hour=hour, minute=minute, second=second or 0)
hour=hour, minute=minute, second=second or 0
)
logger.info(f"Update to {timestamp}") logger.info(f"Update to {timestamp}")
profile = UserProfile.objects.filter(user_id=user_id).first() profile = UserProfile.objects.filter(user_id=user_id).first()
@ -623,9 +601,7 @@ def manual_scrobble_from_url(
scrobbler. Otherwise, return nothing.""" scrobbler. Otherwise, return nothing."""
if RecipeScraperService.is_recipe_url(url): if RecipeScraperService.is_recipe_url(url):
return scrobble_from_recipe_website( return scrobble_from_recipe_website(url, user_id, source=source, action=action)
url, user_id, source=source, action=action
)
content_key = "" content_key = ""
domain = extract_domain(url) domain = extract_domain(url)
@ -828,9 +804,7 @@ def emacs_scrobble_update_task(
if not scrobble.log.get('notes"'): if not scrobble.log.get('notes"'):
scrobble.log["notes"] = [] scrobble.log["notes"] = []
if note.get("timestamp") not in existing_note_ts: if note.get("timestamp") not in existing_note_ts:
scrobble.log["notes"].append( scrobble.log["notes"].append({note.get("timestamp"): note.get("content")})
{note.get("timestamp"): note.get("content")}
)
notes_updated = True notes_updated = True
if notes_updated: if notes_updated:
@ -856,9 +830,7 @@ def emacs_scrobble_task(
user_context_list: list[str] = [], user_context_list: list[str] = [],
) -> Scrobble | None: ) -> Scrobble | None:
orgmode_id = task_data.get("source_id") orgmode_id = task_data.get("source_id")
title = get_title_from_labels( title = get_title_from_labels(task_data.get("labels", []), user_context_list)
task_data.get("labels", []), user_context_list
)
task = Task.find_or_create(title) task = Task.find_or_create(title)
@ -981,9 +953,7 @@ def manual_scrobble_webpage(
): ):
if RecipeScraperService.is_recipe_url(url): if RecipeScraperService.is_recipe_url(url):
return scrobble_from_recipe_website( return scrobble_from_recipe_website(url, user_id, source=source, action=action)
url, user_id, source=source, action=action
)
webpage = WebPage.find_or_create({"url": url}) webpage = WebPage.find_or_create({"url": url})
@ -1205,3 +1175,48 @@ def manual_scrobble_brickset(
# TODO Kick out a process to enrich the media here, and in every scrobble event # TODO Kick out a process to enrich the media here, and in every scrobble event
# TODO Need to check for past scrobbles and auto populate serial scrobble id if possible # TODO Need to check for past scrobbles and auto populate serial scrobble id if possible
return Scrobble.create_or_update(brickset, user_id, scrobble_dict) return Scrobble.create_or_update(brickset, user_id, scrobble_dict)
def manual_scrobble_food(
item_id: str,
user_id: int,
source: str = "Vrobbler",
action: Optional[str] = None,
):
match = re.match(r"(.+?)\s+-\s+(\d+)", item_id)
if not match:
logger.info(
"[manual_scrobble_food] invalid format, expected 'Food Name - calories'",
extra={"item_id": item_id, "user_id": user_id},
)
return
food_name = match.group(1).strip()
calories = int(match.group(2))
food = Food.objects.filter(title=food_name).first()
if not food:
food = Food.objects.create(
title=food_name,
description=food_name,
calories=calories,
)
scrobble_dict = {
"user_id": user_id,
"timestamp": timezone.now(),
"playback_position_seconds": 0,
"source": source,
}
logger.info(
"[scrobblers] manual food scrobble request received",
extra={
"food_id": food.id,
"user_id": user_id,
"scrobble_dict": scrobble_dict,
"media_type": Scrobble.MediaType.FOOD,
},
)
return Scrobble.create_or_update(food, user_id, scrobble_dict)