Compare commits

...

8 Commits
41.0 ... 43.0

Author SHA1 Message Date
b0b22b79dc [release] Bump to version 43.0
All checks were successful
build / test (push) Successful in 1m56s
deploy / test (push) Successful in 1m56s
deploy / build-and-deploy (push) Successful in 33s
- Can we show a graph of all past Weigh-in tasks
- When viewing scrobbles by tag, sum the total time
- Orgmode tasks are not updated if in progress
- Ignore tag 'inprogress' for Tasks
- Deploys are now throwing an unknown version error
2026-06-04 22:13:36 -04:00
6471413681 [tasks] Add weigh in graph 2026-06-04 22:13:14 -04:00
50b10689fc [scrobbles] Add total time to tag views 2026-06-04 22:01:35 -04:00
85bddb6cba [tasks] Better updating of org mode tasks 2026-06-04 15:16:44 -04:00
c285b0d3b3 [tasks] Exclude inpgrogress tag, they're always inprogress
All checks were successful
build / test (push) Successful in 2m14s
2026-06-04 14:45:10 -04:00
671fe8d86f [tooling] Fix releases once and for all 2026-06-04 14:44:19 -04:00
89817110de [release] Bump to version 42.0
Some checks failed
deploy / test (push) Successful in 2m6s
build / test (push) Successful in 2m1s
deploy / build-and-deploy (push) Failing after 26s
- Add ability to add track to current Mopidy queue
2026-06-04 13:13:21 -04:00
ee01e3d8df [tracks] Add mopidy queue button
All checks were successful
build / test (push) Successful in 2m2s
2026-06-04 13:11:21 -04:00
15 changed files with 350 additions and 28 deletions

View File

