diff --git a/vrobbler/apps/scrobbles/admin.py b/vrobbler/apps/scrobbles/admin.py index 1efcb45..363500d 100644 --- a/vrobbler/apps/scrobbles/admin.py +++ b/vrobbler/apps/scrobbles/admin.py @@ -2,6 +2,7 @@ from django.contrib import admin from scrobbles.models import ( AudioScrobblerTSVImport, + BGStatsImport, KoReaderImport, LastFmImport, RetroarchImport, @@ -79,6 +80,11 @@ class RetroarchImportAdmin(ImportBaseAdmin): ... +@admin.register(BGStatsImport) +class BGStatsImportAdmin(ImportBaseAdmin): + ... + + @admin.register(ScaleCSVImport) class ScaleCSVImportAdmin(ImportBaseAdmin): ... diff --git a/vrobbler/apps/scrobbles/importers/webdav.py b/vrobbler/apps/scrobbles/importers/webdav.py index fe4e6f8..f6fa95f 100644 --- a/vrobbler/apps/scrobbles/importers/webdav.py +++ b/vrobbler/apps/scrobbles/importers/webdav.py @@ -7,7 +7,6 @@ import json from books.koreader import fetch_file_from_webdav from profiles.models import UserProfile from scrobbles.models import KoReaderImport, TrailGPXImport -from scrobbles.scrobblers import email_scrobble_board_game from scrobbles.tasks import process_koreader_import, process_trail_gpx_import from scrobbles.utils import get_file_md5_hash from webdav.client import get_webdav_client @@ -290,14 +289,15 @@ def scan_webdav_for_retroarch(webdav_client, user_id): def scan_webdav_for_bgstats(webdav_client, user_id): - """Download .bgsplay files from WebDAV, parse JSON, and scrobble board games.""" + """Download .bgsplay files from WebDAV and queue imports for new files.""" + from scrobbles.models import BGStatsImport + from scrobbles.tasks import process_bgstats_import + bgstats_path = DEFAULT_BGSTATS_PATH try: webdav_client.info(bgstats_path) except: - logger.info( - "No var/bgstats/ directory on webdav", extra={"user_id": user_id} - ) + logger.info("No var/bgstats/ directory on webdav", extra={"user_id": user_id}) return 0 try: @@ -309,38 +309,37 @@ def scan_webdav_for_bgstats(webdav_client, user_id): ) return 0 - processed = 0 - seen_this_cycle = set() + new_imports = 0 + already_imported = set( + BGStatsImport.objects.filter(user_id=user_id).values_list( + "original_filename", flat=True + ) + ) for fname in files: fname = os.path.basename(fname) if not fname.lower().endswith(".bgsplay"): continue - if fname in seen_this_cycle: + if fname in already_imported: + logger.debug(f"Skipping already-imported {fname}") continue - seen_this_cycle.add(fname) tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname) try: webdav_client.download_sync( remote_path=f"{bgstats_path}/{fname}", local_path=tmp.name ) - with open(tmp.name, "r", encoding="utf-8") as f: - parsed_json = json.load(f) - scrobbles = email_scrobble_board_game(parsed_json, user_id) - if scrobbles: - logger.info( - "BG Stats import from %s for user %d created %d scrobble(s)", - fname, - user_id, - len(scrobbles), - ) - processed += 1 - except Exception as e: - logger.error( - "Failed to import BG Stats file %s: %s", fname, e + imp = BGStatsImport.objects.create( + user_id=user_id, + original_filename=fname, ) + with open(tmp.name, "rb") as f: + imp.bgsplay_file.save(fname, f, save=True) + process_bgstats_import.delay(imp.id) + new_imports += 1 + except Exception as e: + logger.error(f"Failed to import BG Stats file {fname}: {e}") finally: os.unlink(tmp.name) - return processed + return new_imports diff --git a/vrobbler/apps/scrobbles/migrations/0081_bgstats_import_model.py b/vrobbler/apps/scrobbles/migrations/0081_bgstats_import_model.py new file mode 100644 index 0000000..30d6a9a --- /dev/null +++ b/vrobbler/apps/scrobbles/migrations/0081_bgstats_import_model.py @@ -0,0 +1,74 @@ +# Generated by Django 4.2.29 on 2026-05-23 20:54 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import scrobbles.models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("scrobbles", "0080_retroarchimport_files_hash"), + ] + + operations = [ + migrations.CreateModel( + name="BGStatsImport", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + django_extensions.db.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="created" + ), + ), + ( + "modified", + django_extensions.db.fields.ModificationDateTimeField( + auto_now=True, verbose_name="modified" + ), + ), + ("uuid", models.UUIDField(default=uuid.uuid4, editable=False)), + ("processing_started", models.DateTimeField(blank=True, null=True)), + ("processed_finished", models.DateTimeField(blank=True, null=True)), + ("process_log", models.TextField(blank=True, null=True)), + ("process_count", models.IntegerField(blank=True, null=True)), + ( + "bgsplay_file", + models.FileField( + blank=True, + null=True, + upload_to=scrobbles.models.BGStatsImport.get_path, + ), + ), + ( + "original_filename", + models.CharField(blank=True, max_length=255, null=True), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "BG Stats Import", + }, + ), + ] diff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.py index ca8e7cf..15accb9 100644 --- a/vrobbler/apps/scrobbles/models.py +++ b/vrobbler/apps/scrobbles/models.py @@ -455,6 +455,53 @@ class RetroarchImport(BaseFileImportMixin): self.mark_finished() +class BGStatsImport(BaseFileImportMixin): + class Meta: + verbose_name = "BG Stats Import" + + @property + def import_type(self) -> str: + return "BG Stats" + + def get_absolute_url(self): + return reverse("scrobbles:bgstats-import-detail", kwargs={"slug": self.uuid}) + + def get_path(instance, filename): + extension = filename.split(".")[-1] + uuid = instance.uuid + return f"bgstats-uploads/{uuid}.{extension}" + + @property + def upload_file_path(self): + if getattr(settings, "USE_S3_STORAGE"): + path = self.bgsplay_file.url + else: + path = self.bgsplay_file.path + return path + + bgsplay_file = models.FileField(upload_to=get_path, **BNULL) + original_filename = models.CharField(max_length=255, **BNULL) + + def process(self, force=False): + """Import scrobbles from a single BG Stats bgsplay file""" + if self.processed_finished and not force: + logger.info(f"{self} already processed on {self.processed_finished}") + return + + self.mark_started() + + import json + + from scrobbles.scrobblers import email_scrobble_board_game + + with open(self.upload_file_path, "r", encoding="utf-8") as f: + parsed_json = json.load(f) + scrobbles = email_scrobble_board_game(parsed_json, self.user_id) + + self.record_log(scrobbles) + self.mark_finished() + + class ScrobbleQuerySet(models.QuerySet): def with_related(self): return self.select_related("user").prefetch_related( diff --git a/vrobbler/apps/scrobbles/tasks.py b/vrobbler/apps/scrobbles/tasks.py index edd2982..c22e909 100644 --- a/vrobbler/apps/scrobbles/tasks.py +++ b/vrobbler/apps/scrobbles/tasks.py @@ -107,6 +107,16 @@ def process_retroarch_import(import_id): retroarch_import.process() +@shared_task +def process_bgstats_import(import_id): + BGStatsImport = apps.get_model("scrobbles", "BGStatsImport") + bgstats_import = BGStatsImport.objects.filter(id=import_id).first() + if not bgstats_import: + logger.warn(f"BGStatsImport not found with id {import_id}") + return + bgstats_import.process() + + @shared_task def process_lastfm_import(import_id): LastFmImport = apps.get_model("scrobbles", "LastFMImport") diff --git a/vrobbler/apps/scrobbles/urls.py b/vrobbler/apps/scrobbles/urls.py index e1b3fd4..b3afa06 100644 --- a/vrobbler/apps/scrobbles/urls.py +++ b/vrobbler/apps/scrobbles/urls.py @@ -126,6 +126,11 @@ urlpatterns = [ views.ScrobbleRetroarchImportDetailView.as_view(), name="retroarch-import-detail", ), + path( + "imports/bgstats//", + views.ScrobbleBGStatsImportDetailView.as_view(), + name="bgstats-import-detail", + ), path( "imports/scale//", views.ScrobbleScaleCSVImportDetailView.as_view(), diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.py index 3185a0e..147e1aa 100644 --- a/vrobbler/apps/scrobbles/views.py +++ b/vrobbler/apps/scrobbles/views.py @@ -69,11 +69,13 @@ from scrobbles.export import export_scrobbles from scrobbles.forms import ExportScrobbleForm, ScrobbleForm from scrobbles.models import ( AudioScrobblerTSVImport, + BGStatsImport, KoReaderImport, LastFmImport, RetroarchImport, ScaleCSVImport, Scrobble, + ScrobbleQuerySet, TrailGPXImport, ) from scrobbles.scrobblers import * @@ -459,6 +461,9 @@ class ScrobbleImportListView(TemplateView): context_data["retroarch_imports"] = RetroarchImport.objects.filter( user=self.request.user, ).order_by("-processing_started")[:10] + context_data["bgstats_imports"] = BGStatsImport.objects.filter( + user=self.request.user, + ).order_by("-processing_started")[:10] context_data["birding_csv_imports"] = apps.get_model( "birds", "BirdingCSVImport" ).objects.filter( @@ -491,6 +496,8 @@ class BaseScrobbleImportDetailView(DetailView): title = "LastFM Import" if self.model == RetroarchImport: title = "Retroarch Import" + if self.model == BGStatsImport: + title = "BG Stats Import" if self.model == ScaleCSVImport: title = "Scale CSV Import" if self.model == TrailGPXImport: @@ -515,6 +522,10 @@ class ScrobbleRetroarchImportDetailView(BaseScrobbleImportDetailView): model = RetroarchImport +class ScrobbleBGStatsImportDetailView(BaseScrobbleImportDetailView): + model = BGStatsImport + + class ScrobbleScaleCSVImportDetailView(BaseScrobbleImportDetailView): model = ScaleCSVImport diff --git a/vrobbler/templates/scrobbles/import_list.html b/vrobbler/templates/scrobbles/import_list.html index 68add84..bda2086 100644 --- a/vrobbler/templates/scrobbles/import_list.html +++ b/vrobbler/templates/scrobbles/import_list.html @@ -79,6 +79,31 @@ {% endif %} +{% if bgstats_imports %} +
+

BG Stats

+
+ + + + + + + + + {% for obj in bgstats_imports %} + + + + + + {% endfor %} + +
StartedFinishedScrobbles
{{obj.human_start}}{{obj.processed_finished}}{{obj.process_count}}
+
+
+{% endif %} + {% if koreader_imports %}

KOReader