[search] Add scrobble searching
This commit is contained in:
@ -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>/",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
134
vrobbler/templates/scrobbles/scrobble_search.html
Normal file
134
vrobbler/templates/scrobbles/scrobble_search.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user