feat: add Open Food Facts search selector for food and drinks

- Add openfoodfacts Python SDK dependency
- Create foods/sources/openfoodfacts.py API wrapper with text_search
- Add off_code field + find_or_create_from_search() to Food model
- Add nutrition fields (protein, fat, carbs, fiber, sugar, sodium),
  ingredients, image, off_code to Drink model
- Create FoodSearchView and FoodScrobbleFromSearchView at /foods/search/
- Route beverages to Drink model based on OFF category keywords
- Update ManualScrobbleView to redirect '-f Food Name' to food search
- Add Drinks section to home page and scrobble detail templates
- Update drink_detail.html with nutrition grid and image display
This commit is contained in:
2026-07-21 17:09:13 -04:00
parent f58f5406bb
commit 155ed2a4f8
15 changed files with 659 additions and 10 deletions

View File

@ -185,7 +185,7 @@ events).
- Timestamp picker (default to now)
- Optional: barcode scanning for food/drinks via CameraX
* Backlog [0/25] :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:
@ -714,6 +714,10 @@ at /api/docs/.
- Verify schema generates cleanly for all ViewSets
- Fix any serializer issues that block schema generation
** DONE [#B] Add open food facts source for Food and Drink :source:food:drinks:
:PROPERTIES:
:ID: d8a3e498-b03c-050d-a9bf-86e76eaa9201
:END:
* Version 61.5 [2/2]
** DONE [#A] Fix referer bug in OSM tiles :bug:osm:templates:
:PROPERTIES:

View File

@ -68,6 +68,7 @@ vaderSentiment = "^3.3.2"
sqids = "^0.5.2"
python-amazon-paapi = "^6.3.0"
yake = "^0.7.3"
openfoodfacts = "^5.3.0"
[tool.poetry.group.test]
optional = true

View File

@ -0,0 +1,86 @@
# Generated by Django 4.2.29 on 2026-07-21 20:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("drinks", "0011_add_wine_fastcork_fields"),
]
operations = [
migrations.AddField(
model_name="drink",
name="carbohydrates",
field=models.DecimalField(
blank=True, decimal_places=2, max_digits=10, null=True
),
),
migrations.AddField(
model_name="drink",
name="description",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="drink",
name="drink_image",
field=models.ImageField(blank=True, null=True, upload_to="drinks/image/"),
),
migrations.AddField(
model_name="drink",
name="fat",
field=models.DecimalField(
blank=True, decimal_places=2, max_digits=10, null=True
),
),
migrations.AddField(
model_name="drink",
name="fiber",
field=models.DecimalField(
blank=True, decimal_places=2, max_digits=10, null=True
),
),
migrations.AddField(
model_name="drink",
name="ingredients",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="drink",
name="off_code",
field=models.CharField(blank=True, max_length=255, null=True, unique=True),
),
migrations.AddField(
model_name="drink",
name="protein",
field=models.DecimalField(
blank=True, decimal_places=2, max_digits=10, null=True
),
),
migrations.AddField(
model_name="drink",
name="sodium",
field=models.DecimalField(
blank=True, decimal_places=2, max_digits=10, null=True
),
),
migrations.AddField(
model_name="drink",
name="source_site",
field=models.CharField(blank=True, max_length=100, null=True),
),
migrations.AddField(
model_name="drink",
name="sugar",
field=models.DecimalField(
blank=True, decimal_places=2, max_digits=10, null=True
),
),
migrations.AddIndex(
model_name="drink",
index=models.Index(
fields=["off_code"], name="drinks_drin_off_cod_346283_idx"
),
),
]

View File

@ -153,9 +153,35 @@ class CoffeeLogData(DrinkLogData):
class Drink(ScrobblableMixin):
calories = models.PositiveIntegerField(**BNULL)
is_alcoholic = models.BooleanField(default=False)
off_code = models.CharField(max_length=255, unique=True, **BNULL)
source_site = models.CharField(max_length=100, **BNULL)
description = models.TextField(**BNULL)
ingredients = models.TextField(**BNULL)
protein = models.DecimalField(max_digits=10, decimal_places=2, **BNULL)
fat = models.DecimalField(max_digits=10, decimal_places=2, **BNULL)
carbohydrates = models.DecimalField(max_digits=10, decimal_places=2, **BNULL)
fiber = models.DecimalField(max_digits=10, decimal_places=2, **BNULL)
sugar = models.DecimalField(max_digits=10, decimal_places=2, **BNULL)
sodium = models.DecimalField(max_digits=10, decimal_places=2, **BNULL)
drink_image = models.ImageField(upload_to="drinks/image/", **BNULL)
drink_image_small = ImageSpecField(
source="drink_image",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
drink_image_medium = ImageSpecField(
source="drink_image",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
)
class Meta:
db_table = "drinks_drink"
indexes = [
models.Index(fields=["off_code"]),
]
def __str__(self):
return self.title or "Drink"
@ -167,6 +193,62 @@ class Drink(ScrobblableMixin):
)
return water
@classmethod
def find_or_create_from_search(cls, product_data: dict) -> "Drink":
code = product_data.get("code", "")
product_name = product_data.get("product_name", "")
brands = product_data.get("brands", "")
drink = None
if code:
drink = cls.objects.filter(off_code=code).first()
if not drink and product_name:
drink = cls.objects.filter(
title=product_name, source_site="Open Food Facts"
).first()
if drink:
return drink
nutriments = product_data.get("nutriments", {})
categories = product_data.get("categories", [])
is_alcoholic = any(
"alcoholic" in cat.lower() or "beer" in cat.lower() or "wine" in cat.lower()
for cat in categories
)
drink = cls.objects.create(
title=product_name,
description=f"{brands} - {product_name}" if brands else product_name,
source_site="Open Food Facts",
ingredients=product_data.get("ingredients_text", ""),
calories=nutriments.get("calories"),
is_alcoholic=is_alcoholic,
protein=nutriments.get("protein"),
fat=nutriments.get("fat"),
carbohydrates=nutriments.get("carbohydrates"),
fiber=nutriments.get("fiber"),
sugar=nutriments.get("sugar"),
sodium=nutriments.get("sodium"),
off_code=code,
)
image_url = product_data.get("image_url", "")
if image_url:
drink.save_image_from_url(image_url)
return drink
def save_image_from_url(self, url: str):
headers = {"User-Agent": "Vrobbler/1.0"}
try:
r = requests.get(url, headers=headers, timeout=10)
if r.status_code == 200:
fname = f"{self.title}_{self.uuid}.jpg"
self.drink_image.save(fname, ContentFile(r.content), save=True)
except requests.RequestException:
pass
def get_absolute_url(self) -> str:
return reverse("drinks:drink_detail", kwargs={"slug": self.uuid})

View File

@ -0,0 +1,24 @@
# Generated by Django 4.2.29 on 2026-07-21 20:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("foods", "0007_alter_food_genre"),
]
operations = [
migrations.AddField(
model_name="food",
name="off_code",
field=models.CharField(blank=True, max_length=255, null=True, unique=True),
),
migrations.AddIndex(
model_name="food",
index=models.Index(
fields=["off_code"], name="foods_food_off_cod_40ebc3_idx"
),
),
]

