Compare commits

...

24 Commits
39.1 ... 41.0

Author SHA1 Message Date
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
f9ba6fec14 [release] Bump to version 40.2
Some checks failed
build / test (push) Successful in 2m0s
deploy / test (push) Successful in 1m59s
deploy / build-and-deploy (push) Failing after 25s
- Try fixing deploy bugs again
2026-06-03 16:32:09 -04:00
5f55163147 [tooling] Try fixing deploys one more time 2026-06-03 16:31:09 -04:00
a6ef34623e [release] Bump to version 40.1
Some checks failed
deploy / test (push) Successful in 2m5s
build / test (push) Successful in 2m6s
deploy / build-and-deploy (push) Failing after 28s
- Releases are still broken
- Fix bug on chart pages where trail titles missing
2026-06-03 16:13:54 -04:00
7cb48d20f6 [tooling] Try to fix deploy issues
Some checks failed
build / test (push) Has been cancelled
2026-06-03 16:13:16 -04:00
445103a878 [trails] Fix display of trails in charts
All checks were successful
build / test (push) Successful in 2m7s
2026-06-03 15:47:56 -04:00
579da8c44e [release] Bump to version 40.0
Some checks failed
build / test (push) Successful in 1m59s
deploy / test (push) Successful in 2m3s
deploy / build-and-deploy (push) Failing after 19s
- Fix error in org-mode task sync
- Adjust how similar artists are shown
2026-06-01 11:12:31 -04:00
daabd2f37f [tasks] Fix bug in syncing orgmode tasks without notes
All checks were successful
build / test (push) Successful in 1m50s
2026-06-01 11:02:56 -04:00
039c58cf89 [tooling] Try fixing deploys again
All checks were successful
build / test (push) Successful in 1m53s
2026-06-01 10:50:38 -04:00
410c033f12 [music] Fix slow loading artist page 2026-06-01 10:50:22 -04:00
ce302e4d45 [release] Bump to version 39.3
Some checks failed
build / test (push) Successful in 1m53s
deploy / test (push) Successful in 1m55s
deploy / build-and-deploy (push) Failing after 19s
- Issue found when doing a release
- Fix deploy actions running twice
2026-06-01 10:35:51 -04:00
19589c9463 [tooling] Fix failing deploys 2026-06-01 10:35:10 -04:00
3d9506b14e [tooling] Actulaly fix the release problems
All checks were successful
build / test (push) Successful in 2m1s
Turns out we need build and deploy in separate files to trigger at
different times. Now we build on all pushes, but only deploy on tag pushes.
2026-06-01 10:21:56 -04:00
23b87278b2 [tooling] Stop running release task twice 2026-06-01 10:12:00 -04:00
0b8e027c30 [release] Bump to version 39.2
Some checks failed
build & deploy / test (push) Successful in 1m59s
build & deploy / build-and-deploy (push) Failing after 24s
- Releases do not pin commit to the repo for display
- Fix the way timestamps are stored for notes on tasks
2026-06-01 10:11:02 -04:00
1bd9f0d942 [tooling] Fix commit stamping on release
All checks were successful
build & deploy / test (push) Successful in 1m56s
build & deploy / build-and-deploy (push) Has been skipped
2026-06-01 09:58:59 -04:00
fa7890cb21 [tasks] Fix timestamps for notes and syncing from org-mode (32973bb3)
All checks were successful
build & deploy / test (push) Successful in 1m54s
build & deploy / build-and-deploy (push) Has been skipped
2026-06-01 09:37:41 -04:00
22 changed files with 558 additions and 78 deletions

View File

