Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a70343d6f3 | |||
| 3e72042c24 | |||
| 087c7775ae | |||
| 3f71065ad6 | |||
| 801672124f | |||
| 811e9c1ce9 | |||
| 415b32bdc7 | |||
| 22319c807a | |||
| f9ba6fec14 | |||
| 5f55163147 | |||
| a6ef34623e | |||
| 7cb48d20f6 | |||
| 445103a878 | |||
| 579da8c44e | |||
| daabd2f37f | |||
| 039c58cf89 | |||
| 410c033f12 | |||
| ce302e4d45 | |||
| 19589c9463 | |||
| 3d9506b14e | |||
| 23b87278b2 | |||
| 0b8e027c30 | |||
| 1bd9f0d942 | |||
| fa7890cb21 |
68
.gitea/workflows/build.yml
Normal file
68
.gitea/workflows/build.yml
Normal 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
|
||||
@ -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
|
||||
230
PROJECT.org
230
PROJECT.org
@ -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:
|
||||
|
||||
1
justfile
1
justfile
@ -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
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "39.1"
|
||||
version = "41.0"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"),
|
||||
)
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user