View File

@ -3,16 +3,18 @@ from dataclasses import dataclass
from typing import Optional, Tuple
from uuid import uuid4
import requests
from django.apps import apps
from django.core.files.base import ContentFile
from django.db import models
from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
from foods.sources.rscraper import RecipeScraperService
from foods.sources.usda import NutritionCalculator
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
from foods.sources.rscraper import RecipeScraperService
from foods.sources.usda import NutritionCalculator
BNULL = {"blank": True, "null": True}
@ -89,12 +91,14 @@ class Food(ScrobblableMixin):
)
allrecipe_id = models.CharField(max_length=255, **BNULL)
allrecipe_rating = models.FloatField(**BNULL)
off_code = models.CharField(max_length=255, unique=True, **BNULL)
category = models.ForeignKey(FoodCategory, on_delete=models.DO_NOTHING, **BNULL)
class Meta:
indexes = [
models.Index(fields=["source_url"]),
models.Index(fields=["allrecipe_id"]),
models.Index(fields=["off_code"]),
models.Index(fields=["description"]),
]
@ -239,6 +243,62 @@ class Food(ScrobblableMixin):
return food, True
@classmethod
def find_or_create_from_search(cls, product_data: dict) -> "Food":
code = product_data.get("code", "")
product_name = product_data.get("product_name", "")
brands = product_data.get("brands", "")
food = None
if code:
food = cls.objects.filter(off_code=code).first()
if not food and product_name:
food = cls.objects.filter(
title=product_name, source_site="Open Food Facts"
).first()
if food:
return food
nutriments = product_data.get("nutriments", {})
category = None
categories = product_data.get("categories", [])
if categories:
category, _ = FoodCategory.objects.get_or_create(name=categories[0])
food = cls.objects.create(
title=product_name,
description=f"{brands} - {product_name}" if brands else product_name,
source_site="Open Food Facts",
ingredients=product_data.get("ingredients_text", ""),
calories=nutriments.get("calories"),
protein=nutriments.get("protein"),
fat=nutriments.get("fat"),
carbohydrates=nutriments.get("carbohydrates"),
fiber=nutriments.get("fiber"),
sugar=nutriments.get("sugar"),
sodium=nutriments.get("sodium"),
off_code=code,
category=category,
)
image_url = product_data.get("image_url", "")
if image_url:
food.save_image_from_url(image_url)
return food
def save_image_from_url(self, url: str):
headers = {"User-Agent": "Vrobbler/1.0"}
try:
r = requests.get(url, headers=headers, timeout=10)
if r.status_code == 200:
fname = f"{self.title}_{self.uuid}.jpg"
self.recipe_image.save(fname, ContentFile(r.content), save=True)
except requests.RequestException:
pass
def refresh_nutrition(self) -> bool:
"""Recalculate nutrition from ingredients (useful if USDA data updated)."""
if not self.ingredients:

