sessions / f6a20b67a6e5ae0e47953836ba2568e06e3ed6c4

/home/powellc/src/code.unbl.ink/secstate/vrobbler

This commit has no recorded session.

diff

ignore whitespace

commit f6a20b67a6e5ae0e47953836ba2568e06e3ed6c4Author: Colin Powell <colin@unbl.ink>Date:   Tue Jul 14 12:53:27 2026 -0400    [drinks] Add coffee migration scriptdiff --git a/PROJECT.org b/PROJECT.orgindex 96a37e8..ac3cbae 100644--- a/PROJECT.org+++ b/PROJECT.org@@ -88,7 +88,7 @@ fetching and simple saving. *** Metadata sources **** Scraper -* Backlog [0/24] :vrobbler:project:personal:+* Backlog [1/25] :vrobbler:project:personal: ** TODO [#C] After transition to linux add curl_cffi as webpage scrapper again :webpages:metadata: ** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles: :PROPERTIES:@@ -619,6 +619,27 @@ The Edit log form should have from top to bottom:   - Expansion ids (which should a multi-select widget of expansions for this game)   - Location (which should be a drop down of BoardGameLocations for this user) +** DONE [#B] Migrate coffee scrobbles to new Coffee model :drinks:mgmtcmd:+:PROPERTIES:+:ID:       edaf1200-b58e-8512-b2a2-c8bc25bed7a0+:END:++*** Description++Food ID 10 is "French Press Coffee with Sugar" and we should convert all of+these scrobbles to scrobbles of a Coffee, but to figure out which coffee, we can+use scrobbles of Task "Chore" where the title is "Roast coffee" and the notes+are not null. In this case, the first note on each one has a Coffee title like+"Mexico - Veracruz Huatusco" where the Origin is Mexico and the title is+"Veracruz Huatusco"++We should create a new Coffee for each Chore with the title of "Roast Coffee"+and then for each "French Press Coffee with Sugar", swap the Food ID 10 for the+Coffee ID of the latest Coffee created from a Chore. If a new Chore is found,+start updating the French Press scrobbles with a newly created Coffee.++If we don't have a roast log in the Chore tasks before a French Press entry, we should use the "Unknown Roast" Coffee title and skip all the othe metadata.+ * Version 60.0 [2/2] ** DONE [#B] Send water drinking notifications :drinks:notifications: :PROPERTIES:diff --git a/vrobbler/apps/drinks/management/commands/migrate_coffee_scrobbles.py b/vrobbler/apps/drinks/management/commands/migrate_coffee_scrobbles.pynew file mode 100644index 0000000..8850da1--- /dev/null+++ b/vrobbler/apps/drinks/management/commands/migrate_coffee_scrobbles.py@@ -0,0 +1,474 @@+from bisect import bisect_right++from django.core.management.base import BaseCommand+from drinks.models import BrewingMethod, Coffee, CoffeeRoaster, RoastLevel+from scrobbles.models import FavoriteMedia, Scrobble++BASE_RUN_TIME_SECONDS = 2400++# French Press (Food ID 10)+FOOD_ID_FRENCH_PRESS = 10+UNKNOWN_ROAST_TITLE = "Unknown Roast"+TIMBERWYCK_ROASTER_ID = 1+FRENCH_PRESS_CALORIES = 50+FRENCH_PRESS_BASE_TAGS = {"sweet maria", "green coffee"}++# Medium Roast (Food ID 27)+FOOD_ID_MEDIUM_ROAST = 27+MEDIUM_ROAST_TITLE = "Medium Roast"+MEDIUM_ROAST_ROASTER_NAME = "Dunkin' Donuts"+MEDIUM_ROAST_CALORIES = 150+MEDIUM_ROAST_BASE_TAGS = {"dunkin"}++# Lavazza (Food ID 12)+FOOD_ID_LAVAZZA = 12+LAVAZZA_TITLE = "Qualità Oro"+LAVAZZA_ROASTER_NAME = "Lavazza"+LAVAZZA_ORIGIN = "Blended"+LAVAZZA_CALORIES = 50+LAVAZZA_BASE_TAGS = {"lavazza", "espresso"}++# Drip Coffee (Food ID 61)+FOOD_ID_DRIP_COFFEE = 61+DRIP_COFFEE_CALORIES = 50+++class Command(BaseCommand):+    help = (+        "Migrate French Press, Medium Roast, and Lavazza food scrobbles "+        "to Coffee scrobbles."+    )++    def add_arguments(self, parser):+        parser.add_argument(+            "--dry-run",+            action="store_true",+            help="Show what would be changed without making changes",+        )++    def handle(self, *args, **options):+        dry_run = options["dry_run"]++        if dry_run:+            self.stdout.write(+                self.style.WARNING("DRY RUN - no changes will be saved\n")+            )++        # French Press migration+        self.stdout.write(self.style.NOTICE("=== French Press Coffee ==="))+        french_migrated, french_skipped = self._migrate_french_press(dry_run)++        # Medium Roast migration+        self.stdout.write(self.style.NOTICE("\n=== Medium Roast ==="))+        medium_migrated, medium_skipped = self._migrate_medium_roast(dry_run)++        # Lavazza migration+        self.stdout.write(self.style.NOTICE("\n=== Lavazza ==="))+        lavazza_migrated, lavazza_skipped = self._migrate_lavazza(dry_run)++        # Drip Coffee migration+        self.stdout.write(self.style.NOTICE("\n=== Drip Coffee ==="))+        drip_migrated, drip_skipped = self._migrate_drip_coffee(dry_run)++        self.stdout.write("")+        total_migrated = (+            french_migrated + medium_migrated + lavazza_migrated + drip_migrated+        )+        total_skipped = french_skipped + medium_skipped + lavazza_skipped + drip_skipped+        if dry_run:+            self.stdout.write(+                self.style.WARNING(+                    f"Dry run: would migrate {total_migrated} scrobbles, "+                    f"skip {total_skipped} already migrated. "+                    f"Use without --dry-run to apply."+                )+            )+        else:+            self.stdout.write(+                self.style.SUCCESS(+                    f"Migrated {total_migrated} scrobbles, skipped {total_skipped} "+                    f"already migrated."+                )+            )++    def _migrate_french_press(self, dry_run):+        roast_coffees = self._build_roast_coffee_timeline()+        if not roast_coffees:+            self.stdout.write(+                self.style.WARNING("No 'Roast coffee' task scrobbles found. Aborting.")+            )+            return 0, 0++        unknown_roast = self._get_or_create_unknown_roast(dry_run)+        food_scrobbles = self._get_food_scrobbles(FOOD_ID_FRENCH_PRESS)+        favorite_foods = self._get_favorite_foods(FOOD_ID_FRENCH_PRESS)++        timestamps = [ts for ts, _ in roast_coffees]++        migrated = 0+        skipped = 0+        for scrobble in food_scrobbles.iterator():+            if scrobble.coffee_id is not None:+                skipped += 1+                continue++            idx = bisect_right(timestamps, scrobble.timestamp) - 1+            if idx >= 0:+                _, coffee = roast_coffees[idx]+            else:+                coffee = unknown_roast++            log = scrobble.log or {}+            log["brewing_method"] = BrewingMethod.FRENCH_PRESS+            log["roast_level"] = RoastLevel.FULL_CITY_PLUS++            self.stdout.write(+                f"  Scrobble {scrobble.id}: Food '{scrobble.food}' -> "+                f"Coffee '{coffee}' ({scrobble.timestamp})"+            )+            if not dry_run:+                scrobble.coffee = coffee+                scrobble.food = None+                scrobble.media_type = Scrobble.MediaType.COFFEE+                scrobble.log = log+                scrobble.save(update_fields=["coffee", "food", "media_type", "log"])+            migrated += 1++        self._migrate_favorites(+            favorite_foods, roast_coffees, timestamps, unknown_roast, dry_run+        )+        return migrated, skipped++    def _migrate_medium_roast(self, dry_run):+        coffee = self._get_or_create_medium_roast(dry_run)+        food_scrobbles = self._get_food_scrobbles(FOOD_ID_MEDIUM_ROAST)+        favorite_foods = self._get_favorite_foods(FOOD_ID_MEDIUM_ROAST)++        migrated = 0+        skipped = 0+        for scrobble in food_scrobbles.iterator():+            if scrobble.coffee_id is not None:+                skipped += 1+                continue++            log = scrobble.log or {}+            log["brewing_method"] = BrewingMethod.DRIP+            log["roast_level"] = RoastLevel.FULL_CITY++            self.stdout.write(+                f"  Scrobble {scrobble.id}: Food '{scrobble.food}' -> "+                f"Coffee '{coffee}' ({scrobble.timestamp})"+            )+            if not dry_run:+                scrobble.coffee = coffee+                scrobble.food = None+                scrobble.media_type = Scrobble.MediaType.COFFEE+                scrobble.log = log+                scrobble.save(update_fields=["coffee", "food", "media_type", "log"])+            migrated += 1++        self._migrate_medium_roast_favorites(favorite_foods, coffee, dry_run)+        return migrated, skipped++    def _migrate_lavazza(self, dry_run):+        coffee = self._get_or_create_lavazza(dry_run)+        food_scrobbles = self._get_food_scrobbles(FOOD_ID_LAVAZZA)+        favorite_foods = self._get_favorite_foods(FOOD_ID_LAVAZZA)++        migrated = 0+        skipped = 0+        for scrobble in food_scrobbles.iterator():+            if scrobble.coffee_id is not None:+                skipped += 1+                continue++            log = scrobble.log or {}+            log["brewing_method"] = BrewingMethod.MOKA_POT++            self.stdout.write(+                f"  Scrobble {scrobble.id}: Food '{scrobble.food}' -> "+                f"Coffee '{coffee}' ({scrobble.timestamp})"+            )+            if not dry_run:+                scrobble.coffee = coffee+                scrobble.food = None+                scrobble.media_type = Scrobble.MediaType.COFFEE+                scrobble.log = log+                scrobble.save(update_fields=["coffee", "food", "media_type", "log"])+            migrated += 1++        self._migrate_lavazza_favorites(favorite_foods, coffee, dry_run)+        return migrated, skipped++    def _migrate_lavazza_favorites(self, favorites, coffee, dry_run):+        updated = 0+        for fav in favorites:+            self.stdout.write(+                f"  FavoriteMedia {fav.id}: Food '{fav.food}' -> Coffee '{coffee}'"+            )+            if not dry_run:+                fav.coffee = coffee+                fav.food = None+                fav.media_type = Scrobble.MediaType.COFFEE+                fav.save(update_fields=["coffee", "food", "media_type"])+            updated += 1++        if updated:+            self.stdout.write(+                f"  {'Would update' if dry_run else 'Updated'} {updated} FavoriteMedia records"+            )++    def _migrate_drip_coffee(self, dry_run):+        roast_coffees = self._build_roast_coffee_timeline()+        if not roast_coffees:+            self.stdout.write(+                self.style.WARNING("No 'Roast coffee' task scrobbles found. Aborting.")+            )+            return 0, 0++        unknown_roast = self._get_or_create_unknown_roast(dry_run)+        food_scrobbles = self._get_food_scrobbles(FOOD_ID_DRIP_COFFEE)+        favorite_foods = self._get_favorite_foods(FOOD_ID_DRIP_COFFEE)++        timestamps = [ts for ts, _ in roast_coffees]++        migrated = 0+        skipped = 0+        for scrobble in food_scrobbles.iterator():+            if scrobble.coffee_id is not None:+                skipped += 1+                continue++            idx = bisect_right(timestamps, scrobble.timestamp) - 1+            if idx >= 0:+                _, coffee = roast_coffees[idx]+            else:+                coffee = unknown_roast++            log = scrobble.log or {}+            log["brewing_method"] = BrewingMethod.DRIP+            log["roast_level"] = RoastLevel.CITY++            self.stdout.write(+                f"  Scrobble {scrobble.id}: Food '{scrobble.food}' -> "+                f"Coffee '{coffee}' ({scrobble.timestamp})"+            )+            if not dry_run:+                scrobble.coffee = coffee+                scrobble.food = None+                scrobble.media_type = Scrobble.MediaType.COFFEE+                scrobble.log = log+                scrobble.save(update_fields=["coffee", "food", "media_type", "log"])+            migrated += 1++        self._migrate_drip_coffee_favorites(+            favorite_foods, roast_coffees, timestamps, unknown_roast, dry_run+        )+        return migrated, skipped++    def _migrate_drip_coffee_favorites(+        self, favorites, roast_coffees, timestamps, unknown_roast, dry_run+    ):+        updated = 0+        for fav in favorites:+            idx = bisect_right(timestamps, fav.created) - 1+            coffee = roast_coffees[idx][1] if idx >= 0 else unknown_roast++            self.stdout.write(+                f"  FavoriteMedia {fav.id}: Food '{fav.food}' -> Coffee '{coffee}'"+            )+            if not dry_run:+                fav.coffee = coffee+                fav.food = None+                fav.media_type = Scrobble.MediaType.COFFEE+                fav.save(update_fields=["coffee", "food", "media_type"])+            updated += 1++        if updated:+            self.stdout.write(+                f"  {'Would update' if dry_run else 'Updated'} {updated} FavoriteMedia records"+            )++    def _get_or_create_lavazza(self, dry_run):+        roaster, _ = CoffeeRoaster.objects.get_or_create(name=LAVAZZA_ROASTER_NAME)+        defaults = {+            "base_run_time_seconds": BASE_RUN_TIME_SECONDS,+            "roaster": roaster,+            "calories": LAVAZZA_CALORIES,+            "origin": LAVAZZA_ORIGIN,+        }++        if dry_run:+            try:+                return Coffee.objects.get(title=LAVAZZA_TITLE, roaster=roaster)+            except Coffee.DoesNotExist:+                return Coffee(title=LAVAZZA_TITLE, **defaults)++        coffee, created = Coffee.objects.get_or_create(+            title=LAVAZZA_TITLE,+            roaster=roaster,+            defaults=defaults,+        )+        if created:+            tags = set(LAVAZZA_TITLE.lower().split()) | LAVAZZA_BASE_TAGS+            coffee.tags.add(*tags)+            self.stdout.write(f"  Created Coffee: '{LAVAZZA_TITLE}' (tags={tags})")+        return coffee++    def _migrate_medium_roast_favorites(self, favorites, coffee, dry_run):+        updated = 0+        for fav in favorites:+            self.stdout.write(+                f"  FavoriteMedia {fav.id}: Food '{fav.food}' -> Coffee '{coffee}'"+            )+            if not dry_run:+                fav.coffee = coffee+                fav.food = None+                fav.media_type = Scrobble.MediaType.COFFEE+                fav.save(update_fields=["coffee", "food", "media_type"])+            updated += 1++        if updated:+            self.stdout.write(+                f"  {'Would update' if dry_run else 'Updated'} {updated} FavoriteMedia records"+            )++    def _build_roast_coffee_timeline(self):+        """Find Task scrobbles where task title='Chore' and log title='Roast coffee'.++        Returns a list of (timestamp, Coffee) sorted by timestamp.+        """+        task_scrobbles = (+            Scrobble.objects.filter(+                media_type=Scrobble.MediaType.TASK,+                task__title="Chore",+                log__title="Roast coffee",+            )+            .exclude(log__notes=None)+            .exclude(log__notes=[])+            .order_by("timestamp")+        )++        coffees = []+        for scrobble in task_scrobbles:+            notes = scrobble.log.get("notes") or []+            if not notes:+                continue++            first_note = notes[0] if isinstance(notes, list) else str(notes)+            origin, title = self._parse_coffee_name(first_note)+            coffee = self._get_or_create_coffee(title, origin)+            coffees.append((scrobble.timestamp, coffee))++        return coffees++    def _parse_coffee_name(self, note):+        """Parse 'Mexico - Veracruz Huatusco' into (origin, title)."""+        if " - " in note:+            parts = note.split(" - ", 1)+            return parts[0].strip(), parts[1].strip()+        return None, note.strip()++    def _get_or_create_coffee(self, title, origin):+        coffee, created = Coffee.objects.get_or_create(+            title=title,+            origin=origin,+            defaults={+                "base_run_time_seconds": BASE_RUN_TIME_SECONDS,+                "roaster_id": TIMBERWYCK_ROASTER_ID,+                "calories": FRENCH_PRESS_CALORIES,+            },+        )+        if created:+            tags = set(title.lower().split()) | FRENCH_PRESS_BASE_TAGS+            coffee.tags.add(*tags)+            self.stdout.write(+                f"  Created Coffee: '{title}' (origin={origin}, tags={tags})"+            )+        return coffee++    def _get_or_create_unknown_roast(self, dry_run):+        defaults = {+            "base_run_time_seconds": BASE_RUN_TIME_SECONDS,+            "roaster_id": TIMBERWYCK_ROASTER_ID,+            "calories": FRENCH_PRESS_CALORIES,+        }++        if dry_run:+            try:+                return Coffee.objects.get(title=UNKNOWN_ROAST_TITLE)+            except Coffee.DoesNotExist:+                return Coffee(title=UNKNOWN_ROAST_TITLE, **defaults)++        coffee, created = Coffee.objects.get_or_create(+            title=UNKNOWN_ROAST_TITLE,+            defaults=defaults,+        )+        if created:+            tags = {"unknown", "roast"} | FRENCH_PRESS_BASE_TAGS+            coffee.tags.add(*tags)+            self.stdout.write(+                f"  Created Coffee: '{UNKNOWN_ROAST_TITLE}' (tags={tags})"+            )+        return coffee++    def _get_or_create_medium_roast(self, dry_run):+        roaster, _ = CoffeeRoaster.objects.get_or_create(name=MEDIUM_ROAST_ROASTER_NAME)+        defaults = {+            "base_run_time_seconds": BASE_RUN_TIME_SECONDS,+            "roaster": roaster,+            "calories": MEDIUM_ROAST_CALORIES,+            "origin": None,+        }++        if dry_run:+            try:+                return Coffee.objects.get(title=MEDIUM_ROAST_TITLE, roaster=roaster)+            except Coffee.DoesNotExist:+                return Coffee(title=MEDIUM_ROAST_TITLE, **defaults)++        coffee, created = Coffee.objects.get_or_create(+            title=MEDIUM_ROAST_TITLE,+            roaster=roaster,+            defaults=defaults,+        )+        if created:+            tags = set(MEDIUM_ROAST_TITLE.lower().split()) | MEDIUM_ROAST_BASE_TAGS+            coffee.tags.add(*tags)+            self.stdout.write(f"  Created Coffee: '{MEDIUM_ROAST_TITLE}' (tags={tags})")+        return coffee++    def _get_food_scrobbles(self, food_id):+        return Scrobble.objects.filter(+            food_id=food_id,+            media_type=Scrobble.MediaType.FOOD,+        ).order_by("timestamp")++    def _get_favorite_foods(self, food_id):+        return FavoriteMedia.objects.filter(+            food_id=food_id,+            media_type=Scrobble.MediaType.FOOD,+        )++    def _migrate_favorites(+        self, favorites, roast_coffees, timestamps, unknown_roast, dry_run+    ):+        updated = 0+        for fav in favorites:+            idx = bisect_right(timestamps, fav.created) - 1+            coffee = roast_coffees[idx][1] if idx >= 0 else unknown_roast++            self.stdout.write(+                f"  FavoriteMedia {fav.id}: Food '{fav.food}' -> Coffee '{coffee}'"+            )+            if not dry_run:+                fav.coffee = coffee+                fav.food = None+                fav.media_type = Scrobble.MediaType.COFFEE+                fav.save(update_fields=["coffee", "food", "media_type"])+            updated += 1++        if updated:+            self.stdout.write(+                f"  {'Would update' if dry_run else 'Updated'} {updated} FavoriteMedia records"+            )diff --git a/vrobbler/apps/drinks/management/commands/populate_demo_data.py b/vrobbler/apps/drinks/management/commands/populate_demo_data.pyindex 2d23286..85f18aa 100644--- a/vrobbler/apps/drinks/management/commands/populate_demo_data.py+++ b/vrobbler/apps/drinks/management/commands/populate_demo_data.py@@ -486,7 +486,7 @@ class Command(BaseCommand):                     playback_position_seconds=media.base_run_time_seconds or 900,                     played_to_completion=True,                     source=source,-                    log={"drink_log": log_data},+                    log=log_data,                     **{fk_field: media},                 )                 scrobbles_created += 1diff --git a/vrobbler/apps/drinks/models.py b/vrobbler/apps/drinks/models.pyindex b1ecbe1..ba631e0 100644--- a/vrobbler/apps/drinks/models.py+++ b/vrobbler/apps/drinks/models.py@@ -40,12 +40,18 @@ class DrinkFormat(models.TextChoices):     DRAFT = "draft", "Draft"     GROWLER = "growler", "Growler"     BOX = "box", "Box"+    MUG = "mug", "Mug"   class RoastLevel(models.TextChoices):-    LIGHT = "light", "Light"-    MEDIUM = "medium", "Medium"-    DARK = "dark", "Dark"+    CINNAMON = "cinnamon", "Cinnamon"+    CITY = "city", "City"+    CITY_PLUS = "city_plus", "City+"+    FULL_CITY = "full_city", "Full City"+    FULL_CITY_PLUS = "full_city_plus", "Full City+"+    VIENNA = "vienna", "Vienna"+    FRENCH = "french", "French"+    SPANISH = "spanish", "Spanish"   class BrewingMethod(models.TextChoices):