sessions / 740e5268ddec24ff2777c4e58dbdf207673a18e3

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

This commit has no recorded session.

diff

ignore whitespace

commit 740e5268ddec24ff2777c4e58dbdf207673a18e3Author: Colin Powell <colin@unbl.ink>Date:   Tue Jul 14 21:05:22 2026 -0400    [drinks] Add better wine lookupsdiff --git a/PROJECT.org b/PROJECT.orgindex 846fa2c..47f8222 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/26] :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,10 @@ 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 Use FastCork to lookup wine data :drinks:wine:metadata:+:PROPERTIES:+:ID:       add9a951-d957-415d-b41e-feee40479774+:END: * Version 60.1 [1/1] ** DONE [#B] Migrate coffee scrobbles to new Coffee model :drinks:mgmtcmd: :PROPERTIES:diff --git a/vrobbler.conf.example b/vrobbler.conf.exampleindex 808987e..eac266b 100644--- a/vrobbler.conf.example+++ b/vrobbler.conf.example@@ -25,6 +25,7 @@ VROBBLER_TODOIST_CLIENT_ID="<id>" VROBBLER_TODOIST_CLIENT_SECRET="<key>" VROBBLER_GOOGLE_API_KEY="<key>" VROBBLER_LICHESS_API_KEY = "<key>"+VROBBLER_FASTCORK_API_KEY="fc_<key>"  # Storages # VROBBLER_DATABASE_URL="postgres://USER:PASSWORD@HOST:PORT/NAME"diff --git a/vrobbler/apps/drinks/fastcork.py b/vrobbler/apps/drinks/fastcork.pynew file mode 100644index 0000000..4eef4f9--- /dev/null+++ b/vrobbler/apps/drinks/fastcork.py@@ -0,0 +1,47 @@+import logging++import requests+from django.conf import settings++logger = logging.getLogger(__name__)++FASTCORK_BASE_URL = "https://fastcork.com"+FASTCORK_SEARCH_URL = f"{FASTCORK_BASE_URL}/v1/search"+++def search_wines(query: str) -> list[dict]:+    api_key = settings.FASTCORK_API_KEY+    if not api_key:+        logger.warning("FASTCORK_API_KEY is not configured")+        return []++    headers = {+        "Authorization": f"Bearer {api_key}",+        "Content-Type": "application/json",+    }+    payload = {"query": query, "lang": "en"}++    try:+        response = requests.post(+            FASTCORK_SEARCH_URL, headers=headers, json=payload, timeout=10+        )+    except requests.RequestException:+        logger.exception("FastCork search request failed")+        return []++    if response.status_code != 200:+        logger.warning(+            "Bad response from FastCork search",+            extra={"status_code": response.status_code},+        )+        return []++    data = response.json()+    if not data.get("success"):+        logger.warning(+            "FastCork search returned success=false",+            extra={"response": data},+        )+        return []++    return data.get("results", {}).get("query", [])diff --git a/vrobbler/apps/drinks/migrations/0011_add_wine_fastcork_fields.py b/vrobbler/apps/drinks/migrations/0011_add_wine_fastcork_fields.pynew file mode 100644index 0000000..5f3f9b6--- /dev/null+++ b/vrobbler/apps/drinks/migrations/0011_add_wine_fastcork_fields.py@@ -0,0 +1,43 @@+# Generated by Django 4.2.29 on 2026-07-15 00:34++from django.db import migrations, models+++class Migration(migrations.Migration):++    dependencies = [+        ("drinks", "0010_alter_beerproducer_options_alter_beerstyle_options_and_more"),+    ]++    operations = [+        migrations.AddField(+            model_name="wine",+            name="abv",+            field=models.FloatField(blank=True, null=True),+        ),+        migrations.AddField(+            model_name="wine",+            name="aroma",+            field=models.TextField(blank=True, null=True),+        ),+        migrations.AddField(+            model_name="wine",+            name="decanting_time_minutes",+            field=models.SmallIntegerField(blank=True, null=True),+        ),+        migrations.AddField(+            model_name="wine",+            name="food_pairing",+            field=models.TextField(blank=True, null=True),+        ),+        migrations.AddField(+            model_name="wine",+            name="serving_temperature_max_celsius",+            field=models.FloatField(blank=True, null=True),+        ),+        migrations.AddField(+            model_name="wine",+            name="serving_temperature_min_celsius",+            field=models.FloatField(blank=True, null=True),+        ),+    ]diff --git a/vrobbler/apps/drinks/models.py b/vrobbler/apps/drinks/models.pyindex ba631e0..2d9f83a 100644--- a/vrobbler/apps/drinks/models.py+++ b/vrobbler/apps/drinks/models.py@@ -367,6 +367,12 @@ class Wine(ScrobblableMixin):     cellartracker_id = models.CharField(max_length=255, **BNULL)     cellartracker_rating = models.FloatField(**BNULL)     cellartracker_image = models.ImageField(upload_to="drinks/cellartracker/", **BNULL)+    abv = models.FloatField(**BNULL)+    aroma = models.TextField(**BNULL)+    food_pairing = models.TextField(**BNULL)+    serving_temperature_min_celsius = models.FloatField(**BNULL)+    serving_temperature_max_celsius = models.FloatField(**BNULL)+    decanting_time_minutes = models.SmallIntegerField(**BNULL)      def get_absolute_url(self) -> str:         return reverse("drinks:wine_detail", kwargs={"slug": self.uuid})@@ -475,6 +481,66 @@ class Wine(ScrobblableMixin):          return wine +    @classmethod+    def find_or_create_from_search(cls, wine_data: dict) -> "Wine":+        title = wine_data.get("full_wine_name", "")+        producer_name = wine_data.get("winery", "")++        wine = None+        if title and producer_name:+            wine = cls.objects.filter(title=title, producer__name=producer_name).first()+        elif title:+            wine = cls.objects.filter(title=title).first()++        if wine:+            return wine++        producer = None+        if producer_name:+            producer, _ = WineProducer.objects.get_or_create(+                name=producer_name,+                defaults={+                    "description": wine_data.get("winery_description", ""),+                },+            )+            if not producer.description and wine_data.get("winery_description"):+                producer.description = wine_data["winery_description"]+                producer.save()++        region = None+        region_name = wine_data.get("region", "")+        if region_name:+            region, _ = WineRegion.objects.get_or_create(name=region_name)++        wine = cls.objects.create(+            title=title,+            description=wine_data.get("tasting_notes", ""),+            wine_type=wine_data.get("wine_type", "").lower(),+            vintage=wine_data.get("vintage", ""),+            producer=producer,+            region=region,+            abv=wine_data.get("alc_percentage"),+            aroma=wine_data.get("aroma", ""),+            food_pairing=wine_data.get("food_pairing", ""),+            serving_temperature_min_celsius=wine_data.get(+                "serving_temperature_celcius_range", {}+            ).get("min_temp"),+            serving_temperature_max_celsius=wine_data.get(+                "serving_temperature_celcius_range", {}+            ).get("max_temp"),+            decanting_time_minutes=wine_data.get("decanting_time_minutes"),+        )++        grape_variety = wine_data.get("grape_variety", "")+        if grape_variety:+            for grape_name in grape_variety.split(","):+                grape_name = grape_name.strip()+                if grape_name:+                    grape, _ = WineGrape.objects.get_or_create(name=grape_name)+                    wine.grapes.add(grape)++        return wine+     def scrobbles(self, user_id):         Scrobble = apps.get_model("scrobbles", "Scrobble")         return Scrobble.objects.filter(user_id=user_id, wine=self).order_by(diff --git a/vrobbler/apps/drinks/urls.py b/vrobbler/apps/drinks/urls.pyindex 4b2fb86..a9be57b 100644--- a/vrobbler/apps/drinks/urls.py+++ b/vrobbler/apps/drinks/urls.py@@ -12,6 +12,16 @@ urlpatterns = [         name="beer_detail",     ),     path("wines/", views.WineListView.as_view(), name="wine_list"),+    path(+        "wines/search/",+        views.WineSearchView.as_view(),+        name="wine_search",+    ),+    path(+        "wines/scrobble-from-search/",+        views.WineScrobbleFromSearchView.as_view(),+        name="wine_scrobble_from_search",+    ),     path(         "wines/<slug:slug>/",         views.WineDetailView.as_view(),diff --git a/vrobbler/apps/drinks/views.py b/vrobbler/apps/drinks/views.pyindex 427ff0a..15def25 100644--- a/vrobbler/apps/drinks/views.py+++ b/vrobbler/apps/drinks/views.py@@ -1,6 +1,11 @@ from django.contrib import messages from django.http import HttpResponseRedirect+from django.shortcuts import render+from django.urls import reverse+from django.utils import timezone from django.views import View+from django.views.generic import TemplateView+from drinks.fastcork import search_wines from drinks.models import Beer, Coffee, Drink, Wine from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView @@ -56,3 +61,64 @@ class QuickWaterScrobbleView(View):      def get(self, request, *args, **kwargs):         return self.post(request, *args, **kwargs)+++class WineSearchView(TemplateView):+    template_name = "drinks/wine_search.html"++    def get_context_data(self, **kwargs):+        context = super().get_context_data(**kwargs)+        query = self.request.GET.get("q", "").strip()+        context["query"] = query++        if query:+            results = search_wines(query)+            context["results"] = results+            self.request.session["wine_search_results"] = results+            self.request.session["wine_search_query"] = query++        return context+++class WineScrobbleFromSearchView(View):+    def post(self, request, *args, **kwargs):+        if not request.user.is_authenticated:+            messages.error(request, "You must be logged in to scrobble wine.")+            return HttpResponseRedirect("/")++        wine_index = request.POST.get("wine_index")+        query = request.POST.get("query", "")+        results = request.session.get("wine_search_results", [])++        try:+            wine_index = int(wine_index)+            wine_data = results[wine_index]+        except (TypeError, IndexError, ValueError):+            messages.error(request, "Invalid wine selection.")+            return HttpResponseRedirect(+                reverse("drinks:wine_search") + f"?q={query}" if query else "/"+            )++        from scrobbles.models import Scrobble++        wine = Wine.find_or_create_from_search(wine_data)++        if not wine:+            messages.error(request, "Could not create wine from search result.")+            return HttpResponseRedirect(+                reverse("drinks:wine_search") + f"?q={query}" if query else "/"+            )++        scrobble_dict = {+            "user_id": request.user.id,+            "timestamp": timezone.now(),+            "playback_position_seconds": 0,+            "source": "FastCork",+        }+        scrobble = Scrobble.create_or_update(wine, request.user.id, scrobble_dict)++        if scrobble:+            return HttpResponseRedirect(scrobble.redirect_url(request.user.id))++        messages.error(request, "Failed to create scrobble.")+        return HttpResponseRedirect(reverse("drinks:wine_list"))diff --git a/vrobbler/apps/scrobbles/constants.py b/vrobbler/apps/scrobbles/constants.pyindex c89cbdf..bc85c59 100644--- a/vrobbler/apps/scrobbles/constants.py+++ b/vrobbler/apps/scrobbles/constants.py@@ -69,7 +69,7 @@ SCROBBLE_CONTENT_URLS = {     "-s": ["https://www.thesportsdb.com/event/"],     "-g": ["https://boardgamegeek.com/boardgame/"],     "-u": ["https://untappd.com/"],-    "-wi": ["https://www.vivino.com/", "https://www.cellartracker.com/"],+    "-wi": [],     "-co": ["https://roastdb.com/"],     "-b": ["https://www.amazon.com/"],     "-t": ["https://app.todoist.com/app/task/{id}"],diff --git a/vrobbler/apps/scrobbles/scrobblers.py b/vrobbler/apps/scrobbles/scrobblers.pyindex 506a78c..ddb2ae5 100644--- a/vrobbler/apps/scrobbles/scrobblers.py+++ b/vrobbler/apps/scrobbles/scrobblers.py@@ -649,22 +649,7 @@ def manual_scrobble_from_url(      scrobble_fn = MANUAL_SCROBBLE_FNS[content_key] -    if content_key == "-wi":-        if "cellartracker.com" in url:-            return manual_scrobble_wine(-                cellartracker_id=item_id,-                user_id=user_id,-                source="CellarTracker",-                action=action,-            )-        else:-            return manual_scrobble_wine(-                vivino_id=item_id,-                user_id=user_id,-                source="Vivino",-                action=action,-            )-    elif content_key == "-co":+    if content_key == "-co":         return manual_scrobble_coffee(             roastdb_url=url,             user_id=user_id,@@ -1281,17 +1266,29 @@ def manual_scrobble_beer(   def manual_scrobble_wine(+    search_query: str = None,     vivino_id: str = None,     cellartracker_id: str = None,     user_id: int = None,-    source: str = "Vivino",+    source: str = "FastCork",     action: Optional[str] = None, ):-    wine = Wine.find_or_create(vivino_id=vivino_id, cellartracker_id=cellartracker_id)+    wine = None++    if search_query:+        from drinks.fastcork import search_wines++        results = search_wines(search_query)+        if results:+            wine = Wine.find_or_create_from_search(results[0])+    else:+        wine = Wine.find_or_create(+            vivino_id=vivino_id, cellartracker_id=cellartracker_id+        )      if not wine:         logger.error(-            f"No wine found for ID vivino={vivino_id} cellartracker={cellartracker_id}"+            f"No wine found for query={search_query} vivino={vivino_id} cellartracker={cellartracker_id}"         )         return diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.pyindex 95558ce..6cb2c9b 100644--- a/vrobbler/apps/scrobbles/views.py+++ b/vrobbler/apps/scrobbles/views.py@@ -636,6 +636,10 @@ class ManualScrobbleView(FormView):         else:             key = item_str[:2]             item_id = item_str[3:]++        if key == "-wi":+            return HttpResponseRedirect(reverse("drinks:wine_search") + f"?q={item_id}")+         scrobble_fn = MANUAL_SCROBBLE_FNS[key]         scrobble = eval(scrobble_fn)(item_id, self.request.user.id) diff --git a/vrobbler/settings.py b/vrobbler/settings.pyindex 6ef61b9..d6fc699 100644--- a/vrobbler/settings.py+++ b/vrobbler/settings.py@@ -87,6 +87,8 @@ AMAZON_PAAPI_SECRET_KEY = os.getenv("VROBBLER_AMAZON_PAAPI_SECRET_KEY", "") AMAZON_PAAPI_ASSOCIATE_TAG = os.getenv("VROBBLER_AMAZON_PAAPI_ASSOCIATE_TAG", "") AMAZON_PAAPI_COUNTRY = os.getenv("VROBBLER_AMAZON_PAAPI_COUNTRY", "US") +FASTCORK_API_KEY = os.getenv("VROBBLER_FASTCORK_API_KEY", "")+ DEFAULT_TASK_CONTEXT_TAGS = [     "Dev",     "Home",diff --git a/vrobbler/templates/drinks/wine_detail.html b/vrobbler/templates/drinks/wine_detail.htmlindex 4daf718..7c92c00 100644--- a/vrobbler/templates/drinks/wine_detail.html+++ b/vrobbler/templates/drinks/wine_detail.html@@ -34,11 +34,8 @@         <hr />         {% endif %}         <p style="float:right;">-            {% if object.vivino_link %}-            <a href="{{object.vivino_link}}"><img src="{% static "images/vivino-logo.png" %}" width=35></a>-            {% endif %}-            {% if object.cellartracker_link %}-            <a href="{{object.cellartracker_link}}">CellarTracker</a>+            {% if object.abv %}+            <span class="text-muted">{{ object.abv }}% ABV</span>             {% endif %}         </p>     </div>diff --git a/vrobbler/templates/drinks/wine_search.html b/vrobbler/templates/drinks/wine_search.htmlnew file mode 100644index 0000000..79aecaf--- /dev/null+++ b/vrobbler/templates/drinks/wine_search.html@@ -0,0 +1,85 @@+{% extends "base.html" %}+{% load static %}++{% block head_extra %}+<style>+    .search-container { margin-bottom: 2rem; }+    .result-item {+        padding: 1rem;+        border-bottom: 1px solid #dee2e6;+        display: flex;+        justify-content: space-between;+        align-items: flex-start;+    }+    .result-item:last-child { border-bottom: none; }+    .result-title { font-weight: 600; margin-bottom: 0.25rem; }+    .result-meta { font-size: 0.875rem; color: #6c757d; }+    .result-detail { font-size: 0.875rem; color: #6c757d; margin-top: 0.25rem; }+    .result-notes { font-size: 0.875rem; margin-top: 0.5rem; font-style: italic; }+    .no-results { padding: 2rem; text-align: center; color: #6c757d; }+</style>+{% endblock %}++{% block content %}+<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">+    <div+        class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">+        <h1 class="h2">Search Wines</h1>+    </div>++<div class="container" style="margin-bottom: 100px;">+    <form method="get" action="{% url 'drinks:wine_search' %}" class="search-container">+        <div class="row">+            <div class="col-md-8">+                <input type="text"+                       name="q"+                       class="form-control form-control-lg"+                       placeholder="Search for a wine..."+                       value="{{ query }}">+            </div>+            <div class="col-md-4">+                <button type="submit" class="btn btn-primary btn-lg">Search</button>+            </div>+        </div>+    </form>++    {% if query %}+    <div class="search-results">+        {% if results %}+        <p class="text-muted">{{ results|length }} result{{ results|length|pluralize }}</p>+        {% for wine in results %}+        <div class="result-item">+            <div>+                <div class="result-title">{{ wine.full_wine_name }}</div>+                <div class="result-meta">+                    {{ wine.winery }} &middot; {{ wine.region }} &middot; {{ wine.vintage }}+                </div>+                <div class="result-detail">+                    {{ wine.wine_type }} &middot; {{ wine.grape_variety }} &middot; {{ wine.alc_percentage }}% ABV+                </div>+                {% if wine.tasting_notes %}+                <div class="result-notes">{{ wine.tasting_notes }}</div>+                {% endif %}+            </div>+            <form method="post" action="{% url 'drinks:wine_scrobble_from_search' %}">+                {% csrf_token %}+                <input type="hidden" name="wine_index" value="{{ forloop.counter0 }}">+                <input type="hidden" name="query" value="{{ query }}">+                <button type="submit" class="btn btn-sm btn-outline-success">Scrobble</button>+            </form>+        </div>+        {% endfor %}+        {% else %}+        <div class="no-results">+            No wines found matching your search.+        </div>+        {% endif %}+    </div>+    {% else %}+    <div class="no-results">+        <p>Enter a wine name to search via FastCork.</p>+    </div>+    {% endif %}+</div>+</main>+{% endblock %}