View File

@ -0,0 +1,90 @@
import logging
import openfoodfacts
logger = logging.getLogger(__name__)
OFF_API = openfoodfacts.API(
user_agent="Vrobbler/1.0 (https://github.com/powellc/vrobbler)",
version=openfoodfacts.APIVersion.v2,
)
def search_products(query: str) -> list[dict]:
if not query or not query.strip():
return []
try:
results = OFF_API.product.text_search(query)
except Exception:
logger.exception("Open Food Facts search request failed")
return []
products = results.get("products", [])
if not products:
return []
return [_normalize_product(p) for p in products]
def get_product(code: str) -> dict | None:
try:
product = OFF_API.product.get(code)
except Exception:
logger.exception("Open Food Facts product lookup failed")
return None
if not product:
return None
return _normalize_product(product)
def _normalize_product(product: dict) -> dict:
nutriments = product.get("nutriments", {})
categories = product.get("categories_tags", [])
category_names = []
for cat in categories:
if cat.startswith("en:"):
category_names.append(cat[3:].replace("-", " ").title())
elif cat.startswith("fr:"):
category_names.append(cat[3:].replace("-", " ").title())
return {
"code": product.get("code", ""),
"product_name": product.get("product_name", product.get("product_name_en", "")),
"brands": product.get("brands", ""),
"categories": category_names,
"ingredients_text": product.get(
"ingredients_text_en", product.get("ingredients_text", "")
),
"image_url": product.get("image_front_url", product.get("image_url", "")),
"nutriments": {
"calories": _safe_int(nutriments.get("energy-kcal_100g")),
"protein": _safe_float(nutriments.get("proteins_100g")),
"fat": _safe_float(nutriments.get("fat_100g")),
"carbohydrates": _safe_float(nutriments.get("carbohydrates_100g")),
"fiber": _safe_float(nutriments.get("fiber_100g")),
"sugar": _safe_float(nutriments.get("sugars_100g")),
"sodium": _safe_float(nutriments.get("sodium_100g")),
},
"serving_size": product.get("serving_size", ""),
}
def _safe_int(value) -> int | None:
if value is None:
return None
try:
return int(value)
except (ValueError, TypeError):
return None
def _safe_float(value) -> float | None:
if value is None:
return None
try:
return float(value)
except (ValueError, TypeError):
return None

View File

