Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f9ba6fec14 | |||
| 5f55163147 | |||
| a6ef34623e | |||
| 7cb48d20f6 | |||
| 445103a878 | |||
| 579da8c44e | |||
| daabd2f37f | |||
| 039c58cf89 | |||
| 410c033f12 | |||
| ce302e4d45 | |||
| 19589c9463 | |||
| 3d9506b14e | |||
| 23b87278b2 | |||
| 0b8e027c30 | |||
| 1bd9f0d942 | |||
| fa7890cb21 | |||
| 957c32e3a7 | |||
| 8d069df9d1 |
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:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ["**"]
|
|
||||||
tags: ["*"]
|
tags: ["*"]
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
@ -22,7 +20,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
|
|
||||||
# Cache pip + Poetry caches (rough equivalent to your mounted pip_cache)
|
|
||||||
- name: Cache pip/poetry
|
- name: Cache pip/poetry
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
@ -47,7 +44,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
poetry run pytest -n 5 --cov-report term:skip-covered --cov=vrobbler tests
|
poetry run pytest -n 5 --cov-report term:skip-covered --cov=vrobbler tests
|
||||||
|
|
||||||
# Notifications (success/failure) for the test job
|
|
||||||
- name: Notify success (ntfy)
|
- name: Notify success (ntfy)
|
||||||
if: success()
|
if: success()
|
||||||
run: |
|
run: |
|
||||||
@ -71,8 +67,6 @@ jobs:
|
|||||||
https://ntfy.unbl.ink/drone
|
https://ntfy.unbl.ink/drone
|
||||||
|
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
# Only deploy on tags (equivalent to Drone when: ref: refs/tags/*)
|
|
||||||
if: startsWith(gitea.ref, 'refs/tags/')
|
|
||||||
needs: [test]
|
needs: [test]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
@ -97,12 +91,19 @@ jobs:
|
|||||||
|
|
||||||
- name: Build package with commit info
|
- name: Build package with commit info
|
||||||
run: |
|
run: |
|
||||||
# Write commit to _commit.py before build
|
|
||||||
echo "commit = '$(echo ${{ gitea.sha }} | cut -c1-8)'" > vrobbler/_commit.py
|
echo "commit = '$(echo ${{ gitea.sha }} | cut -c1-8)'" > vrobbler/_commit.py
|
||||||
poetry build
|
poetry build
|
||||||
# Restore original _commit.py
|
|
||||||
git checkout vrobbler/_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
|
- name: Copy wheel to server and deploy
|
||||||
uses: appleboy/scp-action@v1.0.0
|
uses: appleboy/scp-action@v1.0.0
|
||||||
with:
|
with:
|
||||||
@ -125,8 +126,8 @@ jobs:
|
|||||||
echo "${{ gitea.sha }}" | cut -c1-8 > /var/lib/vrobbler/commit.txt
|
echo "${{ gitea.sha }}" | cut -c1-8 > /var/lib/vrobbler/commit.txt
|
||||||
pip uninstall -y vrobbler
|
pip uninstall -y vrobbler
|
||||||
pip install /var/lib/vrobbler/dist/*.whl
|
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
|
rm -f /var/lib/vrobbler/dist/*.whl
|
||||||
|
python -c "import vrobbler; print(f'vrobbler {vrobbler.__version__} installed OK')"
|
||||||
vrobbler migrate
|
vrobbler migrate
|
||||||
vrobbler collectstatic --noinput
|
vrobbler collectstatic --noinput
|
||||||
immortalctl restart vrobbler-celery && immortalctl restart vrobbler-celerybeat && immortalctl restart vrobbler
|
immortalctl restart vrobbler-celery && immortalctl restart vrobbler-celerybeat && immortalctl restart vrobbler
|
||||||
180
PROJECT.org
180
PROJECT.org
@ -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
|
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.
|
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:
|
:PROPERTIES:
|
||||||
:ID: 5a4fb0f8-0555-40ec-b06f-93c26bd686f4
|
:ID: 5a4fb0f8-0555-40ec-b06f-93c26bd686f4
|
||||||
:END:
|
: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
|
Would be nice to have some loose connection to the actual event in my Garmin
|
||||||
profile.
|
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:
|
** 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
|
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:
|
** TODO [#B] Scrape ComicBookRoundUp ratings for comic book metadata :vrobbler:books:feature:comicbook:personal:project:
|
||||||
@ -480,6 +489,165 @@ 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
|
profile's historic timezone, how many hours to adjust the KoReader time to get
|
||||||
to GMT to save it in the database.
|
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
|
||||||
|
|
||||||
|
** TODO [#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:
|
||||||
|
:ID: 68a011b2-bb6f-3ba8-2312-5947c41db9ac
|
||||||
|
:END:
|
||||||
|
|
||||||
* Version 39.0 [3/3]
|
* Version 39.0 [3/3]
|
||||||
** DONE [#B] Clean up org-mode tasks metadata :bug:tasks:metadata:
|
** DONE [#B] Clean up org-mode tasks metadata :bug:tasks:metadata:
|
||||||
:PROPERTIES:
|
:PROPERTIES:
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "vrobbler"
|
name = "vrobbler"
|
||||||
version = "39.0"
|
version = "40.2"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||||
|
|
||||||
|
|||||||
@ -607,7 +607,8 @@ def test_scrobble_detail_view_post_updates_log(client):
|
|||||||
|
|
||||||
scrobble.refresh_from_db()
|
scrobble.refresh_from_db()
|
||||||
assert scrobble.log["description"] == "Updated description"
|
assert scrobble.log["description"] == "Updated description"
|
||||||
assert scrobble.log["notes"] == ["Updated note"]
|
assert isinstance(scrobble.log["notes"], dict)
|
||||||
|
assert list(scrobble.log["notes"].values()) == ["Updated note"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skip("Need to refactor")
|
@pytest.mark.skip("Need to refactor")
|
||||||
|
|||||||
@ -19,11 +19,7 @@ class ArtistListView(generic.ListView):
|
|||||||
paginate_by = 100
|
paginate_by = 100
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = (
|
qs = super().get_queryset().annotate(scrobble_count=Count("track__scrobble"))
|
||||||
super()
|
|
||||||
.get_queryset()
|
|
||||||
.annotate(scrobble_count=Count("track__scrobble"))
|
|
||||||
)
|
|
||||||
genre = self.request.GET.get("genre")
|
genre = self.request.GET.get("genre")
|
||||||
if genre:
|
if genre:
|
||||||
qs = qs.filter(theaudiodb_genre=genre)
|
qs = qs.filter(theaudiodb_genre=genre)
|
||||||
@ -82,26 +78,30 @@ class ArtistDetailView(generic.DetailView):
|
|||||||
|
|
||||||
similar = artist.similar_artists or []
|
similar = artist.similar_artists or []
|
||||||
if similar:
|
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 = {
|
local_artists = {
|
||||||
a.musicbrainz_id: a
|
a.musicbrainz_id: a
|
||||||
for a in Artist.objects.filter(musicbrainz_id__in=mbids)
|
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"))
|
local = local_artists.get(sa.get("artist_mbid"))
|
||||||
sa["local_url"] = local.get_absolute_url() if local else None
|
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:
|
if artist.theaudiodb_genre:
|
||||||
context_data["genre_count"] = (
|
context_data["genre_count"] = Artist.objects.filter(
|
||||||
Artist.objects.filter(theaudiodb_genre=artist.theaudiodb_genre)
|
theaudiodb_genre=artist.theaudiodb_genre
|
||||||
.count()
|
).count()
|
||||||
)
|
|
||||||
if artist.theaudiodb_mood:
|
if artist.theaudiodb_mood:
|
||||||
context_data["mood_count"] = (
|
context_data["mood_count"] = Artist.objects.filter(
|
||||||
Artist.objects.filter(theaudiodb_mood=artist.theaudiodb_mood)
|
theaudiodb_mood=artist.theaudiodb_mood
|
||||||
.count()
|
).count()
|
||||||
)
|
|
||||||
|
|
||||||
return context_data
|
return context_data
|
||||||
|
|
||||||
|
|||||||
@ -112,11 +112,14 @@ class NotesDictWidget(forms.Widget):
|
|||||||
|
|
||||||
def value_from_datadict(self, data, files, name):
|
def value_from_datadict(self, data, files, name):
|
||||||
timestamps = data.getlist(f"{name}_timestamps")
|
timestamps = data.getlist(f"{name}_timestamps")
|
||||||
contents = data.getlist(f"{name}_contents")
|
if timestamps:
|
||||||
return {
|
contents = data.getlist(f"{name}_contents")
|
||||||
"timestamps": timestamps,
|
result = {}
|
||||||
"contents": contents,
|
for i, ts in enumerate(timestamps):
|
||||||
}
|
if i < len(contents) and ts:
|
||||||
|
result[ts] = contents[i]
|
||||||
|
return result if result else ""
|
||||||
|
return data.get(name, "")
|
||||||
|
|
||||||
def get_context(self, name, value, attrs):
|
def get_context(self, name, value, attrs):
|
||||||
context = super().get_context(name, value, attrs)
|
context = super().get_context(name, value, attrs)
|
||||||
@ -139,10 +142,14 @@ class NotesDictField(forms.Field):
|
|||||||
def clean(self, value):
|
def clean(self, value):
|
||||||
if not value:
|
if not value:
|
||||||
return {}
|
return {}
|
||||||
result = {}
|
|
||||||
timestamps = value.get("timestamps", [])
|
if isinstance(value, str):
|
||||||
contents = value.get("contents", [])
|
if value.strip():
|
||||||
for i, ts in enumerate(timestamps):
|
from scrobbles.utils import make_note_timestamp
|
||||||
if i < len(contents) and ts and contents[i].strip():
|
return {make_note_timestamp(): value.strip()}
|
||||||
result[ts] = contents[i].strip()
|
return {}
|
||||||
return result if result else {}
|
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return value
|
||||||
|
|
||||||
|
return {}
|
||||||
|
|||||||
@ -35,6 +35,7 @@ from scrobbles.notifications import ScrobbleNtfyNotification
|
|||||||
from scrobbles.utils import (
|
from scrobbles.utils import (
|
||||||
convert_to_seconds,
|
convert_to_seconds,
|
||||||
extract_domain,
|
extract_domain,
|
||||||
|
make_note_timestamp,
|
||||||
next_url_if_exists,
|
next_url_if_exists,
|
||||||
remove_last_part,
|
remove_last_part,
|
||||||
)
|
)
|
||||||
@ -528,7 +529,7 @@ def email_scrobble_board_game(
|
|||||||
stop_timestamp = timestamp + timedelta(seconds=duration_seconds)
|
stop_timestamp = timestamp + timedelta(seconds=duration_seconds)
|
||||||
|
|
||||||
if comments:
|
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}")
|
logger.info(f"Creating scrobble for {base_game} at {timestamp}")
|
||||||
log_data["raw_data"] = bgstat_data
|
log_data["raw_data"] = bgstat_data
|
||||||
@ -688,7 +689,7 @@ def todoist_scrobble_update_task(
|
|||||||
)
|
)
|
||||||
return
|
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"):
|
if not scrobble.log.get("notes"):
|
||||||
scrobble.log["notes"] = {}
|
scrobble.log["notes"] = {}
|
||||||
scrobble.log["notes"][timestamp] = todoist_note.get("notes")
|
scrobble.log["notes"][timestamp] = todoist_note.get("notes")
|
||||||
@ -786,12 +787,37 @@ def todoist_scrobble_task(
|
|||||||
return scrobble
|
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(
|
def emacs_scrobble_update_task(
|
||||||
emacs_id: str,
|
emacs_id: str,
|
||||||
emacs_notes: list,
|
emacs_notes: list,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
description: Optional[str] = None,
|
description: Optional[str] = None,
|
||||||
) -> Optional[Scrobble]:
|
) -> Optional[Scrobble]:
|
||||||
|
description = _extract_org_section(description, "*** Description")
|
||||||
scrobble = Scrobble.objects.filter(
|
scrobble = Scrobble.objects.filter(
|
||||||
in_progress=True,
|
in_progress=True,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@ -818,6 +844,11 @@ def emacs_scrobble_update_task(
|
|||||||
for note in emacs_notes:
|
for note in emacs_notes:
|
||||||
timestamp = note.get("timestamp")
|
timestamp = note.get("timestamp")
|
||||||
content = note.get("content")
|
content = note.get("content")
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
content = _truncate_at_org_header(content)
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
if timestamp:
|
if timestamp:
|
||||||
existing = scrobble.log["notes"].get(timestamp)
|
existing = scrobble.log["notes"].get(timestamp)
|
||||||
if existing != content:
|
if existing != content:
|
||||||
@ -896,7 +927,7 @@ def emacs_scrobble_task(
|
|||||||
|
|
||||||
task_data.pop("notes", None)
|
task_data.pop("notes", None)
|
||||||
task_data["title"] = task_data.pop("description")
|
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["labels"] = task_data.pop("labels")
|
||||||
|
|
||||||
task_data["orgmode_id"] = task_data.pop("source_id")
|
task_data["orgmode_id"] = task_data.pop("source_id")
|
||||||
|
|||||||
@ -32,6 +32,16 @@ from webdav.client import get_webdav_client
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from scrobbles.models import Scrobble
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|||||||
@ -64,6 +64,8 @@ def convert_old_todoist_log_to_new(commit=False):
|
|||||||
|
|
||||||
|
|
||||||
def convert_notes_to_dict(commit=False):
|
def convert_notes_to_dict(commit=False):
|
||||||
|
from scrobbles.utils import make_note_timestamp
|
||||||
|
|
||||||
scrobbles = Scrobble.objects.filter(log__notes__isnull=False)
|
scrobbles = Scrobble.objects.filter(log__notes__isnull=False)
|
||||||
count = 0
|
count = 0
|
||||||
for scrobble in scrobbles:
|
for scrobble in scrobbles:
|
||||||
@ -71,7 +73,7 @@ def convert_notes_to_dict(commit=False):
|
|||||||
print(f"Converting {scrobble} string note to dict")
|
print(f"Converting {scrobble} string note to dict")
|
||||||
if scrobble.log.get("notes") == "":
|
if scrobble.log.get("notes") == "":
|
||||||
scrobble.log.pop("notes")
|
scrobble.log.pop("notes")
|
||||||
key = str(int(scrobble.timestamp.timestamp()))
|
key = make_note_timestamp(scrobble.timestamp)
|
||||||
notes = scrobble.log.pop("notes")
|
notes = scrobble.log.pop("notes")
|
||||||
scrobble.log = {}
|
scrobble.log = {}
|
||||||
scrobble.log["notes"] = {key: notes}
|
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):
|
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)
|
scrobbles = Scrobble.objects.filter(task__isnull=False, log__notes__isnull=False)
|
||||||
count = 0
|
count = 0
|
||||||
for scrobble in scrobbles:
|
for scrobble in scrobbles:
|
||||||
notes = scrobble.log.get("notes")
|
notes = scrobble.log.get("notes")
|
||||||
if isinstance(notes, list):
|
if isinstance(notes, list):
|
||||||
key = str(int(scrobble.timestamp.timestamp()) + 10)
|
key = make_note_timestamp(scrobble.timestamp + timedelta(seconds=10))
|
||||||
parts = []
|
parts = []
|
||||||
for note in notes:
|
for note in notes:
|
||||||
if isinstance(note, dict):
|
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):
|
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")
|
scrobbles = Scrobble.objects.filter(board_game__isnull=False, log__has_key="notes")
|
||||||
count = 0
|
count = 0
|
||||||
for scrobble in scrobbles:
|
for scrobble in scrobbles:
|
||||||
@ -123,7 +129,7 @@ def convert_old_boardgame_log_to_new(commit=False):
|
|||||||
notes = [notes]
|
notes = [notes]
|
||||||
if isinstance(notes, list):
|
if isinstance(notes, list):
|
||||||
key_ts = scrobble.stop_timestamp or scrobble.timestamp
|
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
|
str(n) for n in notes
|
||||||
)}
|
)}
|
||||||
count += 1
|
count += 1
|
||||||
|
|||||||
@ -235,7 +235,7 @@ class EmacsWebhookView(APIView):
|
|||||||
if task_in_progress:
|
if task_in_progress:
|
||||||
emacs_scrobble_update_task(
|
emacs_scrobble_update_task(
|
||||||
post_data.get("source_id"),
|
post_data.get("source_id"),
|
||||||
post_data.get("notes", []),
|
post_data.get("notes") or [],
|
||||||
user_id,
|
user_id,
|
||||||
description=post_data.get("body"),
|
description=post_data.get("body"),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -14,11 +14,12 @@ def version_info(request):
|
|||||||
if not commit:
|
if not commit:
|
||||||
# Try to import from _commit.py module first
|
# Try to import from _commit.py module first
|
||||||
try:
|
try:
|
||||||
from vrobbler._commit import commit
|
from vrobbler._commit import commit as _commit
|
||||||
except ImportError:
|
except ImportError:
|
||||||
pass
|
pass
|
||||||
else:
|
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)
|
# Try to read from commit file (written during deploy)
|
||||||
commit_file = Path("/var/lib/vrobbler/commit.txt")
|
commit_file = Path("/var/lib/vrobbler/commit.txt")
|
||||||
|
|||||||
@ -258,7 +258,7 @@
|
|||||||
{% for chart in charts.trail|slice:":20" %}
|
{% for chart in charts.trail|slice:":20" %}
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
|
<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>
|
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@ -53,13 +53,15 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Artist</th>
|
<th scope="col">Artist</th>
|
||||||
<th scope="col">Score</th>
|
<th scope="col">Score</th>
|
||||||
|
<th scope="col"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for sa in similar_artists|slice:":10" %}
|
{% for sa in similar_artists %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{% if sa.local_url %}<a href="{{sa.local_url}}">{{sa.name}}</a>{% else %}{{sa.name}}{% endif %}</td>
|
<td>{% if sa.local_url %}<a href="{{sa.local_url}}">{{sa.name}}</a>{% else %}{{sa.name}}{% endif %}</td>
|
||||||
<td>{{sa.score}}</td>
|
<td>{{sa.score}}</td>
|
||||||
|
<td>{% if sa.musicbrainz_url %}<a href="{{sa.musicbrainz_url}}">musicbrainz</a>{% endif %}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
Reference in New Issue
Block a user