diff --git a/PROJECT.org b/PROJECT.org index 804f5e4..5499fa9 100644 --- a/PROJECT.org +++ b/PROJECT.org @@ -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 - 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": , +"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 diff --git a/tests/agents_tests/__init__.py b/tests/agents_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/agents_tests/test_agent_sessions.py b/tests/agents_tests/test_agent_sessions.py new file mode 100644 index 0000000..235a8da --- /dev/null +++ b/tests/agents_tests/test_agent_sessions.py @@ -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 '
' in html + assert "Prompt 1" in html + assert "bold?" in html + assert "yes" in html + assert "Prompt 2" in html + assert "Thinking" in html diff --git a/vrobbler/apps/agents/__init__.py b/vrobbler/apps/agents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/agents/admin.py b/vrobbler/apps/agents/admin.py new file mode 100644 index 0000000..66fca28 --- /dev/null +++ b/vrobbler/apps/agents/admin.py @@ -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",) diff --git a/vrobbler/apps/agents/apps.py b/vrobbler/apps/agents/apps.py new file mode 100644 index 0000000..5affdcb --- /dev/null +++ b/vrobbler/apps/agents/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AgentsConfig(AppConfig): + name = "agents" diff --git a/vrobbler/apps/agents/migrations/0001_initial.py b/vrobbler/apps/agents/migrations/0001_initial.py new file mode 100644 index 0000000..e7e515e --- /dev/null +++ b/vrobbler/apps/agents/migrations/0001_initial.py @@ -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, + }, + ), + ] diff --git a/vrobbler/apps/agents/migrations/__init__.py b/vrobbler/apps/agents/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vrobbler/apps/agents/models.py b/vrobbler/apps/agents/models.py new file mode 100644 index 0000000..0e5d389 --- /dev/null +++ b/vrobbler/apps/agents/models.py @@ -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('
') + html_parts.append( + f'
Prompt {i + 1}
' + f"{self._md_to_html(prompt)}
" + ) + if response: + html_parts.append( + f'
Response
' + f"{self._md_to_html(response)}
" + ) + else: + html_parts.append( + '
Thinking…
' + ) + html_parts.append("
") + 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 "" diff --git a/vrobbler/apps/agents/providers.py b/vrobbler/apps/agents/providers.py new file mode 100644 index 0000000..d492678 --- /dev/null +++ b/vrobbler/apps/agents/providers.py @@ -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) diff --git a/vrobbler/apps/agents/urls.py b/vrobbler/apps/agents/urls.py new file mode 100644 index 0000000..7715e01 --- /dev/null +++ b/vrobbler/apps/agents/urls.py @@ -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//", + views.AgentSessionDetailView.as_view(), + name="agent_session_detail", + ), +] diff --git a/vrobbler/apps/agents/views.py b/vrobbler/apps/agents/views.py new file mode 100644 index 0000000..893d202 --- /dev/null +++ b/vrobbler/apps/agents/views.py @@ -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 diff --git a/vrobbler/apps/scrobbles/forms.py b/vrobbler/apps/scrobbles/forms.py index aa0e6ab..0ba6218 100644 --- a/vrobbler/apps/scrobbles/forms.py +++ b/vrobbler/apps/scrobbles/forms.py @@ -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", } ), diff --git a/vrobbler/apps/scrobbles/migrations/0105_scrobble_agent_session_and_more.py b/vrobbler/apps/scrobbles/migrations/0105_scrobble_agent_session_and_more.py new file mode 100644 index 0000000..9f0b98d --- /dev/null +++ b/vrobbler/apps/scrobbles/migrations/0105_scrobble_agent_session_and_more.py @@ -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, + ), + ), + ] diff --git a/vrobbler/apps/scrobbles/models.py b/vrobbler/apps/scrobbles/models.py index 53bfff8..0fff2e5 100644 --- a/vrobbler/apps/scrobbles/models.py +++ b/vrobbler/apps/scrobbles/models.py @@ -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: diff --git a/vrobbler/apps/scrobbles/scrobblers.py b/vrobbler/apps/scrobbles/scrobblers.py index 495fffe..da48bec 100644 --- a/vrobbler/apps/scrobbles/scrobblers.py +++ b/vrobbler/apps/scrobbles/scrobblers.py @@ -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 diff --git a/vrobbler/apps/scrobbles/tasks.py b/vrobbler/apps/scrobbles/tasks.py index 07cc407..795fd50 100644 --- a/vrobbler/apps/scrobbles/tasks.py +++ b/vrobbler/apps/scrobbles/tasks.py @@ -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") diff --git a/vrobbler/apps/scrobbles/urls.py b/vrobbler/apps/scrobbles/urls.py index 6fb1e76..5219256 100644 --- a/vrobbler/apps/scrobbles/urls.py +++ b/vrobbler/apps/scrobbles/urls.py @@ -44,6 +44,16 @@ urlpatterns = [ views.ManualScrobbleView.as_view(), name="lookup-manual-scrobble", ), + path( + "agent-session//partial/", + views.ScrobbleAgentSessionPartialView.as_view(), + name="agent-session-partial", + ), + path( + "agent-session//follow-up/", + views.AgentSessionFollowUpView.as_view(), + name="agent-session-followup", + ), path( "long-play-finish//", views.scrobble_longplay_finish, diff --git a/vrobbler/apps/scrobbles/views.py b/vrobbler/apps/scrobbles/views.py index 8f5b6f3..570caba 100644 --- a/vrobbler/apps/scrobbles/views.py +++ b/vrobbler/apps/scrobbles/views.py @@ -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) diff --git a/vrobbler/settings.py b/vrobbler/settings.py index f82d42a..8674596 100644 --- a/vrobbler/settings.py +++ b/vrobbler/settings.py @@ -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", diff --git a/vrobbler/templates/_scrobblable_list.html b/vrobbler/templates/_scrobblable_list.html index 58187a2..7931c92 100644 --- a/vrobbler/templates/_scrobblable_list.html +++ b/vrobbler/templates/_scrobblable_list.html @@ -32,8 +32,10 @@ {% endif %} {% if request.user.is_authenticated %} {{obj.scrobble_count}} + {% if obj.start_url %} Scrobble {% endif %} + {% endif %} {% endfor %} diff --git a/vrobbler/templates/agents/agentsession_detail.html b/vrobbler/templates/agents/agentsession_detail.html new file mode 100644 index 0000000..5db31bb --- /dev/null +++ b/vrobbler/templates/agents/agentsession_detail.html @@ -0,0 +1,35 @@ +{% extends "base_list.html" %} +{% load static %} + +{% block title %}{{object.title}}{% endblock %} + +{% block lists %} +
+ +
+
+
+
+

