Compare commits

..

16 Commits
40.2 ... 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
a70343d6f3 [release] Bump to version 41.0
Some checks failed
build / test (push) Successful in 1m56s
deploy / test (push) Successful in 1m58s
deploy / build-and-deploy (push) Failing after 34s
- For any scrobble detail page with notes display them better
- Imports should send notifications
- Board game imports send duplicate ntfy message
- Too many geolocation notifications go out
- Fix bug where Weigh-in imports do not set title
2026-06-04 11:22:48 -04:00
3e72042c24 [justfile] push for release should only push tags
Some checks failed
build / test (push) Has been cancelled
2026-06-04 11:22:14 -04:00
087c7775ae [templates] Fix note parsing
Some checks failed
build / test (push) Has been cancelled
2026-06-04 11:15:58 -04:00
3f71065ad6 [notifications] Send ntfy on more imports
All checks were successful
build / test (push) Successful in 2m6s
2026-06-04 10:50:32 -04:00
801672124f [notifications] Fix duplicate ntfy for board games
All checks were successful
build / test (push) Successful in 1m58s
2026-06-04 10:47:22 -04:00
811e9c1ce9 [notfications] Dont send ntfy on non-titled geolocations
All checks were successful
build / test (push) Successful in 2m7s
2026-06-04 10:44:21 -04:00
415b32bdc7 [tasks] Scale task sets title properly
All checks were successful
build / test (push) Successful in 1m57s
2026-06-03 16:49:11 -04:00
22319c807a [tooling] Fix deploys for realz
All checks were successful
build / test (push) Successful in 2m0s
2026-06-03 16:38:10 -04:00
24 changed files with 582 additions and 61 deletions

View File