@ -0,0 +1,68 @@
name: build
on:
push:
branches: ["**"]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
env:
VROBBLER_DATABASE_URL: sqlite:///test.db
VROBBLER_USDA_API_KEY: ${{ vars.VROBBLER_USDA_API_KEY }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Cache pip/poetry
uses: actions/cache@v4
with:
path: |
~/.cache/pip
~/.cache/pypoetry
key: ${{ runner.os }}-py311-${{ hashFiles('**/poetry.lock') }}
restore-keys: |
${{ runner.os }}-py311-
- name: Install Poetry
run: |
python -m pip install --upgrade pip
pip install poetry
- name: Install deps
run: |
cp vrobbler.conf.test vrobbler.conf
poetry install --with test
- name: Pytest with coverage
run: |
poetry run pytest -n 5 --cov-report term:skip-covered --cov=vrobbler tests
- name: Notify success (ntfy)
if: success()
run: |
curl -fsS \
-H "Title: vrobbler CI success" \
-H "Priority: low" \
-H "Tags: success,vrobbler" \
-H "Actions: view, Changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, Build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
-d "✅ Build succeeded: ${{ gitea.repository }} @ ${{ gitea.sha }}" \
https://ntfy.unbl.ink/drone
- name: Notify failure (ntfy)
if: failure()
run: |
curl -fsS \
-H "Title: vrobbler CI failure" \
-H "Priority: high" \
-H "Tags: failure,vrobbler" \
-H "Actions: view, Changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, Build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
-d "❌ Build failed: ${{ gitea.repository }} @ ${{ gitea.sha }}" \
https://ntfy.unbl.ink/drone

View File

@ -1,10 +1,8 @@
name: build & deploy
name: deploy
on:
push:
branches: ["**"]
tags: ["*"]
pull_request:
jobs:
test:
@ -22,7 +20,6 @@ jobs:
with:
python-version: "3.11"
# Cache pip + Poetry caches (rough equivalent to your mounted pip_cache)
- name: Cache pip/poetry
uses: actions/cache@v4
with:
@ -47,7 +44,6 @@ jobs:
run: |
poetry run pytest -n 5 --cov-report term:skip-covered --cov=vrobbler tests
# Notifications (success/failure) for the test job
- name: Notify success (ntfy)
if: success()
run: |
@ -71,8 +67,6 @@ jobs:
https://ntfy.unbl.ink/drone
build-and-deploy:
# Only deploy on tags (equivalent to Drone when: ref: refs/tags/*)
if: startsWith(gitea.ref, 'refs/tags/')
needs: [test]
runs-on: ubuntu-latest
@ -97,12 +91,19 @@ jobs:
- name: Build package with commit info
run: |
# Write commit to _commit.py before build
echo "commit = '$(echo ${{ gitea.sha }} | cut -c1-8)'" > vrobbler/_commit.py
poetry build
# Restore original _commit.py
git checkout vrobbler/_commit.py
- name: Clean old wheels from server
uses: appleboy/ssh-action@v1.0.3
with:
host: vrobbler.service
username: root
key: ${{ secrets.JAIL_KEY }}
script: |
rm -f /var/lib/vrobbler/dist/*.whl
- name: Copy wheel to server and deploy
uses: appleboy/scp-action@v1.0.0
with:
@ -125,8 +126,8 @@ jobs:
echo "${{ gitea.sha }}" | cut -c1-8 > /var/lib/vrobbler/commit.txt
pip uninstall -y vrobbler
pip install /var/lib/vrobbler/dist/*.whl
python -c "import vrobbler; print(f'vrobbler {vrobbler.__version__} installed OK')"
rm -f /var/lib/vrobbler/dist/*.whl
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/14] :vrobbler:project:personal:
** TODO [#C] Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment:
:PROPERTIES:
:ID: 37781d6a-f3b0-48b2-bf98-33c2c791cf85
@ -428,7 +428,7 @@ Pretty clear, I would love to make trails more useful. Historically I wasn't
hiking a lot, which made the source for this a bit silly. But it's clear that
AllTrails is the best source, though having TrailForks is nice to.
** TODO [#B] Add `garmin_activity_id` to the TrailMetadataLog class :vrobbler:trails:feature:personal:project:
** TODO [#B] Add `garmin_activity_id` to the TrailLogData class :trails:feature:personal:project:
:PROPERTIES:
:ID: 5a4fb0f8-0555-40ec-b06f-93c26bd686f4
:END:
@ -437,12 +437,21 @@ AllTrails is the best source, though having TrailForks is nice to.
Would be nice to have some loose connection to the actual event in my Garmin
profile.
** TODO [#B] Explore a way to add metadata editing to scrobbles after saving :vrobbler:spike:scrobbling:personal:project:
** TODO [#B] Fix how we show notes and descriptions from scrobbles to users :metadata:notes:tasks:
:PROPERTIES:
:ID: adf4c513-a417-4ec9-8831-f01ffcf63276
:END:
*** Description
Currently the display of notes leaves something to be desired. The biggest issue
is that they don't look good on mobile and are probably trying to be too cute.
Rather than post-it note style, we should just put notes in a list under the
description, above the Edit Log toggle, with timestamps for when they were
added.
They should also probably support markdown formatting and that should be
displayed in the template.
Could be as simple as a JSON form on the scrobble detail page (do I have have
one of those yet?).
** TODO [#B] Explore a good way to show notes and descriptions from scrobbles to users :personal:project:scrobbling:vrobbler:spike:
** TODO [#B] Add webdav syncing to retroarch imports :vrobbler:videogames:webdav:feature:project:personal:
** TODO [#B] Add CSV endpoint for book scrobbles that LibraryThing can ingest :personal:project:books:feature:export:
https://app.todoist.com/app/task/add-a-csv-endpoint-for-users-book-reads-that-library-thing-can-ingest-6X7QPMRp265xMXqg#comment-6X7QrXq6gJjMP4hg
** TODO [#B] Scrape ComicBookRoundUp ratings for comic book metadata :vrobbler:books:feature:comicbook:personal:project:
@ -480,6 +489,213 @@ 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:
:PROPERTIES:
:ID: 7dcebb2c-7c4c-4ac5-bee6-c2e36c3811f9
:END:
*** Description
Currently if you POST to the orgmode webhook with a task that's already in progress, the request just stops there.
We should add logic where if the task is in-progress, instead of doing nothing,
it checks the webhook payload against the in-progress tasks and updates the
description of the scrobble.log with the incoming task description if it's
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
* 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:
*** Description
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:
:ID: 15894943-be1d-200f-8400-a136770ad9d2
:END:
* Version 40.1 [2/2]
** DONE [#A] Releases are still broken :bug:releases:tooling:
:PROPERTIES:
:ID: bca37a18-afa2-4ddd-a11b-ef0555f38bc9
:END:
*** Description
Deploys are still broken, even with them being pulled apart and run separately.
We need to address the way the commit ends up stashed in the codebase.
** DONE [#C] Fix bug on chart pages where trail titles missing :bug:trails:charts:
:PROPERTIES:
:ID: 21075430-8a93-4e59-9a02-479315960ae6
:END:
*** Description
When trails are rendered on the chart views, there are no titles, which means we don't see
anything except the ranking number.
* Version 40.0 [2/2]
** DONE [#A] Fix error in org-mode task sync :emacs:orgmode:tasks:bug:
:PROPERTIES:
:ID: 03256d2a-48aa-4be7-aeb3-fa1cfddc86bf
:END:
*** Description
#+begin_src python
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/tasks/webhooks.py", line 236, in post
emacs_scrobble_update_task(
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/scrobbles/scrobblers.py", line 844, in emacs_scrobble_update_task
for note in emacs_notes:
TypeError: 'NoneType' object is not iterable
#+end_src
** DONE [#B] Adjust how similar artists are shown :feature:templates:artists:music:
:PROPERTIES:
:ID: 2a081620-a0a2-4851-a7cf-4043f9c2ee31
:END:
*** Description
Currently we show the top 10 similar artists on the Artist detail page linked to
the artist detail page on Vrobbler.
First off, this is very slow. We should look into speeding up the rendering of
the similar artists widget.
Second, the artist name in the similar artist list should be a link to the
Vrobbler artist detail page, but there should also be a [musicbrainz] link next
to it, that links out to the musicbrainz page whether we have the artist in the
Vrobbler database or not.
* Version 39.3 [2/2]
** DONE [#A] Issue found when doing a release :bug:tooling:release:cicd:
:PROPERTIES:
:ID: a8fc3ec9-74ec-4190-8ac2-62cd8a33e828
:END:
*** Description
#+begin_src sh
err: ERROR: Cannot install vrobbler 0.16.1 (from /var/lib/vrobbler/dist/vrobbler-0.16.1-py3-none-any.whl) and vrobbler 38.0 (from /var/lib/vrobbler/dist/vrobbler-38.0-py3-none-any.whl) because these package versions have conflicting dependencies.
out: The conflict is caused by:
out: The user requested vrobbler 0.16.1 (from /var/lib/vrobbler/dist/vrobbler-0.16.1-py3-none-any.whl)
out: The user requested vrobbler 38.0 (from /var/lib/vrobbler/dist/vrobbler-38.0-py3-none-any.whl)
out: To fix this you could try to:
out: 1. loosen the range of package versions you've specified
out: 2. remove package versions to allow pip attempt to solve the dependency conflict
err: ERROR: ResolutionImpossible: for help visit https://pip.pypa.io/en/latest/topics/dependency-resolution/#dealing-with-dependency-conflicts
2026/06/01 14:15:00 Process exited with status 1
failed to remove container: Error response from daemon: removal of container 3fe0eaf032c5518aca4ab71734b52bda7c54ed406136b82136ab7155bf5ff3c1 is already in progress
#+end_src
** DONE [#A] Fix deploy actions running twice :bug:tooling:cicd:
:PROPERTIES:
:ID: aa56f2a6-2b61-4ddf-9e27-9eadcddf8412
:END:
*** Description
Turns out we're now running the build-deploy action twice, once on branch push
and once on tag push.
We should do builds on each push and build and deploys only when a new tag is
detected.
* Version 39.2 [2/2]
** DONE [#B] Releases do not pin commit to the repo for display :bug:tooling:releases:
:PROPERTIES:
:ID: 2a9f2ff5-2642-47ab-ba1d-e41825411713
:END:
*** Description
Somewhere in implementing the justfile release flow, we lost the capture of the
latest commit in the relesae flow so the footer now always says: vXX.x (unknown)
It should have the first bit of the commit in the parens at the end.
** DONE [#B] Fix the way timestamps are stored for notes on tasks :bug:scrobbles:tasks:
:PROPERTIES:
:ID: 32973bb3-079b-8cdf-6495-82f8ae907299
:END:
*** Description
:PROPERTIES:
:ID: d833a3df-6eb2-36bb-1863-e438e5d36151
:END:
Turns out Todoist uses a human-readable timestamp format for comments. We should
adapt that for use from org-mode as well.
Format should be: "%Y-%m-%dT%H:%M:%S.%fZ"
* Version 39.1 [1/1]
** DONE [#A] Fix bug in tests for notes saving :bug:scrobbles:forms:
: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 = "39.1"
version = "41.0"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]

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

@ -19,11 +19,7 @@ class ArtistListView(generic.ListView):
paginate_by = 100
def get_queryset(self):
qs = (
super()
.get_queryset()
.annotate(scrobble_count=Count("track__scrobble"))
)
qs = super().get_queryset().annotate(scrobble_count=Count("track__scrobble"))
genre = self.request.GET.get("genre")
if genre:
qs = qs.filter(theaudiodb_genre=genre)
@ -82,26 +78,30 @@ class ArtistDetailView(generic.DetailView):
similar = artist.similar_artists or []
if similar:
mbids = [sa["artist_mbid"] for sa in similar if sa.get("artist_mbid")]
top = similar[:10]
mbids = [sa["artist_mbid"] for sa in top if sa.get("artist_mbid")]
local_artists = {
a.musicbrainz_id: a
for a in Artist.objects.filter(musicbrainz_id__in=mbids)
}
for sa in similar:
for sa in top:
local = local_artists.get(sa.get("artist_mbid"))
sa["local_url"] = local.get_absolute_url() if local else None
context_data["similar_artists"] = similar
sa["musicbrainz_url"] = (
f"https://musicbrainz.org/artist/{sa['artist_mbid']}"
if sa.get("artist_mbid")
else None
)
context_data["similar_artists"] = top
if artist.theaudiodb_genre:
context_data["genre_count"] = (
Artist.objects.filter(theaudiodb_genre=artist.theaudiodb_genre)
.count()
)
context_data["genre_count"] = Artist.objects.filter(
theaudiodb_genre=artist.theaudiodb_genre
).count()
if artist.theaudiodb_mood:
context_data["mood_count"] = (
Artist.objects.filter(theaudiodb_mood=artist.theaudiodb_mood)
.count()
)
context_data["mood_count"] = Artist.objects.filter(
theaudiodb_mood=artist.theaudiodb_mood
).count()
return context_data

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

@ -145,8 +145,8 @@ class NotesDictField(forms.Field):
if isinstance(value, str):
if value.strip():
from datetime import datetime
return {datetime.now().isoformat(): value.strip()}
from scrobbles.utils import make_note_timestamp
return {make_note_timestamp(): value.strip()}
return {}
if isinstance(value, dict):

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

@ -35,6 +35,7 @@ from scrobbles.notifications import ScrobbleNtfyNotification
from scrobbles.utils import (
convert_to_seconds,
extract_domain,
make_note_timestamp,
next_url_if_exists,
remove_last_part,
)
@ -528,7 +529,7 @@ def email_scrobble_board_game(
stop_timestamp = timestamp + timedelta(seconds=duration_seconds)
if comments:
log_data["notes"] = {str(int(stop_timestamp.timestamp())): comments}
log_data["notes"] = {make_note_timestamp(stop_timestamp): comments}
logger.info(f"Creating scrobble for {base_game} at {timestamp}")
log_data["raw_data"] = bgstat_data
@ -561,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
@ -688,7 +688,7 @@ def todoist_scrobble_update_task(
)
return
timestamp = todoist_note.get("posted_at") or str(int(timezone.now().timestamp()))
timestamp = todoist_note.get("posted_at") or make_note_timestamp()
if not scrobble.log.get("notes"):
scrobble.log["notes"] = {}
scrobble.log["notes"][timestamp] = todoist_note.get("notes")
@ -786,12 +786,37 @@ def todoist_scrobble_task(
return scrobble
ORG_HEADING_RE = re.compile(r"^(\*+\s+.*)$", re.MULTILINE)
NOTE_CONTENT_SPLIT = re.compile(r"^\*{3,}\s", re.MULTILINE)
def _truncate_at_org_header(text: str) -> str:
"""Truncate text at the first org-mode heading (*** or more)."""
parts = NOTE_CONTENT_SPLIT.split(text, maxsplit=1)
return parts[0].strip()
def _extract_org_section(body: str | None, heading: str) -> str | None:
"""Extract content under a specific org-mode sub-heading (e.g. '*** Description')."""
if not body:
return None
sections = ORG_HEADING_RE.split(body)
# sections alternates: [prefix, heading1, content1, heading2, content2, ...]
for i, section in enumerate(sections):
if section.strip().startswith(heading):
if i + 1 < len(sections):
return sections[i + 1].strip()
return ""
return None
def emacs_scrobble_update_task(
emacs_id: str,
emacs_notes: list,
user_id: int,
description: Optional[str] = None,
) -> Optional[Scrobble]:
description = _extract_org_section(description, "*** Description")
scrobble = Scrobble.objects.filter(
in_progress=True,
user_id=user_id,
@ -818,6 +843,11 @@ def emacs_scrobble_update_task(
for note in emacs_notes:
timestamp = note.get("timestamp")
content = note.get("content")
if not content:
continue
content = _truncate_at_org_header(content)
if not content:
continue
if timestamp:
existing = scrobble.log["notes"].get(timestamp)
if existing != content:
@ -896,7 +926,7 @@ def emacs_scrobble_task(
task_data.pop("notes", None)
task_data["title"] = task_data.pop("description")
task_data["description"] = task_data.pop("body")
task_data["description"] = _extract_org_section(task_data.pop("body"), "*** Description")
task_data["labels"] = task_data.pop("labels")
task_data["orgmode_id"] = task_data.pop("source_id")

View File

@ -32,6 +32,16 @@ from webdav.client import get_webdav_client
if TYPE_CHECKING:
from scrobbles.models import Scrobble
NOTE_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
def make_note_timestamp(dt: datetime | None = None) -> str:
if dt is None:
dt = timezone.now()
return dt.strftime(NOTE_TIMESTAMP_FORMAT)
logger = logging.getLogger(__name__)
User = get_user_model()

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

@ -64,6 +64,8 @@ def convert_old_todoist_log_to_new(commit=False):
def convert_notes_to_dict(commit=False):
from scrobbles.utils import make_note_timestamp
scrobbles = Scrobble.objects.filter(log__notes__isnull=False)
count = 0
for scrobble in scrobbles:
@ -71,7 +73,7 @@ def convert_notes_to_dict(commit=False):
print(f"Converting {scrobble} string note to dict")
if scrobble.log.get("notes") == "":
scrobble.log.pop("notes")
key = str(int(scrobble.timestamp.timestamp()))
key = make_note_timestamp(scrobble.timestamp)
notes = scrobble.log.pop("notes")
scrobble.log = {}
scrobble.log["notes"] = {key: notes}
@ -92,12 +94,14 @@ def convert_notes_to_dict(commit=False):
def convert_tasks_notes_list_to_dict(commit=False):
from scrobbles.utils import make_note_timestamp
scrobbles = Scrobble.objects.filter(task__isnull=False, log__notes__isnull=False)
count = 0
for scrobble in scrobbles:
notes = scrobble.log.get("notes")
if isinstance(notes, list):
key = str(int(scrobble.timestamp.timestamp()) + 10)
key = make_note_timestamp(scrobble.timestamp + timedelta(seconds=10))
parts = []
for note in notes:
if isinstance(note, dict):
@ -114,6 +118,8 @@ def convert_tasks_notes_list_to_dict(commit=False):
def convert_old_boardgame_log_to_new(commit=False):
from scrobbles.utils import make_note_timestamp
scrobbles = Scrobble.objects.filter(board_game__isnull=False, log__has_key="notes")
count = 0
for scrobble in scrobbles:
@ -123,7 +129,7 @@ def convert_old_boardgame_log_to_new(commit=False):
notes = [notes]
if isinstance(notes, list):
key_ts = scrobble.stop_timestamp or scrobble.timestamp
scrobble.log["notes"] = {str(int(key_ts.timestamp())): "\n".join(
scrobble.log["notes"] = {make_note_timestamp(key_ts): "\n".join(
str(n) for n in notes
)}
count += 1

View File

@ -235,7 +235,7 @@ class EmacsWebhookView(APIView):
if task_in_progress:
emacs_scrobble_update_task(
post_data.get("source_id"),
post_data.get("notes", []),
post_data.get("notes") or [],
user_id,
description=post_data.get("body"),
)

View File

@ -14,11 +14,12 @@ def version_info(request):
if not commit:
# Try to import from _commit.py module first
try:
from vrobbler._commit import commit
from vrobbler._commit import commit as _commit
except ImportError:
pass
else:
return {"app_version": app_version, "git_commit": commit}
if _commit and _commit != "unknown":
return {"app_version": app_version, "git_commit": _commit}
# Try to read from commit file (written during deploy)
commit_file = Path("/var/lib/vrobbler/commit.txt")

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

@ -258,7 +258,7 @@
{% for chart in charts.trail|slice:":20" %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
<a href="{{chart.trail.get_absolute_url}}">{{chart.trail.name}}</a>
<a href="{{chart.trail.get_absolute_url}}">{{chart.trail.title}}</a>
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
</li>
{% endfor %}

View File

@ -53,13 +53,15 @@
<tr>
<th scope="col">Artist</th>
<th scope="col">Score</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{% for sa in similar_artists|slice:":10" %}
{% for sa in similar_artists %}
<tr>
<td>{% if sa.local_url %}<a href="{{sa.local_url}}">{{sa.name}}</a>{% else %}{{sa.name}}{% endif %}</td>
<td>{{sa.score}}</td>
<td>{% if sa.musicbrainz_url %}<a href="{{sa.musicbrainz_url}}">musicbrainz</a>{% endif %}</td>
</tr>
{% endfor %}
</tbody>

View File

@ -115,8 +115,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>