Last scrobbles

+
+ + + + + + + + {% for scrobble in scrobbles %} + + + + {% endfor %} + +
Date
{{scrobble.local_timestamp}}
+
+
+
+{% endblock %} diff --git a/vrobbler/templates/agents/agentsession_list.html b/vrobbler/templates/agents/agentsession_list.html new file mode 100644 index 0000000..5eadbf7 --- /dev/null +++ b/vrobbler/templates/agents/agentsession_list.html @@ -0,0 +1,13 @@ +{% extends "base_list.html" %} + +{% block title %}Agent sessions{% endblock %} + +{% block lists %} +
+
+
+ {% include "_scrobblable_list.html" %} +
+
+
+{% endblock %} diff --git a/vrobbler/templates/scrobbles/_agent_session.html b/vrobbler/templates/scrobbles/_agent_session.html new file mode 100644 index 0000000..1b1768b --- /dev/null +++ b/vrobbler/templates/scrobbles/_agent_session.html @@ -0,0 +1,24 @@ +{% load static %} +
+ {% if object.logdata.as_html %} + {{ object.logdata.as_html|safe }} + {% else %} +

No conversation yet.

+ {% endif %} + {% if not object.logdata.pending %} +
+
+ {% csrf_token %} + +
+ +
+ {% endif %} +
diff --git a/vrobbler/templates/scrobbles/scrobble_detail.html b/vrobbler/templates/scrobbles/scrobble_detail.html index edce629..d0ed40f 100644 --- a/vrobbler/templates/scrobbles/scrobble_detail.html +++ b/vrobbler/templates/scrobbles/scrobble_detail.html @@ -49,13 +49,13 @@

-{% 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 %} {% endif %} {{ object.media_obj.title }} {% if object.media_obj.get_absolute_url %} {% endif %} -{% if user.is_authenticated and object.media_obj %} +{% if user.is_authenticated and object.media_obj and object.media_type != "AgentSession" %}

{% endif %} +{% if object.media_type == "AgentSession" %} +
+ {% include "scrobbles/_agent_session.html" %} +
+{% endif %} + {% if user.is_authenticated and object.user == user %}