Compare commits

...

2 Commits
60.1 ... 60.2

Author SHA1 Message Date
abf0eae493 [release] Bump to version 60.2
All checks were successful
ci / test (push) Successful in 2m31s
ci / build-and-deploy (push) Successful in 2m6s
- Use FastCork to lookup wine data
2026-07-14 21:05:46 -04:00
740e5268dd [drinks] Add better wine lookups 2026-07-14 21:05:22 -04:00
14 changed files with 351 additions and 27 deletions

View File

@ -88,7 +88,7 @@ fetching and simple saving.
*** Metadata sources
**** Scraper
* Backlog [0/24] :vrobbler:project:personal:
* Backlog [0/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,12 @@ 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)
* Version 60.2 [1/1]
** 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:

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "vrobbler"
version = "60.1"
version = "60.2"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]

View File

@ -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"

View File

@ -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", [])

View File

@ -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),
),
]

View File

@ -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(

View File

@ -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(),

View File

@ -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"))

View File

@ -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}"],

View File

@ -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

View File

@ -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)

View File

@ -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",

View File

@ -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>

View File

@ -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 %}