@ -6,6 +6,12 @@ app_name = "foods"
urlpatterns = [
path("foods/", views.FoodListView.as_view(), name="food_list"),
path("foods/search/", views.FoodSearchView.as_view(), name="food_search"),
path(
"foods/scrobble-from-search/",
views.FoodScrobbleFromSearchView.as_view(),
name="food_scrobble_from_search",
),
path(
"foods/<slug:slug>/",
views.FoodDetailView.as_view(),

View File

@ -1,11 +1,51 @@
from django.contrib import messages
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils import timezone
from django.views import View
from django.views.generic import TemplateView
from drinks.models import Drink
from foods.models import Food
from foods.sources.openfoodfacts import search_products
from scrobbles.views import (
ScrobbleableListView,
ScrobbleableDetailView,
ChartContextMixin,
ScrobbleableDetailView,
ScrobbleableListView,
)
BEVERAGE_KEYWORDS = [
"beverage",
"drink",
"water",
"beer",
"wine",
"juice",
"coffee",
"tea",
"soda",
"carbonated",
"milk",
"smoothie",
"shake",
"energy drink",
"alcohol",
"liquor",
"spirits",
"cocktail",
"cider",
"kombucha",
]
def _is_beverage(product_data: dict) -> bool:
categories = product_data.get("categories", [])
for cat in categories:
cat_lower = cat.lower()
for keyword in BEVERAGE_KEYWORDS:
if keyword in cat_lower:
return True
return False
class FoodListView(ScrobbleableListView):
model = Food
@ -13,3 +53,67 @@ class FoodListView(ScrobbleableListView):
class FoodDetailView(ScrobbleableDetailView, ChartContextMixin):
model = Food
class FoodSearchView(TemplateView):
template_name = "foods/food_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_products(query)
context["results"] = results
self.request.session["food_search_results"] = results
self.request.session["food_search_query"] = query
return context
class FoodScrobbleFromSearchView(View):
def post(self, request, *args, **kwargs):
if not request.user.is_authenticated:
messages.error(request, "You must be logged in to scrobble food.")
return HttpResponseRedirect("/")
product_index = request.POST.get("product_index")
query = request.POST.get("query", "")
results = request.session.get("food_search_results", [])
try:
product_index = int(product_index)
product_data = results[product_index]
except (TypeError, IndexError, ValueError):
messages.error(request, "Invalid food selection.")
return HttpResponseRedirect(
reverse("foods:food_search") + f"?q={query}" if query else "/"
)
from scrobbles.models import Scrobble
if _is_beverage(product_data):
media = Drink.find_or_create_from_search(product_data)
else:
media = Food.find_or_create_from_search(product_data)
if not media:
messages.error(request, "Could not create food from search result.")
return HttpResponseRedirect(
reverse("foods:food_search") + f"?q={query}" if query else "/"
)
scrobble_dict = {
"user_id": request.user.id,
"timestamp": timezone.now(),
"playback_position_seconds": 0,
"source": "Open Food Facts",
}
scrobble = Scrobble.create_or_update(media, 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("foods:food_list"))

View File

@ -640,9 +640,16 @@ class ManualScrobbleView(FormView):
if key == "-wi":
return HttpResponseRedirect(reverse("drinks:wine_search") + f"?q={item_id}")
if key == "-f" and " - " not in item_id:
return HttpResponseRedirect(reverse("foods:food_search") + f"?q={item_id}")
scrobble_fn = MANUAL_SCROBBLE_FNS[key]
scrobble = eval(scrobble_fn)(item_id, self.request.user.id)
if not scrobble:
messages.error(self.request, "Could not create scrobble.")
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER", "/"))
return HttpResponseRedirect(scrobble.redirect_url(self.request.user.id))

View File

@ -22,20 +22,96 @@
width: 600px;
margin-left: 10px;
}
.nutrition-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.5rem;
margin: 1rem 0;
}
.nutrition-item {
text-align: center;
padding: 0.5rem;
border: 1px solid #dee2e6;
border-radius: 4px;
}
.nutrition-item .value {
font-size: 1.25rem;
font-weight: 600;
}
.nutrition-item .label {
font-size: 0.75rem;
color: #6c757d;
}
</style>
{% endblock %}
{% block lists %}
<div class="row">
{% if object.drink_image %}
<div class="cover">
<img src="{{ object.drink_image.url }}" alt="{{ object.title }}">
</div>
{% endif %}
<div class="summary">
{% if object.description%}
{% if object.description %}
<p>{{object.description|safe|linebreaks|truncatewords:160}}</p>
<hr />
{% endif %}
<p style="float:right;">
<a href="{{object.untappd_link}}"><img src="{% static "images/untappd-logo.png" %}" width=35></a>
</p>
{% if object.calories or object.protein %}
<div class="nutrition-grid">
{% if object.calories %}
<div class="nutrition-item">
<div class="value">{{ object.calories }}</div>
<div class="label">kcal</div>
</div>
{% endif %}
{% if object.protein %}
<div class="nutrition-item">
<div class="value">{{ object.protein }}g</div>
<div class="label">Protein</div>
</div>
{% endif %}
{% if object.fat %}
<div class="nutrition-item">
<div class="value">{{ object.fat }}g</div>
<div class="label">Fat</div>
</div>
{% endif %}
{% if object.carbohydrates %}
<div class="nutrition-item">
<div class="value">{{ object.carbohydrates }}g</div>
<div class="label">Carbs</div>
</div>
{% endif %}
{% if object.fiber %}
<div class="nutrition-item">
<div class="value">{{ object.fiber }}g</div>
<div class="label">Fiber</div>
</div>
{% endif %}
{% if object.sugar %}
<div class="nutrition-item">
<div class="value">{{ object.sugar }}g</div>
<div class="label">Sugar</div>
</div>
{% endif %}
</div>
{% endif %}
{% if object.ingredients %}
<h5>Ingredients</h5>
<p>{{ object.ingredients }}</p>
{% endif %}
{% if object.off_code %}
<p><small class="text-muted">OFF: {{ object.off_code }}</small></p>
{% endif %}
</div>
</div>
<div class="row">

