[boardgames] Start adding email scrobbling for board games

This commit is contained in:
2025-07-02 10:55:24 -04:00
parent a2f64a98c3
commit 0fa831fa42
4 changed files with 173 additions and 4 deletions

View File

@ -0,0 +1,39 @@
# Generated by Django 4.2.19 on 2025-07-02 14:54
from django.db import migrations, models
import encrypted_field.fields
class Migration(migrations.Migration):
dependencies = [
("profiles", "0023_alter_userprofile_timezone"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="bgstat_id",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="userprofile",
name="imap_auto_import",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="userprofile",
name="imap_pass",
field=encrypted_field.fields.EncryptedField(blank=True, null=True),
),
migrations.AddField(
model_name="userprofile",
name="imap_url",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="userprofile",
name="imap_user",
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -31,6 +31,7 @@ class UserProfile(TimeStampedModel):
task_context_tags_str = models.CharField(max_length=255, **BNULL) task_context_tags_str = models.CharField(max_length=255, **BNULL)
bgstat_id = models.CharField(max_length=255, **BNULL)
bgg_username = models.CharField(max_length=255, **BNULL) bgg_username = models.CharField(max_length=255, **BNULL)
lichess_username = models.CharField(max_length=255, **BNULL) lichess_username = models.CharField(max_length=255, **BNULL)
@ -43,6 +44,11 @@ class UserProfile(TimeStampedModel):
webdav_pass = EncryptedField(**BNULL) webdav_pass = EncryptedField(**BNULL)
webdav_auto_import = models.BooleanField(default=False) webdav_auto_import = models.BooleanField(default=False)
imap_url = models.CharField(max_length=255, **BNULL)
imap_user = models.CharField(max_length=255, **BNULL)
imap_pass = EncryptedField(**BNULL)
imap_auto_import = models.BooleanField(default=False)
ntfy_url = models.CharField(max_length=255, **BNULL) ntfy_url = models.CharField(max_length=255, **BNULL)
ntfy_enabled = models.BooleanField(default=False) ntfy_enabled = models.BooleanField(default=False)

View File

@ -0,0 +1,98 @@
import json
import imaplib
import email
from email.header import decode_header
from django.core.files.base import ContentFile
from profiles.models import UserProfile
from scrobbles.models import Scrobble
from scrobbles.scrobblers import email_scrobble_board_game
import logging
logger = logging.getLogger(__name__)
def process_scrobbles_from_imap() -> list[Scrobble] | None:
"""For all user profiles with IMAP creds, check inbox for scrobbleable email attachments."""
scrobbles_to_create: list[Scrobble] = []
active_profiles = UserProfile.objects.filter(imap_auto_import=True)
for profile in active_profiles:
logger.info(
"Importing scrobbles from IMAP for user",
extra={"user_id": profile.user_id},
)
mail = imaplib.IMAP4_SSL(profile.imap_url)
mail.login(profile.imap_user, profile.imap_pass)
mail.select("INBOX")
# Search for unseen emails
status, messages = mail.search(None, "(UNSEEN)")
if status != "OK":
return
for uid in messages[0].split():
status, msg_data = mail.fetch(uid, "(RFC822)")
if status != "OK":
logger.info("IMAP status not OK", extra={"status": status})
continue
try:
msg = email.message_from_bytes(msg_data[0][1])
logger.info("Processing email message", extra={"msg": msg})
except IndexError:
logger.info("No email message data found")
return
for part in msg.walk():
if part.get_content_disposition() == "attachment":
filename = part.get_filename()
if filename:
# Decode the filename if necessary
decoded_name, encoding = decode_header(filename)[0]
if isinstance(decoded_name, bytes):
filename = decoded_name.decode(encoding or "utf-8")
file_data = part.get_payload(decode=True)
parsed_json = ""
# Try parsing JSON if applicable
if filename.lower().endswith(".bgsplay"):
try:
parsed_json = json.loads(
file_data.decode("utf-8")
)
scrobbles_to_create.append(
email_scrobble_board_game(
parsed_json, profile.user_id
)
)
except Exception as e:
# You might want to log this
print(
f"Failed to parse JSON from {filename}: {e}"
)
# Avoid duplicates
if not EmailAttachment.objects.filter(
email_uid=uid.decode(), filename=filename
).exists():
attachment = EmailAttachment(
email_uid=uid.decode(), filename=filename
)
attachment.file.save(
filename, ContentFile(file_data)
)
attachment.save()
mail.logout()
if scrobbles_to_create:
logger.info(
f"Creating {len(scrobbles_to_create)} new scrobbles",
extra={"scrobbles_to_create": scrobbles_to_create},
)
created_scrobbles = Scrobble.objects.bulk_create(scrobbles_to_create)
return created_scrobbles
logger.info(f"No new scrobbles found in IMAP folders")

View File

@ -1,7 +1,7 @@
import logging import logging
import re import re
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Any, Optional
import pendulum import pendulum
import pytz import pytz
@ -285,7 +285,7 @@ def manual_scrobble_book(
def manual_scrobble_board_game( def manual_scrobble_board_game(
bggeek_id: str, user_id: int, action: Optional[str] = None bggeek_id: str, user_id: int, action: Optional[str] = None
): ) -> Scrobble | None:
boardgame = BoardGame.find_or_create(bggeek_id) boardgame = BoardGame.find_or_create(bggeek_id)
if not boardgame: if not boardgame:
@ -311,6 +311,28 @@ def manual_scrobble_board_game(
return Scrobble.create_or_update(boardgame, user_id, scrobble_dict) return Scrobble.create_or_update(boardgame, user_id, scrobble_dict)
def email_scrobble_board_game(
bgstat_data: dict[str, Any], user_id: int
) -> Scrobble | None:
game_dict: dict[str, Any] = bgstat_data.get("games", [])[0]
if not game_dict.get("bggId", False):
logger.info(
"Data from BG Stats JSON had not BGG ID, not scrobbling",
extra={"bgstat_data": bgstat_data},
)
return
boardgame = BoardGame.find_or_create(game_dict.get("bggId"))
# TODO Enrich data from our bgstats data?
#
# TODO Build up board game meta data from the rest of the data
scrobble_dict = {}
players_list: list[dict[str, Any]] = bgstat_data.get("players", [])
location: dict[str, Any] = bgstat_data.get("locations", [])[0]
return Scrobble.create_or_update(boardgame, user_id, scrobble_dict)
def manual_scrobble_from_url( def manual_scrobble_from_url(
url: str, user_id: int, action: Optional[str] = None url: str, user_id: int, action: Optional[str] = None
) -> Scrobble: ) -> Scrobble:
@ -411,7 +433,9 @@ def todoist_scrobble_task(
stopped: bool = False, stopped: bool = False,
user_context_list: list[str] = [], user_context_list: list[str] = [],
) -> Scrobble: ) -> Scrobble:
title = get_title_from_labels(todoist_task.get("todoist_label_list", []), user_context_list) title = get_title_from_labels(
todoist_task.get("todoist_label_list", []), user_context_list
)
task = Task.find_or_create(title) task = Task.find_or_create(title)
timestamp = pendulum.parse(todoist_task.get("updated_at", timezone.now())) timestamp = pendulum.parse(todoist_task.get("updated_at", timezone.now()))
@ -536,7 +560,9 @@ def emacs_scrobble_task(
user_context_list: list[str] = [], user_context_list: list[str] = [],
) -> Scrobble | None: ) -> Scrobble | None:
source_id = task_data.get("source_id") source_id = task_data.get("source_id")
title = get_title_from_labels(task_data.get("labels", []), user_context_list) title = get_title_from_labels(
task_data.get("labels", []), user_context_list
)
task = Task.find_or_create(title) task = Task.find_or_create(title)