[agents] Add agent-session scrobbling for Gemini and opencode providers
All checks were successful
ci / test (push) Successful in 3m2s
ci / build-and-deploy (push) Has been skipped

This commit is contained in:
2026-07-31 00:21:24 -04:00
parent 952ed596da
commit 0c92174440
26 changed files with 1458 additions and 5 deletions

View File

@ -185,7 +185,7 @@ events).
- Timestamp picker (default to now)
- Optional: barcode scanning for food/drinks via CameraX
* Backlog [0/24] :vrobbler:project:personal:
* Backlog [2/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:
@ -698,6 +698,39 @@ The Edit log form should have from top to bottom:
*** Description
** TODO [#A] Add trends tests for concurrent trends :trends:tests:concurrent:
** DONE [#A] Add agent-session scrobbling :scrobbles:agents:
:PROPERTIES:
:ID: 0114a3d8-b13e-bbb6-b461-c7005e9be680
:END:
*** Description
What if we could "scrobble" an agent session? Honestly, this is mostly applicable to
web searches or quick questions via Gemini or similar web-based agents.
The scrobble should be the tool, so using Gemini is an agent_session scrobble, and the
associated prompt and any response are stored in the AgentSessionLogData. For this,
we'd need a way to start a prompt with an AI agent via python libraries like genai.
Then we'd ask a question or a prompt request in the top Scrobble something bar. In
this case, an entry without a -<letter> prefix would be understood as starting a
Gemini chat and would send the prompt to Gemini, get a response and return it
to the browser, starting a new scrobble and storing any response in the log data.
Then the AgentSessionLogData would capture a list of turns: [{"prompt_id": <id>,
"prompt": "A request prompt", "response": "An agent respones"},
**** Tasks
- [X] Add agents app with AgentSession model and AgentSessionLogData
- [X] Add gemini (httpx) and opencode (CLI) agent provider modules
- [X] Wire AgentSession into scrobbles (media type, FK, prefetching)
- [X] Route unprefixed manual scrobble input to agent sessions
- [X] Add async Celery task to fetch turn responses
- [X] Render agent turns on scrobble detail with htmx polling
- [X] Add agent session list/detail views and templates
- [X] Generate migrations
- [X] Write tests
** DONE [#A] Fix small bug in nature importer if geojson is missing :importers:nature:
:PROPERTIES:
:ID: 0e0a9089-fda1-5e0d-6974-bebacb571cd3

View File

View File

@ -0,0 +1,592 @@
import json
import subprocess
from unittest.mock import MagicMock, patch
import pytest
from agents.models import AgentSession, AgentSessionLogData
from agents.providers import (
_parse_opencode_events,
agent_prompt,
gemini_agent_prompt,
opencode_agent_prompt,
)
from django.conf import settings
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.utils import timezone
from scrobbles.models import Scrobble
from scrobbles.scrobblers import (
manual_scrobble_agent_follow_up,
manual_scrobble_agent_session,
)
from scrobbles.tasks import scrobble_agent_session_prompt
User = get_user_model()
@pytest.fixture
def user(db):
return User.objects.create_user(username="agentuser", password="pw")
def _mk_scrobble(user, *, in_progress=True, turns=None, title="First prompt"):
agent_session, _ = AgentSession.find_or_create(
provider="gemini", model=settings.LLM_MODEL
)
scrobble = Scrobble.create_or_update(
agent_session,
user.id,
{
"user_id": user.id,
"timestamp": timezone.now(),
"playback_position_seconds": 0,
"source": "Vrobbler",
},
skip_in_progress_check=True,
)
log = scrobble.log if isinstance(scrobble.log, dict) else {}
log["provider"] = "gemini"
log["model"] = settings.LLM_MODEL
log["title"] = title
log["turns"] = turns or []
scrobble.log = log
scrobble.in_progress = in_progress
scrobble.save(update_fields=["log", "in_progress"])
return scrobble
# --- providers ---
def test_parse_opencode_events_joins_text_parts():
raw = "\n".join(
[
json.dumps({"type": "step_start"}),
json.dumps({"type": "text", "part": {"type": "text", "text": "hello"}}),
json.dumps({"type": "text", "part": {"type": "text", "text": " world"}}),
json.dumps({"type": "step_finish"}),
"not-json",
]
)
assert _parse_opencode_events(raw) == "hello\n world"
def test_opencode_agent_prompt(settings):
settings.OPENCODE_CMD = "opencode"
events = json.dumps(
{"type": "text", "part": {"type": "text", "text": "the answer"}}
)
with patch(
"agents.providers.subprocess.run",
return_value=MagicMock(returncode=0, stdout=events, stderr=""),
) as mock_run:
result = opencode_agent_prompt("what is 2+2?")
mock_run.assert_called_once()
cmd = mock_run.call_args.args[0]
assert cmd[:2] == ["opencode", "run"]
assert cmd[2:5] == ["--format", "json", "--dir"]
assert cmd[5].startswith("/tmp/")
assert cmd[6] == "what is 2+2?"
assert result == {"text": "the answer", "provider": "opencode", "model": "opencode"}
def test_opencode_agent_prompt_nonzero_exit():
with patch(
"agents.providers.subprocess.run",
return_value=MagicMock(returncode=1, stdout="", stderr="boom"),
):
with pytest.raises(ValueError, match="opencode exited with 1"):
opencode_agent_prompt("hi")
def test_opencode_agent_prompt_timeout():
with patch(
"agents.providers.subprocess.run",
side_effect=subprocess.TimeoutExpired("cmd", 900),
):
with pytest.raises(ValueError, match="timed out"):
opencode_agent_prompt("hi")
def test_opencode_agent_prompt_no_text_output():
with patch(
"agents.providers.subprocess.run",
return_value=MagicMock(
returncode=0, stdout=json.dumps({"type": "step_start"}), stderr=""
),
):
with pytest.raises(ValueError, match="no text output"):
opencode_agent_prompt("hi")
def test_gemini_agent_prompt(settings):
settings.GOOGLE_API_KEY = "test-key"
settings.LLM_MODEL = "gemini-2.5-flash"
fake = MagicMock()
fake.raise_for_status = MagicMock()
fake.json.return_value = {
"candidates": [{"content": {"parts": [{"text": " the answer "}]}}]
}
with patch("agents.providers.httpx.post", return_value=fake) as mock_post:
result = gemini_agent_prompt("what is 2+2?")
mock_post.assert_called_once()
kwargs = mock_post.call_args.kwargs
assert kwargs["params"] == {"key": "test-key"}
assert kwargs["json"] == {"contents": [{"parts": [{"text": "what is 2+2?"}]}]}
assert result == {
"text": "the answer",
"provider": "gemini",
"model": "gemini-2.5-flash",
}
def test_gemini_agent_prompt_missing_key(settings):
settings.GOOGLE_API_KEY = ""
with pytest.raises(ValueError, match="VROBBLER_GOOGLE_API_KEY is not set"):
gemini_agent_prompt("hi")
def test_gemini_agent_prompt_bad_response(settings):
settings.GOOGLE_API_KEY = "test-key"
fake = MagicMock()
fake.raise_for_status = MagicMock()
fake.json.return_value = {"unexpected": True}
with patch("agents.providers.httpx.post", return_value=fake):
with pytest.raises(ValueError, match="Unexpected Gemini response"):
gemini_agent_prompt("hi")
def test_gemini_agent_prompt_with_history(settings):
settings.GOOGLE_API_KEY = "test-key"
fake = MagicMock()
fake.raise_for_status = MagicMock()
fake.json.return_value = {"candidates": [{"content": {"parts": [{"text": "12"}]}}]}
history = [
{"prompt": "what is 2+2?", "response": "4"},
{"prompt": "no response yet", "response": None},
]
with patch("agents.providers.httpx.post", return_value=fake) as mock_post:
gemini_agent_prompt("times 3?", history=history)
contents = mock_post.call_args.kwargs["json"]["contents"]
assert contents == [
{"parts": [{"text": "what is 2+2?"}]},
{"parts": [{"text": "4"}]},
{"parts": [{"text": "times 3?"}]},
]
def test_opencode_agent_prompt_with_history(settings):
settings.OPENCODE_CMD = "opencode"
with patch(
"agents.providers.subprocess.run",
return_value=MagicMock(
returncode=0,
stdout=json.dumps({"type": "text", "part": {"type": "text", "text": "x"}}),
stderr="",
),
) as mock_run:
opencode_agent_prompt(
"times 3?", history=[{"prompt": "what is 2+2?", "response": "4"}]
)
prompt = mock_run.call_args.args[0][6]
assert "Previous conversation:" in prompt
assert "User: what is 2+2?" in prompt
assert "Assistant: 4" in prompt
assert "times 3?" in prompt
def test_agent_prompt_unknown_provider(settings):
settings.AGENT_PROVIDER = "nope"
with pytest.raises(ValueError, match="Unknown agent provider"):
agent_prompt("hi")
def test_agent_prompt_defaults_to_configured_provider(settings):
settings.AGENT_PROVIDER = "gemini"
settings.GOOGLE_API_KEY = "test-key"
with patch("agents.providers.gemini_agent_prompt", return_value={"text": "x"}) as m:
agent_prompt("hi")
m.assert_called_once_with("hi", history=None)
# --- scrobbler ---
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
def test_manual_scrobble_agent_session_creates_new_session(mock_delay, user):
scrobble = manual_scrobble_agent_session("What is the capital of France?", user.id)
assert Scrobble.objects.filter(id=scrobble.id).exists()
assert scrobble.media_type == Scrobble.MediaType.AGENT_SESSION
assert scrobble.in_progress is True
assert scrobble.agent_session.provider == "gemini"
assert scrobble.agent_session.model == settings.LLM_MODEL
assert scrobble.agent_session.title == f"gemini {settings.LLM_MODEL}"
assert scrobble.log["title"] == "What is the capital of France?"
turns = scrobble.log["turns"]
assert len(turns) == 1
assert turns[0]["prompt"] == "What is the capital of France?"
assert turns[0]["response"] is None
mock_delay.assert_called_once_with(scrobble.id, turns[0]["prompt_id"])
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
def test_manual_scrobble_agent_session_reuses_agent_session(mock_delay, user):
agent_session, created = AgentSession.find_or_create(
provider="gemini", model=settings.LLM_MODEL
)
assert created is True
manual_scrobble_agent_session("First question?", user.id)
agent_session_again, created = AgentSession.find_or_create(
provider="gemini", model=settings.LLM_MODEL
)
assert created is False
assert agent_session_again.id == agent_session.id
assert AgentSession.objects.count() == 1
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
def test_manual_scrobble_agent_session_appends_to_in_progress(mock_delay, user):
_mk_scrobble(
user,
in_progress=True,
turns=[
{"prompt_id": "first-1", "prompt": "First prompt", "response": "answer"}
],
)
scrobble = manual_scrobble_agent_session("And the second question?", user.id)
turns = scrobble.log["turns"]
assert len(turns) == 2
assert turns[0]["prompt"] == "First prompt"
assert turns[1]["prompt"] == "And the second question?"
assert turns[1]["response"] is None
assert scrobble.log["title"] == "First prompt"
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
def test_manual_scrobble_agent_session_new_session_after_completed(mock_delay, user):
_mk_scrobble(user, in_progress=False)
scrobble = manual_scrobble_agent_session("A new session?", user.id)
assert scrobble.media_type == Scrobble.MediaType.AGENT_SESSION
assert len(scrobble.log["turns"]) == 1
assert Scrobble.objects.filter(user_id=user.id).count() == 2
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
def test_manual_scrobble_agent_session_opencode_model_empty(mock_delay, user, settings):
settings.AGENT_PROVIDER = "opencode"
scrobble = manual_scrobble_agent_session("hi", user.id)
assert scrobble.agent_session.provider == "opencode"
assert scrobble.agent_session.model == "opencode"
assert scrobble.agent_session.title == "opencode opencode"
# --- celery task ---
@patch("agents.providers.agent_prompt")
def test_scrobble_agent_session_prompt_fills_turn(mock_agent_prompt, user):
scrobble = _mk_scrobble(
user,
turns=[
{
"prompt_id": "abc-123",
"prompt": "hello",
"response": None,
}
],
)
mock_agent_prompt.return_value = {
"text": "hi back",
"provider": "gemini",
"model": "gemini-2.5-flash",
}
scrobble_agent_session_prompt(scrobble.id, "abc-123")
scrobble.refresh_from_db()
mock_agent_prompt.assert_called_once_with("hello", provider="gemini", history=[])
assert scrobble.log["turns"][0]["response"] == "hi back"
assert scrobble.log["provider"] == "gemini"
assert scrobble.in_progress is False
assert scrobble.played_to_completion is True
@patch("agents.providers.agent_prompt")
def test_scrobble_agent_session_prompt_passes_history(mock_agent_prompt, user):
scrobble = _mk_scrobble(
user,
turns=[
{
"prompt_id": "first-1",
"prompt": "what is 2+2?",
"response": "4",
},
{
"prompt_id": "err-1",
"prompt": "boom?",
"response": "Error: boom",
"error": True,
},
{
"prompt_id": "cur-1",
"prompt": "and times 3?",
"response": None,
},
],
)
mock_agent_prompt.return_value = {
"text": "12",
"provider": "gemini",
"model": settings.LLM_MODEL,
}
scrobble_agent_session_prompt(scrobble.id, "cur-1")
mock_agent_prompt.assert_called_once_with(
"and times 3?",
provider="gemini",
history=[{"prompt": "what is 2+2?", "response": "4"}],
)
@patch("agents.providers.agent_prompt")
def test_scrobble_agent_session_prompt_stores_error(mock_agent_prompt, user):
scrobble = _mk_scrobble(
user,
turns=[
{
"prompt_id": "abc-123",
"prompt": "hello",
"response": None,
}
],
)
mock_agent_prompt.side_effect = ValueError("boom")
scrobble_agent_session_prompt(scrobble.id, "abc-123")
scrobble.refresh_from_db()
assert scrobble.log["turns"][0]["response"] == "Error: boom"
assert scrobble.log["turns"][0]["error"] is True
assert scrobble.in_progress is False
# --- views ---
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
def test_manual_scrobble_view_routes_plain_text_to_agent(mock_delay, client, user):
client.force_login(user)
response = client.post(
reverse("scrobbles:lookup-manual-scrobble"),
{"item_id": "what is the meaning of life?"},
)
assert response.status_code == 302
scrobble = Scrobble.objects.filter(user_id=user.id).first()
assert scrobble is not None
assert scrobble.media_type == Scrobble.MediaType.AGENT_SESSION
assert response.url.startswith(reverse("scrobbles:detail", args=[scrobble.id]))
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
def test_agent_session_partial_pending_polls(mock_delay, client, user):
scrobble = _mk_scrobble(
user,
turns=[{"prompt_id": "abc-123", "prompt": "hello", "response": None}],
)
client.force_login(user)
response = client.get(
reverse("scrobbles:agent-session-partial", args=[scrobble.id])
)
assert response.status_code == 200
assert b"every 5s" in response.content
assert b"Thinking" in response.content
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
def test_agent_session_partial_completed_stops_polling(mock_delay, client, user):
scrobble = _mk_scrobble(
user,
in_progress=False,
turns=[
{
"prompt_id": "abc-123",
"prompt": "hello",
"response": "**hi back**",
}
],
)
client.force_login(user)
response = client.get(
reverse("scrobbles:agent-session-partial", args=[scrobble.id])
)
assert response.status_code == 200
assert b"every 5s" not in response.content
assert b"hi back" in response.content
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
def test_agent_session_list_view(mock_delay, client, user):
_mk_scrobble(user, in_progress=True)
client.force_login(user)
response = client.get(reverse("agents:agent_session_list"))
assert response.status_code == 200
assert settings.LLM_MODEL.encode() in response.content
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
def test_agent_session_detail_view(mock_delay, client, user):
scrobble = _mk_scrobble(user, in_progress=True)
client.force_login(user)
response = client.get(scrobble.media_obj.get_absolute_url())
assert response.status_code == 200
assert settings.LLM_MODEL.encode() in response.content
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
def test_manual_scrobble_agent_follow_up_appends_turn(mock_delay, user):
scrobble = _mk_scrobble(
user,
in_progress=False,
turns=[
{
"prompt_id": "abc-123",
"prompt": "hello",
"response": "hi back",
}
],
)
result = manual_scrobble_agent_follow_up("tell me more", scrobble.id, user.id)
assert result.id == scrobble.id
scrobble.refresh_from_db()
turns = scrobble.log["turns"]
assert len(turns) == 2
assert turns[1]["prompt"] == "tell me more"
assert turns[1]["response"] is None
assert scrobble.in_progress is True
mock_delay.assert_called_once_with(scrobble.id, turns[1]["prompt_id"])
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
def test_manual_scrobble_agent_follow_up_unknown_scrobble(mock_delay, user):
assert manual_scrobble_agent_follow_up("tell me more", 999999, user.id) is None
mock_delay.assert_not_called()
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
def test_agent_session_followup_post_appends_turn(mock_delay, client, user):
scrobble = _mk_scrobble(
user,
in_progress=False,
turns=[
{
"prompt_id": "abc-123",
"prompt": "hello",
"response": "hi back",
}
],
)
client.force_login(user)
response = client.post(
reverse("scrobbles:agent-session-followup", args=[scrobble.id]),
{"prompt": "tell me more"},
)
assert response.status_code == 200
assert b"tell me more" in response.content
assert b"Thinking" in response.content
assert b"every 5s" in response.content
scrobble.refresh_from_db()
assert len(scrobble.log["turns"]) == 2
assert scrobble.in_progress is True
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
def test_agent_session_followup_post_empty_prompt(mock_delay, client, user):
scrobble = _mk_scrobble(user, in_progress=False)
client.force_login(user)
response = client.post(
reverse("scrobbles:agent-session-followup", args=[scrobble.id]),
{"prompt": " "},
)
assert response.status_code == 302
mock_delay.assert_not_called()
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
def test_agent_session_followup_form_shown_when_complete(mock_delay, client, user):
scrobble = _mk_scrobble(
user,
in_progress=False,
turns=[
{
"prompt_id": "abc-123",
"prompt": "hello",
"response": "hi back",
}
],
)
client.force_login(user)
response = client.get(
reverse("scrobbles:agent-session-partial", args=[scrobble.id])
)
assert response.status_code == 200
assert b"Ask a follow-up" in response.content
assert b"/follow-up/" in response.content
assert b"csrfmiddlewaretoken" in response.content
# --- log data ---
def test_agent_session_logdata_pending():
log = AgentSessionLogData(
provider="gemini",
model="gemini-2.5-flash",
turns=[{"prompt_id": "1", "prompt": "hi", "response": None}],
)
assert log.pending is True
log.turns[0]["response"] = "hi back"
assert log.pending is False
assert AgentSessionLogData(provider="gemini").pending is False
def test_agent_session_logdata_as_html():
log = AgentSessionLogData(
provider="gemini",
model="gemini-2.5-flash",
turns=[
{"prompt_id": "1", "prompt": "**bold?**", "response": "yes"},
{"prompt_id": "2", "prompt": "again", "response": None},
],
)
html = log.as_html()
assert '<div class="agent-turn">' in html
assert "Prompt 1" in html
assert "<strong>bold?</strong>" in html
assert "yes" in html
assert "Prompt 2" in html
assert "Thinking" in html

View File

View File

@ -0,0 +1,8 @@
from agents.models import AgentSession
from django.contrib import admin
@admin.register(AgentSession)
class AgentSessionAdmin(admin.ModelAdmin):
list_display = ("title", "provider", "model", "created")
search_fields = ("title",)

View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class AgentsConfig(AppConfig):
name = "agents"

View File

@ -0,0 +1,78 @@
# Generated by Django 4.2.29 on 2026-07-31 03:27
from django.db import migrations, models
import django_extensions.db.fields
import taggit.managers
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
("scrobbles", "0104_backfill_null_uuids"),
]
operations = [
migrations.CreateModel(
name="AgentSession",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
(
"uuid",
models.UUIDField(
blank=True, default=uuid.uuid4, editable=False, null=True
),
),
("title", models.CharField(blank=True, max_length=255, null=True)),
("base_run_time_seconds", models.IntegerField(blank=True, null=True)),
("provider", models.CharField(blank=True, max_length=50, null=True)),
("model", models.CharField(blank=True, max_length=100, null=True)),
(
"genre",
taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="scrobbles.ObjectWithGenres",
to="scrobbles.Genre",
verbose_name="Genre",
),
),
(
"tags",
taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
],
options={
"abstract": False,
},
),
]

View File

@ -0,0 +1,138 @@
import logging
from dataclasses import dataclass
from typing import Optional
import bleach
import markdown
from django import forms
from django.db import models
from django.urls import reverse
from scrobbles.dataclasses import BaseLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
AGENT_HTML_ALLOWED_TAGS = [
"p",
"br",
"strong",
"em",
"a",
"ul",
"ol",
"li",
"code",
"pre",
"blockquote",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"img",
"table",
"thead",
"tbody",
"tr",
"th",
"td",
]
@dataclass
class AgentSessionLogData(BaseLogData):
title: Optional[str] = None
provider: Optional[str] = None
model: Optional[str] = None
turns: Optional[list] = None
_excluded_fields = {"turns"}
@classmethod
def override_fields(cls) -> dict:
return {
"title": forms.CharField(required=False),
"provider": forms.CharField(required=False),
"model": forms.CharField(required=False),
}
@property
def pending(self) -> bool:
if not self.turns:
return False
return any(turn.get("response") is None for turn in self.turns)
@staticmethod
def _md_to_html(text: str) -> str:
md = markdown.Markdown(extensions=["extra"])
return bleach.clean(md.convert(text), tags=AGENT_HTML_ALLOWED_TAGS, strip=True)
def as_html(self) -> str:
if not self.turns:
return ""
html_parts = []
for i, turn in enumerate(self.turns):
prompt = turn.get("prompt", "")
response = turn.get("response")
html_parts.append('<div class="agent-turn">')
html_parts.append(
f'<div class="agent-prompt"><h5>Prompt {i + 1}</h5>'
f"{self._md_to_html(prompt)}</div>"
)
if response:
html_parts.append(
f'<div class="agent-response"><h5>Response</h5>'
f"{self._md_to_html(response)}</div>"
)
else:
html_parts.append(
'<div class="agent-response agent-thinking">Thinking&hellip;</div>'
)
html_parts.append("</div>")
return "".join(html_parts)
class AgentSession(ScrobblableMixin):
provider = models.CharField(max_length=50, **BNULL)
model = models.CharField(max_length=100, **BNULL)
@classmethod
def find_or_create(cls, provider: str, model: str):
agent_session = cls.objects.filter(provider=provider, model=model).first()
if agent_session:
return agent_session, False
title = f"{provider} {model}".strip()
agent_session = cls.objects.create(provider=provider, model=model, title=title)
return agent_session, True
def __str__(self):
if self.title:
return self.title
return str(self.uuid)
def get_absolute_url(self):
return reverse("agents:agent_session_detail", kwargs={"slug": self.uuid})
@property
def subtitle(self) -> str:
return " ".join(p for p in [self.provider, self.model] if p)
@property
def strings(self) -> ScrobblableConstants:
return ScrobblableConstants(verb="Asking", tags="agents")
@property
def start_url(self) -> str:
return ""
@property
def logdata_cls(self):
return AgentSessionLogData
@property
def primary_image_url(self) -> str:
return ""

View File

@ -0,0 +1,129 @@
import json
import logging
import subprocess
import tempfile
import httpx
from django.conf import settings
logger = logging.getLogger(__name__)
GEMINI_GENERATE_CONTENT_URL = (
"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent"
)
def agent_prompt(
prompt: str,
provider: str | None = None,
history: list[dict] | None = None,
) -> dict:
"""Send a prompt to the configured agent provider and return the response.
``history`` is a list of prior turns as ``{"prompt": str, "response": str}``
dicts, used as conversation context for follow-up questions.
Returns a dict with ``text``, ``provider`` and ``model`` keys.
"""
provider = provider or settings.AGENT_PROVIDER
if provider == "gemini":
return gemini_agent_prompt(prompt, history=history)
if provider == "opencode":
return opencode_agent_prompt(prompt, history=history)
raise ValueError(f"Unknown agent provider: {provider}")
def _gemini_contents(prompt: str, history: list[dict] | None) -> list[dict]:
contents = []
for turn in history or []:
if not turn.get("response"):
continue
contents.append({"parts": [{"text": turn["prompt"]}]})
contents.append({"parts": [{"text": turn["response"]}]})
contents.append({"parts": [{"text": prompt}]})
return contents
def gemini_agent_prompt(prompt: str, history: list[dict] | None = None) -> dict:
api_key = settings.GOOGLE_API_KEY
if not api_key:
raise ValueError("VROBBLER_GOOGLE_API_KEY is not set")
model = settings.LLM_MODEL
response = httpx.post(
GEMINI_GENERATE_CONTENT_URL.format(model=model),
params={"key": api_key},
json={"contents": _gemini_contents(prompt, history)},
timeout=120.0,
)
response.raise_for_status()
data = response.json()
try:
text = data["candidates"][0]["content"]["parts"][0]["text"]
except (KeyError, IndexError) as e:
raise ValueError(f"Unexpected Gemini response: {data}") from e
return {"text": text.strip(), "provider": "gemini", "model": model}
def _opencode_prompt_with_history(prompt: str, history: list[dict] | None) -> str:
if not history:
return prompt
lines = ["Previous conversation:"]
for turn in history:
if not turn.get("response"):
continue
lines.append(f"User: {turn['prompt']}")
lines.append(f"Assistant: {turn['response']}")
lines.extend(["", "Continue the conversation and answer the latest question:"])
return "\n".join(lines) + "\n\n" + prompt
def opencode_agent_prompt(prompt: str, history: list[dict] | None = None) -> dict:
with tempfile.TemporaryDirectory(prefix="vrobbler-agent-") as tmp_dir:
try:
result = subprocess.run(
[
settings.OPENCODE_CMD,
"run",
"--format",
"json",
"--dir",
tmp_dir,
_opencode_prompt_with_history(prompt, history),
],
capture_output=True,
text=True,
timeout=900,
)
except subprocess.TimeoutExpired as e:
raise ValueError("opencode agent timed out") from e
if result.returncode != 0:
raise ValueError(
f"opencode exited with {result.returncode}: {result.stderr[:500]}"
)
text = _parse_opencode_events(result.stdout)
if not text:
raise ValueError("opencode agent returned no text output")
return {"text": text.strip(), "provider": "opencode", "model": "opencode"}
def _parse_opencode_events(raw: str) -> str:
parts = []
for line in raw.splitlines():
line = line.strip()
if not line:
continue
try:
event = json.loads(line)
except json.JSONDecodeError:
continue
if event.get("type") != "text":
continue
part = event.get("part") or {}
if part.get("text"):
parts.append(part["text"])
return "\n".join(parts)

View File

@ -0,0 +1,14 @@
from agents import views
from django.urls import path
app_name = "agents"
urlpatterns = [
path("agents/", views.AgentSessionListView.as_view(), name="agent_session_list"),
path(
"agents/<slug:slug>/",
views.AgentSessionDetailView.as_view(),
name="agent_session_detail",
),
]

View File

@ -0,0 +1,10 @@
from agents.models import AgentSession
from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView
class AgentSessionListView(ScrobbleableListView):
model = AgentSession
class AgentSessionDetailView(ScrobbleableDetailView):
model = AgentSession

View File

@ -22,7 +22,7 @@ class ScrobbleForm(forms.Form):
widget=forms.TextInput(
attrs={
"class": "form-control form-control-dark w-100",
"placeholder": "Scrobble something (ttIMDB, -v Video Game title, -b Book title, -s TheSportsDB ID, -f Food name - calories)",
"placeholder": "Scrobble something (ttIMDB, -v Video Game title, -b Book title, -s TheSportsDB ID, -f Food name - calories, or type a question for Gemini)",
"aria-label": "Scrobble something",
}
),

View File

@ -0,0 +1,96 @@
# Generated by Django 4.2.29 on 2026-07-31 03:27
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("agents", "0001_initial"),
("scrobbles", "0104_backfill_null_uuids"),
]
operations = [
migrations.AddField(
model_name="scrobble",
name="agent_session",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="agents.agentsession",
),
),
migrations.AlterField(
model_name="favoritemedia",
name="media_type",
field=models.CharField(
choices=[
("AgentSession", "Agent session"),
("Video", "Video"),
("Track", "Track"),
("PodcastEpisode", "Podcast episode"),
("SportEvent", "Sport event"),
("Book", "Book"),
("Paper", "Paper"),
("VideoGame", "Video game"),
("BoardGame", "Board game"),
("GeoLocation", "GeoLocation"),
("Trail", "Trail"),
("Beer", "Beer"),
("Wine", "Wine"),
("Coffee", "Coffee"),
("Drink", "Drink"),
("Puzzle", "Puzzle"),
("Food", "Food"),
("Task", "Task"),
("WebPage", "Web Page"),
("LifeEvent", "Life event"),
("Mood", "Mood"),
("BrickSet", "Brick set"),
("Channel", "Channel"),
("BirdingLocation", "Birding location"),
("DiscGolfCourse", "Disc golf"),
("SpeciesObservation", "Species observation"),
],
max_length=20,
),
),
migrations.AlterField(
model_name="scrobble",
name="media_type",
field=models.CharField(
choices=[
("AgentSession", "Agent session"),
("Video", "Video"),
("Track", "Track"),
("PodcastEpisode", "Podcast episode"),
("SportEvent", "Sport event"),
("Book", "Book"),
("Paper", "Paper"),
("VideoGame", "Video game"),
("BoardGame", "Board game"),
("GeoLocation", "GeoLocation"),
("Trail", "Trail"),
("Beer", "Beer"),
("Wine", "Wine"),
("Coffee", "Coffee"),
("Drink", "Drink"),
("Puzzle", "Puzzle"),
("Food", "Food"),
("Task", "Task"),
("WebPage", "Web Page"),
("LifeEvent", "Life event"),
("Mood", "Mood"),
("BrickSet", "Brick set"),
("Channel", "Channel"),
("BirdingLocation", "Birding location"),
("DiscGolfCourse", "Disc golf"),
("SpeciesObservation", "Species observation"),
],
default="Video",
max_length=20,
),
),
]

View File

@ -9,6 +9,7 @@ from zoneinfo import ZoneInfo
import pendulum
import pytz
from agents.models import AgentSession
from birds.models import BirdingLocation
from boardgames.models import BoardGame
from books.koreader import process_koreader_sqlite_file
@ -674,6 +675,7 @@ class UDiscCSVImport(BaseFileImportMixin):
TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = {
"AgentSession": ("agent_session",),
"Video": ("video",),
"Track": ("track", "track__artist_fk"),
"PodcastEpisode": ("podcast_episode", "podcast_episode__podcast"),
@ -705,6 +707,7 @@ TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = {
class ScrobbleQuerySet(models.QuerySet):
def with_related(self):
return self.select_related("user").prefetch_related(
"agent_session",
"video",
"track",
"track__artist_fk",
@ -761,6 +764,7 @@ class Scrobble(TimeStampedModel):
class MediaType(models.TextChoices):
"""Enum mapping a media model type to a string"""
AGENT_SESSION = "AgentSession", "Agent session"
VIDEO = "Video", "Video"
TRACK = "Track", "Track"
PODCAST_EPISODE = "PodcastEpisode", "Podcast episode"
@ -792,6 +796,9 @@ class Scrobble(TimeStampedModel):
return list(map(lambda c: c.value, cls))
uuid = models.UUIDField(editable=False, **BNULL)
agent_session = models.ForeignKey(
AgentSession, on_delete=models.DO_NOTHING, **BNULL
)
video = models.ForeignKey(Video, on_delete=models.DO_NOTHING, **BNULL)
channel = models.ForeignKey("videos.Channel", on_delete=models.DO_NOTHING, **BNULL)
track = models.ForeignKey(Track, on_delete=models.DO_NOTHING, **BNULL)
@ -1361,6 +1368,8 @@ class Scrobble(TimeStampedModel):
@property
def media_obj(self):
media_obj = None
if self.agent_session:
media_obj = self.agent_session
if self.video:
media_obj = self.video
if self.track:

View File

@ -2,6 +2,7 @@ import logging
import re
from datetime import datetime, timedelta
from typing import Any, Optional
from uuid import uuid4
import pendulum
import pytz
@ -1568,3 +1569,134 @@ def manual_scrobble_discgolf(
)
return Scrobble.create_or_update(course, user_id, scrobble_dict)
def _append_agent_turn(
scrobble, prompt: str, provider: str, model: str, *, set_in_progress: bool = False
) -> str:
"""Append a pending turn to an agent session scrobble's log data."""
log = scrobble.log if isinstance(scrobble.log, dict) else {}
prompt_id = str(uuid4())
turns = log.get("turns", [])
turns.append({"prompt_id": prompt_id, "prompt": prompt, "response": None})
log["turns"] = turns
log["provider"] = provider
log["model"] = model
scrobble.log = log
update_fields = ["log"]
if set_in_progress:
scrobble.in_progress = True
update_fields.append("in_progress")
scrobble.save(update_fields=update_fields)
return prompt_id
def manual_scrobble_agent_session(
prompt: str,
user_id: int,
source: str = "Vrobbler",
):
"""Start (or append to) an agent session scrobble for a prompt.
The scrobbled media object is the agent model itself; the initial prompt is
stored as the title in the scrobble's log data. Follow-up prompts append a
new turn to the user's most recent in-progress scrobble for that model, or a
new session is started if none exists. The provider call itself happens
asynchronously in a Celery task.
"""
from agents.models import AgentSession
from django.conf import settings
provider = settings.AGENT_PROVIDER
model = settings.LLM_MODEL if provider == "gemini" else provider
agent_session, _ = AgentSession.find_or_create(provider=provider, model=model)
last_scrobble = (
Scrobble.objects.filter(
user_id=user_id,
agent_session_id=agent_session.id,
in_progress=True,
)
.order_by("-timestamp")
.first()
)
if last_scrobble:
scrobble = last_scrobble
else:
scrobble_dict = {
"user_id": user_id,
"timestamp": timezone.now(),
"playback_position_seconds": 0,
"source": source,
}
scrobble = Scrobble.create_or_update(
agent_session,
user_id,
scrobble_dict,
skip_in_progress_check=True,
)
log = scrobble.log if isinstance(scrobble.log, dict) else {}
if "title" not in log:
log["title"] = prompt
scrobble.log = log
scrobble.save(update_fields=["log"])
prompt_id = _append_agent_turn(scrobble, prompt, provider, model)
logger.info(
"[scrobblers] manual agent session scrobble request received",
extra={
"scrobble_id": scrobble.id,
"agent_session_id": agent_session.id,
"user_id": user_id,
"prompt_id": prompt_id,
"media_type": Scrobble.MediaType.AGENT_SESSION,
},
)
from scrobbles.tasks import scrobble_agent_session_prompt
scrobble_agent_session_prompt.delay(scrobble.id, prompt_id)
return scrobble
def manual_scrobble_agent_follow_up(
prompt: str,
scrobble_id: int,
user_id: int,
):
"""Append a follow-up turn to a specific agent session scrobble."""
from django.conf import settings
scrobble = Scrobble.objects.filter(id=scrobble_id, user_id=user_id).first()
if not scrobble:
return None
log = scrobble.log if isinstance(scrobble.log, dict) else {}
provider = log.get("provider") or settings.AGENT_PROVIDER
model = log.get("model") or (
settings.LLM_MODEL if provider == "gemini" else provider
)
prompt_id = _append_agent_turn(
scrobble, prompt, provider, model, set_in_progress=True
)
logger.info(
"[scrobblers] manual agent session follow-up received",
extra={
"scrobble_id": scrobble.id,
"user_id": user_id,
"prompt_id": prompt_id,
"media_type": Scrobble.MediaType.AGENT_SESSION,
},
)
from scrobbles.tasks import scrobble_agent_session_prompt
scrobble_agent_session_prompt.delay(scrobble.id, prompt_id)
return scrobble

View File

@ -99,6 +99,67 @@ def check_twitch_channels_for_vods():
logger.info(f"[twitch_vods] Matched {matched_count} VODs")
@shared_task
def scrobble_agent_session_prompt(scrobble_id, prompt_id):
"""Ask the configured agent provider for a turn response and store it.
Looks up the pending turn by ``prompt_id`` on the scrobble's log data,
calls the agent provider, then fills in the response and finishes the
scrobble so the detail page can stop polling.
"""
from agents.providers import agent_prompt
from scrobbles.models import Scrobble
scrobble = Scrobble.objects.filter(id=scrobble_id).first()
if not scrobble:
logger.warning(
"[scrobble_agent_session_prompt] scrobble not found",
extra={"scrobble_id": scrobble_id},
)
return
log = scrobble.log if isinstance(scrobble.log, dict) else {}
turn = next(
(t for t in log.get("turns", []) if t.get("prompt_id") == prompt_id), None
)
if not turn:
logger.warning(
"[scrobble_agent_session_prompt] turn not found",
extra={"scrobble_id": scrobble_id, "prompt_id": prompt_id},
)
return
prompt = turn.get("prompt", "")
provider = log.get("provider") or settings.AGENT_PROVIDER
history = []
for prior_turn in log.get("turns", []):
if prior_turn.get("prompt_id") == prompt_id:
break
if prior_turn.get("response") and not prior_turn.get("error"):
history.append(
{"prompt": prior_turn["prompt"], "response": prior_turn["response"]}
)
try:
result = agent_prompt(prompt, provider=provider, history=history)
turn["response"] = result["text"]
log["provider"] = result["provider"]
log["model"] = result["model"]
except Exception as e:
logger.exception(
"[scrobble_agent_session_prompt] provider error",
extra={"scrobble_id": scrobble_id, "prompt_id": prompt_id},
)
turn["response"] = f"Error: {e}"
turn["error"] = True
scrobble.log = log
scrobble.played_to_completion = True
scrobble.in_progress = False
scrobble.save(update_fields=["log", "played_to_completion", "in_progress"])
@shared_task
def process_retroarch_import(import_id):
RetroarchImport = apps.get_model("scrobbles", "RetroarchImport")

View File

@ -44,6 +44,16 @@ urlpatterns = [
views.ManualScrobbleView.as_view(),
name="lookup-manual-scrobble",
),
path(
"agent-session/<int:pk>/partial/",
views.ScrobbleAgentSessionPartialView.as_view(),
name="agent-session-partial",
),
path(
"agent-session/<int:pk>/follow-up/",
views.AgentSessionFollowUpView.as_view(),
name="agent-session-followup",
),
path(
"long-play-finish/<slug:media_uuid>/",
views.scrobble_longplay_finish,

View File

@ -33,6 +33,7 @@ from django.http import (
JsonResponse,
)
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.utils.dateformat import DateFormat
@ -388,6 +389,45 @@ class NowPlayingPartialView(LoginRequiredMixin, TemplateView):
return ctx
class ScrobbleAgentSessionPartialView(LoginRequiredMixin, TemplateView):
template_name = "scrobbles/_agent_session.html"
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
scrobble = get_object_or_404(
Scrobble,
pk=self.kwargs["pk"],
user=self.request.user,
media_type=Scrobble.MediaType.AGENT_SESSION,
)
ctx["object"] = scrobble
return ctx
class AgentSessionFollowUpView(LoginRequiredMixin, View):
http_method_names = ["post"]
template_name = "scrobbles/_agent_session.html"
def post(self, request, pk):
prompt = request.POST.get("prompt", "").strip()
scrobble = get_object_or_404(
Scrobble,
pk=pk,
user=request.user,
media_type=Scrobble.MediaType.AGENT_SESSION,
)
if not prompt:
messages.error(request, "Follow-up prompt is empty.")
return HttpResponseRedirect(scrobble.get_absolute_url())
scrobble = manual_scrobble_agent_follow_up(prompt, scrobble.id, request.user.id)
return TemplateResponse(
request,
self.template_name,
context={"object": scrobble},
)
class ScrobbleListView(LoginRequiredMixin, ListView):
model = Scrobble
paginate_by = 100
@ -643,6 +683,13 @@ class ManualScrobbleView(FormView):
if key == "-f" and " - " not in item_id:
return HttpResponseRedirect(reverse("foods:food_search") + f"?q={item_id}")
if key not in MANUAL_SCROBBLE_FNS:
scrobble = manual_scrobble_agent_session(item_str, self.request.user.id)
if scrobble:
return HttpResponseRedirect(scrobble.get_absolute_url())
messages.error(self.request, "Could not start agent session.")
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER", "/"))
scrobble_fn = MANUAL_SCROBBLE_FNS[key]
scrobble = eval(scrobble_fn)(item_id, self.request.user.id)

View File

@ -82,6 +82,10 @@ TODOIST_CLIENT_SECRET = os.getenv("VROBBLER_TODOIST_CLIENT_SECRET", "")
GOOGLE_API_KEY = os.getenv("VROBBLER_GOOGLE_API_KEY", "")
LICHESS_API_KEY = os.getenv("VROBBLER_LICHESS_API_KEY", "")
AGENT_PROVIDER = os.getenv("VROBBLER_AGENT_PROVIDER", "gemini")
LLM_MODEL = os.getenv("VROBBLER_LLM_MODEL", "gemini-3.6-flash")
OPENCODE_CMD = os.getenv("VROBBLER_OPENCODE_CMD", "opencode")
AMAZON_PAAPI_ACCESS_KEY = os.getenv("VROBBLER_AMAZON_PAAPI_ACCESS_KEY", "")
AMAZON_PAAPI_SECRET_KEY = os.getenv("VROBBLER_AMAZON_PAAPI_SECRET_KEY", "")
AMAZON_PAAPI_ASSOCIATE_TAG = os.getenv("VROBBLER_AMAZON_PAAPI_ASSOCIATE_TAG", "")
@ -223,6 +227,7 @@ INSTALLED_APPS = [
"oauth2_provider",
"encrypted_field",
"profiles",
"agents",
"scrobbles",
"people",
"charts",

View File

@ -32,8 +32,10 @@
{% endif %}
{% if request.user.is_authenticated %}
<td>{{obj.scrobble_count}}</td>
{% if obj.start_url %}
<td><a type="button" class="btn btn-sm btn-primary" href="{{obj.start_url}}">Scrobble</a></td>
{% endif %}
{% endif %}
</tr>
{% endfor %}
</tbody>

View File

@ -0,0 +1,35 @@
{% extends "base_list.html" %}
{% load static %}
{% block title %}{{object.title}}{% endblock %}
{% block lists %}
<div class="row webpage">
<div class="webpage-metadata">
<h2>{{ object.title }}</h2>
<p class="text-muted">{{ object.subtitle }}</p>
</div>
<hr/>
</div>
<div class="row">
<div class="col-md">
<h3>Last scrobbles</h3>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
</tr>
</thead>
<tbody>
{% for scrobble in scrobbles %}
<tr>
<td><a href={{scrobble.get_absolute_url}}>{{scrobble.local_timestamp}}</a></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,13 @@
{% extends "base_list.html" %}
{% block title %}Agent sessions{% endblock %}
{% block lists %}
<div class="row">
<div class="col-md">
<div class="table-responsive">
{% include "_scrobblable_list.html" %}
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,24 @@
{% load static %}
<div id="agent-session-content"
hx-get="{% url 'scrobbles:agent-session-partial' object.id %}"
hx-trigger="{% if object.logdata.pending %}every 5s{% else %}none{% endif %}"
hx-swap="outerHTML">
{% if object.logdata.as_html %}
{{ object.logdata.as_html|safe }}
{% else %}
<p class="text-muted">No conversation yet.</p>
{% endif %}
{% if not object.logdata.pending %}
<form class="mt-3"
hx-post="{% url 'scrobbles:agent-session-followup' object.id %}"
hx-target="#agent-session-content"
hx-swap="outerHTML">
<div class="mb-2">
{% csrf_token %}
<textarea name="prompt" class="form-control" rows="2"
placeholder="Ask a follow-up..."></textarea>
</div>
<button type="submit" class="btn btn-sm btn-primary">Ask</button>
</form>
{% endif %}
</div>

View File

@ -49,13 +49,13 @@
<div class="row">
<h1 class="d-flex align-items-center gap-2">
{% if object.media_type == "Video" %}🎬{% elif object.media_type == "Track" %}🎵{% elif object.media_type == "PodcastEpisode" %}🎙️{% elif object.media_type == "SportEvent" %}⚽{% elif object.media_type == "Book" %}📚{% elif object.media_type == "Paper" %}📄{% elif object.media_type == "VideoGame" %}🎮{% elif object.media_type == "BoardGame" %}🎲{% elif object.media_type == "GeoLocation" %}📍{% elif object.media_type == "Trail" %}🥾{% elif object.media_type == "Beer" %}🍺{% elif object.media_type == "Puzzle" %}🧩{% elif object.media_type == "Food" %}🍔{% elif object.media_type == "Task" %}✅{% elif object.media_type == "WebPage" %}🌐{% elif object.media_type == "LifeEvent" %}🎉{% elif object.media_type == "Mood" %}😊{% elif object.media_type == "BrickSet" %}🧱{% elif object.media_type == "Channel" %}📺{% endif %}
{% if object.media_type == "Video" %}🎬{% elif object.media_type == "Track" %}🎵{% elif object.media_type == "PodcastEpisode" %}🎙️{% elif object.media_type == "SportEvent" %}⚽{% elif object.media_type == "Book" %}📚{% elif object.media_type == "Paper" %}📄{% elif object.media_type == "VideoGame" %}🎮{% elif object.media_type == "BoardGame" %}🎲{% elif object.media_type == "GeoLocation" %}📍{% elif object.media_type == "Trail" %}🥾{% elif object.media_type == "Beer" %}🍺{% elif object.media_type == "Puzzle" %}🧩{% elif object.media_type == "Food" %}🍔{% elif object.media_type == "Task" %}✅{% elif object.media_type == "WebPage" %}🌐{% elif object.media_type == "LifeEvent" %}🎉{% elif object.media_type == "Mood" %}😊{% elif object.media_type == "BrickSet" %}🧱{% elif object.media_type == "Channel" %}📺{% elif object.media_type == "AgentSession" %}🤖{% endif %}
{% if object.media_obj.get_absolute_url %}
<a href="{{ object.media_obj.get_absolute_url }}">{% endif %}
{{ object.media_obj.title }}
{% if object.media_obj.get_absolute_url %}</a>
{% endif %}
{% if user.is_authenticated and object.media_obj %}
{% if user.is_authenticated and object.media_obj and object.media_type != "AgentSession" %}
<button id="favorite-btn"
data-url="{% url 'scrobbles:toggle-favorite' object.media_type object.media_obj.id %}"
data-favorited="{{ is_favorited|yesno:'true,false' }}"
@ -77,6 +77,10 @@
</p>
<h2>{{ object.logdata.title }}</h2>
{% endif %}
{% if object.media_type == "AgentSession" and object.logdata.title %}
</p>
<h2>{{ object.logdata.title }}</h2>
{% endif %}
<h3 class="text-muted">{{ object.local_timestamp }}</h3>
{% if user.is_authenticated and object.user == user %}
@ -275,6 +279,12 @@
</div>
{% endif %}
{% if object.media_type == "AgentSession" %}
<div class="mb-3">
{% include "scrobbles/_agent_session.html" %}
</div>
{% endif %}
{% if user.is_authenticated and object.user == user %}
<button class="btn btn-secondary mb-3" type="button" data-bs-toggle="collapse" data-bs-target="#editLogForm">
Edit Log
@ -322,7 +332,7 @@
<tr{% if scrobble.id == object.id %} class="table-active fw-bold"{% endif %}>
<td>{% if scrobble.id == object.id %}{{ scrobble.timestamp|date:"M d, Y" }}{% else %}<a href="{% url 'scrobbles:detail' scrobble.id %}">{{ scrobble.timestamp|date:"M d, Y" }}</a>{% endif %}</td>
<td>
{% 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 }}{% endif %}
{% if scrobble.media_type == "Task" and scrobble.logdata.title %}{{ scrobble.media_obj.title }}: {{ scrobble.logdata.title }}{% elif scrobble.media_type == "AgentSession" and scrobble.logdata.title %}{{ scrobble.logdata.title }}{% else %}{{ scrobble.media_obj.title|default:scrobble.media_obj }}{% endif %}
</td>
<td>
{% if scrobble.is_long_play and scrobble.long_play_seconds %}

View File

@ -7,6 +7,7 @@ from oauth2_provider import urls as oauth2_urls
from rest_framework import routers
import vrobbler.apps.scrobbles.views as scrobbles_views
from vrobbler.apps.agents import urls as agents_urls
from vrobbler.apps.birds import urls as birds_urls
from vrobbler.apps.birds.api.views import BirdingLocationViewSet, BirdViewSet
from vrobbler.apps.boardgames import urls as boardgame_urls
@ -196,6 +197,7 @@ urlpatterns = [
path("", include(podcast_urls, namespace="podcasts")),
path("", include(lifeevents_urls, namespace="life-events")),
path("", include(moods_urls, namespace="moods")),
path("", include(agents_urls, namespace="agents")),
path("", include(birds_urls, namespace="birds")),
path("", include(nature_urls, namespace="nature")),
path("", include(scrobble_urls, namespace="scrobbles")),