View File

@ -0,0 +1,96 @@
{% 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-nutrition { font-size: 0.875rem; margin-top: 0.5rem; }
.result-nutrition span { margin-right: 1rem; }
.no-results { padding: 2rem; text-align: center; color: #6c757d; }
.result-image { width: 60px; height: 60px; object-fit: contain; margin-right: 1rem; }
.result-row { display: flex; align-items: flex-start; }
</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 Food & Drinks</h1>
</div>
<div class="container" style="margin-bottom: 100px;">
<form method="get" action="{% url 'foods:food_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 food or drink product..."
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 product in results %}
<div class="result-item">
<div class="result-row">
{% if product.image_url %}
<img src="{{ product.image_url }}" alt="" class="result-image">
{% endif %}
<div>
<div class="result-title">{{ product.product_name }}</div>
<div class="result-meta">
{% if product.brands %}{{ product.brands }}{% endif %}
{% if product.categories %} &middot; {{ product.categories|join:", " }}{% endif %}
</div>
{% if product.nutriments.calories %}
<div class="result-nutrition">
<span>{{ product.nutriments.calories }} kcal</span>
{% if product.nutriments.protein %}<span>P {{ product.nutriments.protein }}g</span>{% endif %}
{% if product.nutriments.fat %}<span>F {{ product.nutriments.fat }}g</span>{% endif %}
{% if product.nutriments.carbohydrates %}<span>C {{ product.nutriments.carbohydrates }}g</span>{% endif %}
</div>
{% endif %}
</div>
</div>
<form method="post" action="{% url 'foods:food_scrobble_from_search' %}">
{% csrf_token %}
<input type="hidden" name="product_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 products found matching your search.
</div>
{% endif %}
</div>
{% else %}
<div class="no-results">
<p>Enter a food or drink name to search via Open Food Facts.</p>
</div>
{% endif %}
</div>
</main>
{% endblock %}

View File

@ -183,6 +183,15 @@
<p>No coffee today</p>
{% endif %}
<h3><a href="{% url 'drinks:drink_list' %}">Drinks</a></h3>
{% if Drink %}
{% with scrobbles=Drink count=Drink_count time=Drink_time %}
{% include "scrobbles/_scrobble_table.html" %}
{% endwith %}
{% else %}
<p>No drinks today</p>
{% endif %}
<h3><a href="{% url 'bricksets:brickset_list' %}">Brick sets</a></h3>
{% if BrickSet %}
{% with scrobbles=BrickSet count=BrickSet_count time=BrickSet_time %}

View File

@ -93,6 +93,8 @@
<a href="{% url 'drinks:wine_detail' scrobble.wine.uuid %}">{{ scrobble.wine.title }}</a>
{% elif scrobble.coffee %}
<a href="{% url 'drinks:coffee_detail' scrobble.coffee.uuid %}">{{ scrobble.coffee.title }}</a>
{% elif scrobble.drink %}
<a href="{% url 'drinks:drink_detail' scrobble.drink.uuid %}">{{ scrobble.drink.title }}</a>
{% elif scrobble.water %}
<a href="{% url 'drinks:water_detail' scrobble.water.uuid %}">{{ scrobble.water.title }}</a>
{% elif scrobble.web_page %}

View File

@ -87,6 +87,8 @@
<a href="{% url 'drinks:wine_detail' scrobble.wine.uuid %}">{{ scrobble.wine.title }}</a>
{% elif scrobble.coffee %}
<a href="{% url 'drinks:coffee_detail' scrobble.coffee.uuid %}">{{ scrobble.coffee.title }}</a>
{% elif scrobble.drink %}
<a href="{% url 'drinks:drink_detail' scrobble.drink.uuid %}">{{ scrobble.drink.title }}</a>
{% elif scrobble.web_page %}
<a href="{% url 'webpages:webpage_detail' scrobble.web_page.uuid %}">{{ scrobble.web_page.title }}</a>
{% elif scrobble.podcast_episode %}