@ -127,7 +127,7 @@ jobs:
pip uninstall -y vrobbler
pip install /var/lib/vrobbler/dist/*.whl
rm -f /var/lib/vrobbler/dist/*.whl
python -c "import vrobbler; print(f'vrobbler {vrobbler.__version__} installed OK')"
python3 -c "import vrobbler; print(f'vrobbler {vrobbler.__version__} installed OK')"
vrobbler migrate
vrobbler collectstatic --noinput
immortalctl restart vrobbler-celery && immortalctl restart vrobbler-celerybeat && immortalctl restart vrobbler

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/15] :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,7 +534,106 @@ 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
** TODO [#C] Fix bug where Weigh-in imports do not set title :bug:tasks:scale:
** 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:
:ID: c0dcf9da-227f-4a22-bcd9-9d46053607d9
:END:
*** Description
Currently notes are displayed as little post-it notes. This is cute, but not terribly useful.
We should update note rendering to be a simple newest to oldest display in a
single column with the timestamp has a small header, and the content rendered as
markdown with a small bar or horizontal divider marking them from the next note.
** DONE [#A] Imports should send notifications :feature:notifications:imports:
:PROPERTIES:
:ID: 6f78f8d5-ecaa-4d8a-a666-ae4e27653191
:END:
*** Description
Currently importing board games sends out a ntfy message when a scrobble is
created.
We should do the same thing for other import types; namely: gpx, ebird, and scale.
** DONE [#A] Board game imports send duplicate ntfy message :bug:notifications:boardgames:
:PROPERTIES:
:ID: 8f067432-0399-4b79-9e93-727edcccedbd
:END:
*** Description
When a board game scrobble is created via a bgstats import, ntfy messages are
sent.
But right now they are duplicated (two are sent at the same time). Can we review
the code to see why this is happening and fix it?
** DONE [#A] Too many geolocation notifications go out :bug:notifications:geolocations:
:PROPERTIES:
:ID: 6357ad7a-fe4e-49dd-a063-55d87e459c17
:END:
*** Description
Currently ntfy gets overwhelemed when there's more than a hundred or so messages left in a queue on a client.
It would be nice if we could not spam ntfy, and this is especially true with Geolocations, where we really
don't need to alert folks unless they have a named Geolocation (has a title). Can we adjust the ntfy
sending for Geolocations to only send if the scrobbled location has a title?
** DONE [#C] Fix bug where Weigh-in imports do not set title :bug:tasks:scale:
:PROPERTIES:
:ID: 622e354a-8e66-4ecd-9e1c-a53f0a2ec362
:END:
@ -516,6 +644,7 @@ Currently when we import a scale CSV row and create data, the title is left
blank which makes it look funny in a list view. Let's save the weight as the
title of the Weigh-in task.
* Version 40.2 [1/1]
** DONE [#A] Try fixing deploy bugs again :tooling:releases:bug:
:PROPERTIES:

View File

@ -19,5 +19,4 @@ release kind="minor":
poetry run python scripts/release.py {{kind}}
push:
git push && git push gitea
git push --tags && git push --tags gitea

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "vrobbler"
version = "40.2"
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

@ -8,6 +8,7 @@ from django.contrib.auth import get_user_model
from birds.models import Bird, BirdSightingEntry, BirdSightingLogData, BirdingLocation
from scrobbles.models import Scrobble
from scrobbles.notifications import ScrobbleNtfyNotification
logger = logging.getLogger(__name__)
User = get_user_model()
@ -183,4 +184,6 @@ def import_birding_csv(file_path, user_id):
created = Scrobble.objects.bulk_create(new_scrobbles)
logger.info(f"Created {len(created)} birding scrobbles")
for scrobble in created:
ScrobbleNtfyNotification(scrobble).send()
return created

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

@ -83,43 +83,110 @@ class BaseLogData(JSONDataclass):
return ""
@staticmethod
def _format_timestamp(ts: str) -> str:
from datetime import datetime, timezone
import re
cleaned = ts.strip().strip("[]")
dt = None
if cleaned.isdigit():
try:
seconds = int(cleaned)
dt = datetime.fromtimestamp(seconds, tz=timezone.utc)
except (ValueError, OSError):
pass
if dt is None:
for fmt in [
"%Y-%m-%dT%H:%M:%S.%fZ",
"%Y-%m-%dT%H:%M:%SZ",
"%Y-%m-%dT%H:%M:%S.%f",
"%Y-%m-%dT%H:%M:%S",
]:
try:
dt = datetime.strptime(cleaned, fmt)
break
except ValueError:
continue
if dt is None:
m = re.match(
r"(\d{4}-\d{2}-\d{2})\s+\w{3}\s+(\d{2}:\d{2})", cleaned
)
if m:
try:
dt = datetime.strptime(
f"{m.group(1)} {m.group(2)}", "%Y-%m-%d %H:%M"
)
except ValueError:
pass
if dt is None:
try:
dt = datetime.fromisoformat(cleaned)
except ValueError:
pass
if dt:
return dt.strftime("%b %-d, %Y %-I:%M %p")
return ts
def notes_as_html(self) -> str:
import html
import bleach
import markdown
from django.utils.safestring import mark_safe
if not self.notes:
return ""
html_notes = []
md = markdown.Markdown(extensions=["extra"])
allowed_tags = [
"p", "br", "strong", "em", "a", "ul", "ol", "li",
"code", "pre", "blockquote", "h1", "h2", "h3", "h4", "h5", "h6",
"hr", "img",
]
note_items = []
if isinstance(self.notes, dict):
for ts, text in self.notes.items():
note_text = " ".join(text.strip().split())
html_notes.append(
f'<div class="sticky-note">{ts}: {note_text}</div>'
)
for ts, text in sorted(self.notes.items(), reverse=True):
note_items.append((ts, text.strip()))
elif isinstance(self.notes, str):
for line in self.notes.split("\n"):
if line.strip():
escaped_line = html.escape(line)
html_notes.append(f'<div class="sticky-note">{escaped_line}</div>')
note_items.append((None, line.strip()))
elif isinstance(self.notes, list):
for note in self.notes:
if isinstance(note, dict):
timestamp, note_text = next(iter(note.items()))
note_text = " ".join(note_text.strip().split())
html_notes.append(
f'<div class="sticky-note">{timestamp}: {note_text}</div>'
)
note_items.append((timestamp, note_text.strip()))
elif isinstance(note, str):
for line in note.split("\n"):
if line.strip():
escaped_line = html.escape(line)
html_notes.append(
f'<div class="sticky-note">{escaped_line}</div>'
)
note_items.append((None, line.strip()))
return mark_safe("".join(html_notes))
html_parts = []
for i, (ts, text) in enumerate(note_items):
if i > 0:
html_parts.append('<hr class="note-divider">')
ts_html = ""
if ts:
ts_html = f'<h5 class="note-timestamp">{self._format_timestamp(ts)}</h5>'
content_html = bleach.clean(
md.convert(text),
tags=allowed_tags,
strip=True,
)
html_parts.append(
f'<div class="note-item">{ts_html}<div class="note-content">{content_html}</div></div>'
)
return mark_safe("".join(html_parts))
@dataclass

View File

@ -5,6 +5,7 @@ from datetime import datetime, timedelta
from django.contrib.auth import get_user_model
from scrobbles.models import Scrobble
from scrobbles.notifications import ScrobbleNtfyNotification
from tasks.models import Task
logger = logging.getLogger(__name__)
@ -85,6 +86,11 @@ def import_scale_csv(file_path, user_id):
log_dict["unit_type"] = user.profile.weigh_in_units
weight_val = log_dict.get("weight")
if weight_val is not None:
unit = "kg" if user.profile.weigh_in_units == "metric" else "lbs"
log_dict["title"] = f"{weight_val} {unit}"
existing = Scrobble.objects.filter(
timestamp=start,
task=weigh_in,
@ -110,4 +116,6 @@ def import_scale_csv(file_path, user_id):
created = Scrobble.objects.bulk_create(new_scrobbles)
logger.info(f"Created {len(created)} weigh-in scrobbles")
for scrobble in created:
ScrobbleNtfyNotification(scrobble).send()
return created

View File

@ -12,6 +12,7 @@ from django.core.files import File
from locations.models import GeoLocation
from scrobbles.models import Scrobble
from scrobbles.notifications import ScrobbleNtfyNotification
from trails.models import Trail, TrailLogData
logger = logging.getLogger(__name__)
@ -328,4 +329,6 @@ def import_trail_gpx(file_path, user_id, original_filename=None):
created = Scrobble.objects.bulk_create(new_scrobbles)
logger.info(f"Created {len(created)} trail scrobbles")
for scrobble in created:
ScrobbleNtfyNotification(scrobble).send()
return created

View File

@ -1529,7 +1529,12 @@ class Scrobble(TimeStampedModel):
scrobble_data: dict,
) -> "Scrobble":
scrobble = cls.objects.create(**scrobble_data)
ScrobbleNtfyNotification(scrobble).send()
if not (
scrobble.media_type == cls.MediaType.GEO_LOCATION
and scrobble.media_obj
and not scrobble.media_obj.title
):
ScrobbleNtfyNotification(scrobble).send()
return scrobble
def stop(self, timestamp=None, force_finish=False) -> None:

View File

@ -562,7 +562,6 @@ def email_scrobble_board_game(
scrobble.played_to_completion = True
scrobble.save()
scrobbles_created.append(scrobble)
ScrobbleNtfyNotification(scrobble).send()
return scrobbles_created
@ -762,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

@ -76,33 +76,62 @@ class TaskLogData(BaseLogData):
return separator.join(lines).encode("utf-8").decode("unicode_escape")
def notes_as_html(self) -> str:
import bleach
import markdown
from django.utils.safestring import mark_safe
from scrobbles.dataclasses import BaseLogData
if not self.notes:
return ""
md = markdown.Markdown(extensions=["extra"])
allowed_tags = [
"p", "br", "strong", "em", "a", "ul", "ol", "li",
"code", "pre", "blockquote", "h1", "h2", "h3", "h4", "h5", "h6",
"hr", "img",
]
notes = self.notes
if isinstance(notes, dict):
notes = [{k: v} for k, v in notes.items()]
if isinstance(notes, str):
notes = [notes]
html_notes = []
note_items = []
for note in notes:
if isinstance(note, dict):
timestamp, note_text = next(iter(note.items()))
note_text = " ".join(note_text.strip().split())
html_notes.append(
f'<div class="sticky-note">{timestamp}: {note_text}</div>'
)
note_items.append((timestamp, note_text.strip()))
elif isinstance(note, str):
escaped = note.encode("utf-8").decode("unicode_escape")
for line in escaped.split("\n"):
if line.strip():
html_notes.append(f'<div class="sticky-note">{line}</div>')
note_items.append((None, line.strip()))
elif isinstance(note, list):
for item in note:
if isinstance(item, str):
html_notes.append(f'<div class="sticky-note">{item}</div>')
return "".join(html_notes)
note_items.append((None, item.strip()))
html_parts = []
for i, (ts, text) in enumerate(note_items):
if i > 0:
html_parts.append('<hr class="note-divider">')
ts_html = ""
if ts:
ts_html = f'<h5 class="note-timestamp">{BaseLogData._format_timestamp(ts)}</h5>'
content_html = bleach.clean(
md.convert(text),
tags=allowed_tags,
strip=True,
)
html_parts.append(
f'<div class="note-item">{ts_html}<div class="note-content">{content_html}</div></div>'
)
return mark_safe("".join(html_parts))
class Task(LongPlayScrobblableMixin):

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

@ -168,6 +168,35 @@
}
#scrobble-form { width: 100% }
.notes-list {
max-width: 600px;
}
.note-item {
padding: 4px 0;
}
.note-timestamp {
font-size: 0.8em;
color: #666;
margin: 0 0 4px;
}
.note-content {
font-size: 0.95em;
line-height: 1.5;
}
.note-content p:last-child {
margin-bottom: 0;
}
.note-divider {
margin: 8px 0;
border: 0;
border-top: 1px solid #ddd;
}
.sticky-notes-container {
display: flex;
flex-wrap: wrap;
@ -218,6 +247,9 @@
a:not(.nav-link):not(.btn):not(.page-link):hover {
color: var(--accent);
}
a.badge {
color: #fff !important;
}
.table-striped > tbody > tr:nth-of-type(odd) {
background-color: color-mix(in srgb, var(--accent) 6%, #fff);
}

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 %}
@ -115,8 +122,8 @@
{% with notes_html=object.logdata.notes_as_html %}
{% if notes_html %}
<div class="mb-3">
<h5>Notes</h5>
<div class="sticky-notes-container">
<h4>Notes</h4>
<div class="notes-list">
{{ notes_html|safe }}
</div>
</div>

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 %}