[search] Add scrobble searching
All checks were successful
build & deploy / test (push) Successful in 1m44s
build & deploy / deploy (push) Successful in 22s

This commit is contained in:
2026-03-24 14:23:24 -04:00
parent 6766ea7dbb
commit cce2db0ea1
4 changed files with 259 additions and 0 deletions

View File

@ -5,6 +5,7 @@ from tasks.webhooks import emacs_webhook, todoist_webhook
app_name = "scrobbles"
urlpatterns = [
path("search/", views.ScrobbleSearchView.as_view(), name="search"),
path("status/", views.ScrobbleStatusView.as_view(), name="status"),
path(
"widget/top-artists/<str:period>/<int:user_id>/",

View File

@ -968,6 +968,123 @@ class EmbeddableTopArtistsWidget(TemplateView):
return context
class ScrobbleSearchView(LoginRequiredMixin, TemplateView):
template_name = "scrobbles/scrobble_search.html"
MEDIA_FIELDS = {
"Video": ("video__title", "video__overview"),
"Track": ("track__title", None),
"PodcastEpisode": ("podcast_episode__title", None),
"Book": ("book__title", "book__description"),
"Paper": ("paper__title", None),
"VideoGame": ("video_game__title", "video_game__description"),
"BoardGame": ("board_game__title", "board_game__description"),
"Beer": ("beer__name", "beer__style"),
"Food": ("food__name", "food__description"),
"Puzzle": ("puzzle__name", "puzzle__description"),
"Trail": ("trail__name", "trail__location"),
"GeoLocation": ("geo_location__name", "geo_location__description"),
"Task": ("task__title", "task__description"),
"WebPage": ("web_page__title", None),
"LifeEvent": ("life_event__title", "life_event__description"),
"Mood": ("mood__title", "mood__description"),
"BrickSet": ("brick_set__title", None),
}
def get(self, request, *args, **kwargs):
if not request.GET.get("q") and not request.GET.get("media_type"):
return super().get(request, *args, **kwargs)
return self.search(request)
def search(self, request):
query = request.GET.get("q", "").strip()
media_type = request.GET.get("media_type", "")
user = request.user
scrobbles = Scrobble.objects.filter(user=user)
if media_type:
scrobbles = scrobbles.filter(media_type=media_type)
if query:
from django.contrib.contenttypes.models import ContentType
from taggit.models import TaggedItem
scrobble_ct = ContentType.objects.get_for_model(Scrobble)
tag_match_ids = list(
TaggedItem.objects.filter(
content_type=scrobble_ct,
tag__name__icontains=query,
).values_list("object_id", flat=True)
)
q = Q()
q |= Q(log__notes__icontains=query)
q |= Q(log__title__icontains=query)
q |= Q(log__description__icontains=query)
q |= Q(source__icontains=query)
if media_type and media_type in self.MEDIA_FIELDS:
title_field, desc_field = self.MEDIA_FIELDS[media_type]
if title_field:
q |= Q(**{f"{title_field}__icontains": query})
if desc_field:
q |= Q(**{f"{desc_field}__icontains": query})
scrobbles = scrobbles.filter(q).distinct()
else:
all_matching_ids = set()
base_matches = scrobbles.filter(q).values_list("id", flat=True)
all_matching_ids.update(base_matches)
for mt, (title_field, desc_field) in self.MEDIA_FIELDS.items():
mt_queries = []
if title_field:
mt_queries.append(Q(**{f"{title_field}__icontains": query}))
if desc_field:
mt_queries.append(Q(**{f"{desc_field}__icontains": query}))
if mt_queries:
mt_q = mt_queries[0]
for mq in mt_queries[1:]:
mt_q |= mq
try:
mt_matches = scrobbles.filter(mt_q).values_list(
"id", flat=True
)
all_matching_ids.update(mt_matches)
except Exception:
pass
all_matching_ids.update(tag_match_ids)
scrobbles = Scrobble.objects.filter(id__in=all_matching_ids, user=user)
scrobbles = scrobbles.order_by("-timestamp")[:100]
context = self.get_context_data(
scrobbles=scrobbles,
query=query,
media_type=media_type,
media_types=Scrobble.MediaType.choices,
)
return self.render_to_response(context)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
if "scrobbles" not in ctx:
ctx["scrobbles"] = []
if "query" not in ctx:
ctx["query"] = ""
if "media_type" not in ctx:
ctx["media_type"] = ""
if "media_types" not in ctx:
ctx["media_types"] = Scrobble.MediaType.choices
return ctx
class EmbeddableTopBoardGamesWidget(BaseEmbeddableWidget):
media_type = "Board Games"
count_label = "plays"

View File

@ -57,6 +57,13 @@
<h1 class="h2">{% if date %}{{date|naturaltime}}{% else %}{{title}}{% endif %}</h1>
<div class="btn-toolbar mb-2 mb-md-0">
{% if user.is_authenticated %}
<form method="get" action="{% url 'scrobbles:search' %}" class="d-flex me-2">
<input type="text"
name="q"
class="form-control form-control-sm"
placeholder="Search..."
style="width: 150px;">
</form>
<div class="btn-group me-2">
<a href="{% url 'admin:index' %}" class="btn btn-sm btn-outline-secondary">Admin</a>
</div>

View File

@ -0,0 +1,134 @@
{% extends "base.html" %}
{% load static %}
{% load humanize %}
{% load naturalduration %}
{% block head_extra %}
<style>
.search-container { margin-bottom: 2rem; }
.filter-chips { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 1rem; }
.filter-chip {
display: inline-flex;
align-items: center;
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
border-radius: 1rem;
border: 1px solid #dee2e6;
background: #fff;
color: #495057;
text-decoration: none;
transition: all 0.2s;
}
.filter-chip:hover { background: #e9ecef; }
.filter-chip.active {
background: #0d6efd;
border-color: #0d6efd;
color: #fff;
}
.search-results { margin-top: 2rem; }
.result-item {
padding: 1rem;
border-bottom: 1px solid #dee2e6;
}
.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-tags { margin-top: 0.5rem; }
.result-tag {
display: inline-block;
padding: 0.125rem 0.5rem;
font-size: 0.75rem;
border-radius: 0.25rem;
background: #e9ecef;
color: #495057;
margin-right: 0.25rem;
}
.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 Scrobbles</h1>
<div class="btn-toolbar mb-2 mb-md-0">
{% if user.is_authenticated %}
<div class="btn-group me-2">
<a href="{% url 'admin:index' %}" class="btn btn-sm btn-outline-secondary">Admin</a>
</div>
<div class="btn-group me-2">
<a href="{% url 'charts:charts-home' %}" class="btn btn-sm btn-outline-secondary">Charts</a>
</div>
{% endif %}
</div>
</div>
<div class="container" style="margin-bottom: 100px;">
<form method="get" action="{% url 'scrobbles: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 scrobbles..."
value="{{ query }}">
</div>
<div class="col-md-4">
<button type="submit" class="btn btn-primary btn-lg">Search</button>
</div>
</div>
<div class="filter-chips">
<a href="{% url 'scrobbles:search' %}{% if query %}?q={{ query }}{% endif %}"
class="filter-chip{% if not media_type %} active{% endif %}">
All
</a>
{% for value, label in media_types %}
<a href="{% url 'scrobbles:search' %}?{% if query %}q={{ query }}&{% endif %}media_type={{ value }}"
class="filter-chip{% if media_type == value %} active{% endif %}">
{{ label }}
</a>
{% endfor %}
</div>
</form>
{% if query or media_type %}
<div class="search-results">
{% if scrobbles %}
<p class="text-muted">{{ scrobbles|length }} result{{ scrobbles|length|pluralize }}</p>
{% for scrobble in scrobbles %}
<div class="result-item">
<div class="result-title">
<a href="{% url 'scrobbles:detail' scrobble.uuid %}">
{% if scrobble.media_type == "Task" and scrobble.logdata.title %}{{ scrobble.media_obj.title }}: {{ scrobble.logdata.title }}{% else %}{{ scrobble.media_obj.title|default:scrobble.media_obj|truncatechars:100 }}{% endif %}
</a>
</div>
<div class="result-meta">
<span class="badge bg-secondary">{{ scrobble.get_media_type_display }}</span>
{{ scrobble.timestamp|date:"M d, Y" }}
{% if scrobble.source %} via {{ scrobble.source }}{% endif %}
</div>
{% if scrobble.tags.all %}
<div class="result-tags">
{% for tag in scrobble.tags.all %}
<span class="result-tag">{{ tag.name }}</span>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="no-results">
No scrobbles found matching your search.
</div>
{% endif %}
</div>
{% else %}
<div class="no-results">
<p>Enter a search term or select a media type to filter.</p>
</div>
{% endif %}
</div>
</main>
{% endblock %}