@ -93,7 +93,7 @@ fetching and simple saving.
:LOGBOOK:
CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
:END:
* Backlog [0/14] :vrobbler:project:personal:
* Backlog [0/13] :vrobbler:project:personal:
** TODO [#C] Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment:
:PROPERTIES:
:ID: 37781d6a-f3b0-48b2-bf98-33c2c791cf85
@ -459,11 +459,11 @@ https://app.todoist.com/app/task/add-a-csv-endpoint-for-users-book-reads-that-li
- Note taken on [2025-09-25 Thu 10:51]
As an example https://comicbookroundup.com/comic-books/reviews/humanoids-publishing/the-history-of-science-fiction
** TODO [#B] Find page numbers for comic books from ComicVine :vrobbler:feature:books:personal:project:
** TODO [#B] Find page numbers for comic books from ComicVine :feature:books:
:PROPERTIES:
:ID: 79f867c3-1288-4143-b6bf-2a452983ee9f
:END:
** TODO [#B] Fix koreader scrobble imports to use DST properly :vrobbler:personal:bug:books:imports:
** TODO [#B] Fix koreader scrobble imports to use DST properly :bug:books:imports:
:PROPERTIES:
:ID: 79758cba-a440-48b6-a637-efb88827acf2
:END:
@ -489,7 +489,36 @@ whatever time KoReader reports, we need to know, given the date and the user
profile's historic timezone, how many hours to adjust the KoReader time to get
to GMT to save it in the database.
** TODO [#A] Orgmode tasks are not updated if in progress :tasks:orgmode:bug:
* Version 43.0 [5/5]
** DONE [#B] Can we show a graph of all past Weigh-in tasks :scale:tasks:graphs:javascript:
:PROPERTIES:
:ID: ae499d87-03bf-4e48-9b2c-1a421a46af11
:END:
*** Description
I wonder if, as a special type of task, Weigh-in's could show a graph of the
metrics that are stored against all the past weigh-ins?
The graph would contain all Weigh-in scrobbles for that user, no matter which
date is being viewed, and the highlighted value on the graph would be the date
being viewed.
Probably could use something like chart.js although maybe that's too heavy?
And can we have each metric overlayed on the same graph?
** DONE [#B] When viewing scrobbles by tag, sum the total time :scrobbles:tags:
:PROPERTIES:
:ID: d51f23df-c2c5-4e1a-b000-67c89032af02
:END:
*** Description
On scrobbles filtered by tags, we should see a sum of the time spent doing those tasks, in a human
readable format like "X days, X hours, X minutes and X seconds"
** DONE [#A] Orgmode tasks are not updated if in progress :tasks:orgmode:bug:
:PROPERTIES:
:ID: 7dcebb2c-7c4c-4ac5-bee6-c2e36c3811f9
:END:
@ -505,6 +534,52 @@ different. And the same for comments. If a comment (by timestamp key) is
different in the webhook than what's in the scrobble.log, update the comment in
the scrobble.log
** DONE [#A] Ignore tag 'inprogress' for Tasks :bug:tasks:tags:
:PROPERTIES:
:ID: cd37c1ec-e2fc-b93c-daf8-6b329712c3f1
:END:
*** Description
When scrobbling tasks from Todoist, the tag `inprogress` is always in the
payload, because that's how we parse tasks starting from the Todoist webhooks.
But we don't really need anything tagged as `inprogress` Can we ignore this tag
when applying tags to Task scrobbles coming from Todoist?`
** DONE [#A] Deploys are now throwing an unknown version error :bug:tooling:releases:
:PROPERTIES:
:ID: 3870f9d3-b5ed-4b87-9e8c-9bf905bfb766
:END:
*** Description
Almost everything is working, but for some reason `__version__` does not seem to
exist.
#+begin_src sh
out: Installing collected packages: vrobbler
out: Successfully installed vrobbler-42.0
err: WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv
err: Traceback (most recent call last):
err: File "<string>", line 1, in <module>
err: AttributeError: module 'vrobbler' has no attribute '__version__'
2026/06/04 17:18:15 Process exited with status 1
failed to remove container: Error response from daemon: removal of container c8ac64bee9b6bf5978d2c16f299e5ac271d8bbf7192b7a4023c3712bc2444f8b is already in progress
❌ Failure - Main Install wheel and restart services
exit with `FAILURE`: 1
#+end_src
* Version 42.0 [1/1]
** DONE [#B] Add ability to add track to current Mopidy queue :feature:mopidy:tracks:
:PROPERTIES:
:ID: 79d5b580-4ea6-461b-4c6c-2c950d8b3e4c
:END:
* Version 41.0 [5/5]
** DONE [#B] For any scrobble detail page with notes display them better :templates:notes:scrobbles:
:PROPERTIES:

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "vrobbler"
version = "41.0"
version = "43.0"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]
@ -107,6 +107,8 @@ exclude_dirs = ["*/tests/*", "*/migrations/*"]
[tool.poetry.scripts]
vrobbler = "vrobbler.cli:main"
[tool.poetry_bumpversion.file."vrobbler/__init__.py"]
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

View File

@ -2,4 +2,5 @@
# Django starts so that shared_task will use this app.
from .celery import app as celery_app
__all__ = ("celery_app",)
__version__ = "42.0"
__all__ = ("celery_app", "__version__")

View File

@ -31,6 +31,7 @@ class UserProfileForm(forms.ModelForm):
"webdav_auto_import",
"ntfy_url",
"ntfy_enabled",
"mopidy_api_url",
"redirect_to_webpage",
"enable_public_widgets",
"widget_custom_css",

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.30 on 2026-06-04 16:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0032_userprofile_weigh_in_units"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="mopidy_api_url",
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -64,6 +64,8 @@ class UserProfile(TimeStampedModel):
ntfy_url = models.CharField(max_length=255, **BNULL)
ntfy_enabled = models.BooleanField(default=False)
mopidy_api_url = models.CharField(max_length=255, **BNULL)
redirect_to_webpage = models.BooleanField(default=True)
enable_public_widgets = models.BooleanField(default=False)

View File

@ -761,7 +761,10 @@ def todoist_scrobble_task(
todoist_task["title"] = todoist_task.pop("description")
todoist_task["description"] = todoist_task.pop("details")
todoist_task["labels"] = todoist_task.pop("todoist_label_list", [])
labels = todoist_task.pop("todoist_label_list", [])
todoist_task["labels"] = [
l for l in labels if l.lower() != "inprogress"
]
todoist_task.pop("todoist_type")
todoist_task.pop("todoist_event")

View File

@ -158,6 +158,11 @@ urlpatterns = [
views.ScrobbleDetailView.as_view(),
name="detail",
),
path(
"scrobbles/<slug:uuid>/add-to-mopidy-queue/",
views.add_to_mopidy_queue,
name="add-to-mopidy-queue",
),
path("scrobbles/<slug:uuid>/start/", views.scrobble_start, name="start"),
path("scrobbles/<slug:uuid>/finish/", views.scrobble_finish, name="finish"),
path("scrobbles/<slug:uuid>/cancel/", views.scrobble_cancel, name="cancel"),

View File

@ -4,6 +4,8 @@ import logging
from datetime import datetime, timedelta
from uuid import uuid4
import requests
import pendulum
import pytz
from dateutil.relativedelta import relativedelta
@ -12,7 +14,7 @@ from django.contrib import messages
from django.contrib.auth import get_user_model
from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.db.models import Count, Max, Q
from django.db.models import Count, Max, Q, Sum
from django.db.models.query import QuerySet
from rest_framework.authentication import TokenAuthentication
from rest_framework.authtoken.models import Token
@ -30,10 +32,11 @@ from django.http import (
HttpResponseRedirect,
JsonResponse,
)
from django.shortcuts import redirect
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse_lazy
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django.views.generic import DetailView, FormView, TemplateView
from django.views.generic.edit import CreateView
from django.views.generic.list import ListView
@ -364,6 +367,7 @@ class ScrobbleListView(LoginRequiredMixin, ListView):
else:
tag_list = []
self.tag_list = tag_list
self._full_queryset = qs
return qs
def _compute_overlap_groups(self, scrobbles):
@ -430,6 +434,13 @@ class ScrobbleListView(LoginRequiredMixin, ListView):
ctx["tag_list"] = getattr(self, "tag_list", [])
scrobbles = list(ctx.get("object_list", []))
ctx["overlap_map"] = self._compute_overlap_groups(scrobbles)
full_qs = getattr(self, "_full_queryset", None)
if full_qs is not None and getattr(self, "tag_list", []):
total = (
full_qs.aggregate(total=Sum("playback_position_seconds"))["total"]
or 0
)
ctx["total_time_seconds"] = total
return ctx
@ -963,6 +974,65 @@ def scrobble_cancel(request, uuid):
return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
@require_POST
def add_to_mopidy_queue(request, uuid):
if not request.user.is_authenticated:
return redirect("scrobbles:detail", uuid=uuid)
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
mopidy_url = request.user.profile.mopidy_api_url
if not mopidy_url:
messages.add_message(
request,
messages.ERROR,
"Mopidy API URL not configured in your profile settings.",
)
return redirect("scrobbles:detail", uuid=uuid)
raw_data = scrobble.log.get("raw_data", {})
mopidy_uri = raw_data.get("mopidy_uri")
logger.debug(mopidy_uri)
if not mopidy_uri:
messages.add_message(
request,
messages.ERROR,
"No Mopidy URI found for this scrobble.",
)
return redirect("scrobbles:detail", uuid=uuid)
rpc_url = mopidy_url.rstrip("/") + "/rpc"
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "core.tracklist.add",
"params": {"uris": [mopidy_uri]},
}
try:
resp = requests.post(rpc_url, json=payload, timeout=10)
resp.raise_for_status()
rpc_result = resp.json()
if rpc_result.get("error"):
messages.add_message(
request,
messages.ERROR,
f'Mopidy error: {rpc_result["error"]}',
)
else:
msg = f'Added "{scrobble.media_obj}" to Mopidy queue.'
messages.add_message(request, messages.SUCCESS, msg)
except requests.RequestException as e:
messages.add_message(
request,
messages.ERROR,
f"Failed to contact Mopidy: {e}",
)
return redirect("scrobbles:detail", uuid=uuid)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def export(request):

View File

@ -1,5 +1,6 @@
import logging
import pendulum
from django.contrib import messages
from django.http import HttpResponseRedirect
from django.urls import reverse_lazy
@ -8,6 +9,7 @@ from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from scrobbles.models import Scrobble
from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView
from tasks.models import Task
@ -23,6 +25,72 @@ class TaskListView(ScrobbleableListView):
class TaskDetailView(ScrobbleableDetailView):
model = Task
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
if self.object.title != "Weigh-in":
return ctx
scrobbles = list(
Scrobble.objects.filter(
user=self.request.user,
task=self.object,
log__weight__isnull=False,
).order_by("timestamp")
)
if not scrobbles:
return ctx
labels = []
weight_data = []
body_fat_data = []
bmi_data = []
for s in scrobbles:
ts = s.timestamp
if isinstance(ts, str):
ts = pendulum.parse(ts)
labels.append(ts.strftime("%Y-%m-%d"))
log = s.log if isinstance(s.log, dict) else {}
raw_weight = log.get("weight")
weight_data.append(
float(raw_weight) if raw_weight is not None else None
)
raw_bf = log.get("body_fat")
body_fat_data.append(
float(raw_bf) if raw_bf is not None else None
)
raw_bmi = log.get("bmi")
bmi_data.append(
float(raw_bmi) if raw_bmi is not None else None
)
ctx["weighin_chart"] = {
"labels": labels,
"datasets": [
{
"label": "Weight",
"data": weight_data,
"borderColor": "#4bc0c0",
"fill": False,
"yAxisID": "y",
},
{
"label": "Body Fat %",
"data": body_fat_data,
"borderColor": "#ff6384",
"fill": False,
"yAxisID": "y1",
},
{
"label": "BMI",
"data": bmi_data,
"borderColor": "#36a2eb",
"fill": False,
"yAxisID": "y2",
},
],
}
return ctx
@api_view(["GET"])
@permission_classes([IsAuthenticated])

View File

@ -232,7 +232,7 @@ class EmacsWebhookView(APIView):
status=status.HTTP_304_NOT_MODIFIED,
)
if task_in_progress:
if scrobble and scrobble.in_progress:
emacs_scrobble_update_task(
post_data.get("source_id"),
post_data.get("notes") or [],

View File

@ -8,21 +8,20 @@ def natural_duration(value):
if not value:
return
value = int(value)
total_minutes = int(value / 60)
hours = int(total_minutes / 60)
minutes = total_minutes - (hours * 60)
seconds = value % 60
value_str = ""
if seconds:
value_str = f"{seconds} seconds"
if minutes:
if value_str:
value_str = f"{minutes} minutes, " + value_str
else:
value_str = f"{minutes} minutes"
days = int(value / 86400)
remainder = value % 86400
hours = int(remainder / 3600)
minutes = int((remainder % 3600) / 60)
seconds = remainder % 60
parts = []
if days:
parts.append(f"{days} day{'s' if days != 1 else ''}")
if hours:
if value_str:
value_str = f"{hours} hours, " + value_str
else:
value_str = f"{hours} hours"
return value_str
parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
if minutes:
parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
if seconds or not parts:
parts.append(f"{seconds} second{'s' if seconds != 1 else ''}")
if len(parts) == 1:
return parts[0]
return ", ".join(parts[:-1]) + " and " + parts[-1]

View File

@ -9,6 +9,9 @@
<h1 class="h2">All Scrobbles</h1>
{% if tag_list %}
<h6 class="text-muted">Tagged {{ tag_list|join:", " }}</h6>
{% if total_time_seconds %}
<p class="text-muted small mb-0">Total time: {{ total_time_seconds|natural_duration }}</p>
{% endif %}
{% endif %}
</div>
</div>

View File

@ -35,11 +35,18 @@
<h1>
{% 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_obj.get_absolute_url %}<a href="{{ object.media_obj.get_absolute_url }}">{% endif %}{{ object.media_obj }}{% if object.media_obj.get_absolute_url %}</a>{% endif %}</h1>
{% if object.media_obj.get_absolute_url %}<a href="{{ object.media_obj.get_absolute_url }}">{% endif %}{{ object.media_obj }}{% if object.media_obj.get_absolute_url %}</a>{% endif %}
</h1>
{% if object.media_type == "Task" and object.logdata.title %}
<h2>{{ object.logdata.title }}</h2>
{% endif %}
<h3 class="text-muted">{{ object.local_timestamp }}</h3>
{% if object.media_type == "Track" and object.log.raw_data.mopidy_uri and user.profile.mopidy_api_url %}
<form method="post" action="{% url 'scrobbles:add-to-mopidy-queue' object.uuid %}" class="mb-1">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-secondary">add to mopidy queue</button>
</form>
{% endif %}
{% if object.media_type == "Track" %}
<p class="text-muted small">Source: {{ object.source }}{% if object.log.mopidy_source %} ({{ object.log.mopidy_source|capfirst }}){% endif %}</p>
{% endif %}

View File

@ -2,6 +2,7 @@
{% load mathfilters %}
{% load static %}
{% load naturalduration %}
{% load l10n %}
{% block title %}{{object.title}}{% endblock %}
@ -39,6 +40,15 @@
</p>
</div>
</div>
{% if weighin_chart %}
<div class="row mb-4">
<div class="col-md-8">
<canvas id="weighinChart" width="800" height="300"></canvas>
</div>
</div>
{% endif %}
<div class="row">
<p>{{scrobbles.count}} scrobbles</p>
<p>
@ -94,3 +104,61 @@
{% endif %}
</div>
{% endblock %}
{% block extra_js %}
{% if weighin_chart %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@2.9.4/dist/Chart.min.js"></script>
<script>
var ctx = document.getElementById('weighinChart').getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: {{ weighin_chart.labels|safe }},
datasets: [
{
label: 'Weight',
data: {{ weighin_chart.datasets.0.data|safe }},
borderColor: '{{ weighin_chart.datasets.0.borderColor }}',
backgroundColor: 'transparent',
pointRadius: 2,
fill: false,
yAxisID: 'y',
},
{
label: 'Body Fat %',
data: {{ weighin_chart.datasets.1.data|safe }},
borderColor: '{{ weighin_chart.datasets.1.borderColor }}',
backgroundColor: 'transparent',
pointRadius: 2,
fill: false,
yAxisID: 'y1',
},
{
label: 'BMI',
data: {{ weighin_chart.datasets.2.data|safe }},
borderColor: '{{ weighin_chart.datasets.2.borderColor }}',
backgroundColor: 'transparent',
pointRadius: 2,
fill: false,
yAxisID: 'y2',
},
]
},
options: {
responsive: true,
scales: {
xAxes: [{
ticks: { maxTicksLimit: 25, maxRotation: 45 },
}],
yAxes: [
{ id: 'y', type: 'linear', position: 'left', scaleLabel: { display: true, labelString: 'Weight' } },
{ id: 'y1', type: 'linear', position: 'right', scaleLabel: { display: true, labelString: 'Body Fat %' }, gridLines: { display: false } },
{ id: 'y2', type: 'linear', position: 'right', scaleLabel: { display: true, labelString: 'BMI' }, gridLines: { display: false } },
]
},
legend: { position: 'bottom' },
}
});
</script>
{% endif %}
{% endblock %}