Compare commits
147 Commits
dfd51c1343
...
46.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 4bf22c96e9 | |||
| dec7a79509 | |||
| 371e1d654c | |||
| bef7e683c5 | |||
| ec219ef3ea | |||
| dcc7229e90 | |||
| 73665ef19e | |||
| 2536e330af | |||
| 99c056adeb | |||
| 7a504e45de | |||
| 7618d0ba30 | |||
| ce4dc40033 | |||
| b0b22b79dc | |||
| 6471413681 | |||
| 50b10689fc | |||
| 85bddb6cba | |||
| c285b0d3b3 | |||
| 671fe8d86f | |||
| 89817110de | |||
| ee01e3d8df | |||
| a70343d6f3 | |||
| 3e72042c24 | |||
| 087c7775ae | |||
| 3f71065ad6 | |||
| 801672124f | |||
| 811e9c1ce9 | |||
| 415b32bdc7 | |||
| 22319c807a | |||
| f9ba6fec14 | |||
| 5f55163147 | |||
| a6ef34623e | |||
| 7cb48d20f6 | |||
| 445103a878 | |||
| 579da8c44e | |||
| daabd2f37f | |||
| 039c58cf89 | |||
| 410c033f12 | |||
| ce302e4d45 | |||
| 19589c9463 | |||
| 3d9506b14e | |||
| 23b87278b2 | |||
| 0b8e027c30 | |||
| 1bd9f0d942 | |||
| fa7890cb21 | |||
| 957c32e3a7 | |||
| 8d069df9d1 | |||
| 96d1d7ac6b | |||
| 009b2ba243 | |||
| 4f051ae250 | |||
| 7e9fbb1bf6 | |||
| ec1a54f623 | |||
| b502667ca6 | |||
| 263874288a | |||
| bca90c97ae | |||
| 12f49a6cee | |||
| 034c7cb413 | |||
| e737870733 | |||
| 95757650f6 | |||
| 1928acf8a6 | |||
| 22956c7c7f | |||
| 17aed1191d | |||
| 0b1fb8667c | |||
| 13dd5b67d0 | |||
| 20c7874466 | |||
| 62d8ffd794 | |||
| bea2b2d187 | |||
| 034cb99c77 | |||
| 37187f33dd | |||
| d7a23c3832 | |||
| 6d45571e75 | |||
| 88fd0ed7f8 | |||
| 2100cedc1a | |||
| 2b17a92c6c | |||
| 72fd1ab90e | |||
| 301440909b | |||
| 389641002d | |||
| 43d514cf5b | |||
| 25baeca2b0 | |||
| b4e15c73c1 | |||
| 90f3d38687 | |||
| 8afb227267 | |||
| 425abebc9a | |||
| afb61f6622 | |||
| 65815fc198 | |||
| 75479d91a3 | |||
| 99789e5477 | |||
| fd95f1e686 | |||
| e133c4149b | |||
| 7e75828012 | |||
| 664148e702 | |||
| 768819b664 | |||
| 760d453165 | |||
| 2fac5815b1 | |||
| f4b30ade70 | |||
| 1cbb84c29f | |||
| b622b151d4 | |||
| 4e1c3ffbf0 | |||
| 8a419c7bbc | |||
| 0639033aa9 | |||
| 6927729284 | |||
| 766f9db17c | |||
| 25cbd88071 | |||
| 972568bebc | |||
| 939c89d368 | |||
| 83e7061b92 | |||
| a171517f92 | |||
| a4030e89ec | |||
| dce31ed840 | |||
| 645e81299b | |||
| 72fa41977e | |||
| 56745b33f4 | |||
| dd2f44e72f | |||
| 41890d14d9 | |||
| c5f1ee2d64 | |||
| 26176ccd73 | |||
| 9b7fa0d4f8 | |||
| aeb460d677 | |||
| 4e059683b0 | |||
| d3146433f2 | |||
| 7b487f8494 | |||
| f7675b8a02 | |||
| d036dbe4fd | |||
| 86c495f22f | |||
| b9324d6443 | |||
| c11858810c | |||
| 9bafe45951 | |||
| 4d8e925f8c | |||
| e7fff25543 | |||
| 62c200ab05 | |||
| 7c33095d87 | |||
| cb50de13c0 | |||
| 08152de086 | |||
| 0d6b2c4afc | |||
| 9d3f7f434f | |||
| 2b88f89794 | |||
| 3014a30616 | |||
| c7850878fe | |||
| db5b673cd5 | |||
| 217e2443e2 | |||
| af8b1d4f8a | |||
| d69bc6c235 | |||
| cc0f7db453 | |||
| 11bde2a306 | |||
| cd4f6ff71d | |||
| bf7e5677e6 | |||
| 2b04a17d77 | |||
| 97f35f62b8 |
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:
|
||||
@ -120,13 +121,16 @@ jobs:
|
||||
key: ${{ secrets.JAIL_KEY }}
|
||||
command_timeout: 2m
|
||||
script: |
|
||||
set -e
|
||||
mkdir -p /var/lib/vrobbler
|
||||
echo "${{ gitea.sha }}" | cut -c1-8 > /var/lib/vrobbler/commit.txt
|
||||
pip uninstall -y vrobbler
|
||||
pip install /var/lib/vrobbler/dist/*.whl
|
||||
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 celery && immortalctl restart vrobbler
|
||||
immortalctl restart vrobbler-celery && immortalctl restart vrobbler-celerybeat && immortalctl restart vrobbler
|
||||
|
||||
- name: Notify deploy success (ntfy)
|
||||
if: success()
|
||||
706
PROJECT.org
706
PROJECT.org
@ -1,5 +1,6 @@
|
||||
#+title: Vrobbler Project
|
||||
|
||||
We should convert this PROJECT file to put tickets in a subdirectory, tickets, with each ticket having it's own shortid_title.org
|
||||
* Overview
|
||||
Vrobbler began humbly enough as a way to use Jellyfin's webhook to keep track of
|
||||
the shows and movies I was watching. More specifically, I broke my ankle a few
|
||||
@ -92,8 +93,11 @@ fetching and simple saving.
|
||||
:LOGBOOK:
|
||||
CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
|
||||
:END:
|
||||
* Backlog [20/40]
|
||||
* Backlog [0/12] :vrobbler:project:personal:
|
||||
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles:
|
||||
:PROPERTIES:
|
||||
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
|
||||
:END:
|
||||
** TODO [#C] Move to using more robust mopidy-webhooks pacakge form pypi :utility:improvement:
|
||||
:PROPERTIES:
|
||||
:ID: ab31fdc3-359c-1b1d-6b9d-546b476021ba
|
||||
@ -389,33 +393,73 @@ CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
|
||||
}
|
||||
#+end_src
|
||||
** TODO [#C] User should be able to enable auto trail tracking via amail reader with Garmin LiveTrack URLs :vrobbler:trails:project:feature:personal:
|
||||
** TODO [#C] Allow users to see tasks on calendar view :vrobbler:personal:project:templates:feature:
|
||||
https://codepen.io/oliviale/pen/QYqybo
|
||||
** TODO [#C] Come up with a possible flow using WebDAV and super-productivity for tasks :personal:feature:project:vrobbler:tasks:
|
||||
** TODO [#B] Add importer class for IMAP imports :vrobbler:feature:imap:importers:project:personal:
|
||||
:PROPERTIES:
|
||||
:ID: 133bcf71-078f-4efa-a029-1eae4b4d146d
|
||||
:END:
|
||||
** TODO [#C] Fix exporting so it works reliably :exporting:project:feature:
|
||||
|
||||
*** Description
|
||||
|
||||
The existing export function is very naieve. It runs in the web process, takes
|
||||
too long and just dumps tracks. We should make it more robust by creating one
|
||||
CSV file per scrobble media type and writing them into a zip file that gets
|
||||
placed in the media directory:
|
||||
|
||||
`/media/exports/user_<user_id>/<timestamp>-export.zip`
|
||||
|
||||
And this should all be done in a celery task that is just kicked off by the
|
||||
"Export" button on the frontend
|
||||
|
||||
|
||||
** TODO [#B] Add AllTrails as a source for Trail data :vrobbler:trails:feature:personal:project:
|
||||
:PROPERTIES:
|
||||
:ID: 39313362-cdfe-46e7-bbd4-9139a65c0b3c
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
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:
|
||||
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:
|
||||
|
||||
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 `garmin_activity_id` to the TrailLogData class :trails:feature:personal:project:
|
||||
:PROPERTIES:
|
||||
:ID: 5a4fb0f8-0555-40ec-b06f-93c26bd686f4
|
||||
:END:
|
||||
*** Description
|
||||
|
||||
Would be nice to have some loose connection to the actual event in my Garmin
|
||||
profile.
|
||||
|
||||
** 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.
|
||||
|
||||
** 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] Fix PuzzleLogData has no attribute form :vrobbler:puzzles:personal:project:logdata:
|
||||
** TODO [#B] Scrape ComicBookRoundUp ratings for comic book metadata :vrobbler:books:feature:comicbook:personal:project:
|
||||
|
||||
- Note taken on [2025-09-25 Thu 10:51]
|
||||
|
||||
As an example https://comicbookroundup.com/comic-books/reviews/humanoids-publishing/the-history-of-science-fiction
|
||||
** TODO [#B] Add PuzzleLogData class with with_people and completed :vrobbler:feature:puzzles:logdata:personal:project:
|
||||
** TODO [#A] Find page numbers for comic books from ComicVine :vrobbler:feature:books:personal:project:
|
||||
** TODO [#A] Fix koreader scrobble imports to use DST properly :vrobbler:personal:bug:books:imports:
|
||||
** TODO [#B] Find page numbers for comic books from ComicVine :feature:books:
|
||||
:PROPERTIES:
|
||||
:ID: 79f867c3-1288-4143-b6bf-2a452983ee9f
|
||||
:END:
|
||||
** TODO [#B] Fix koreader scrobble imports to use DST properly :bug:books:imports:
|
||||
:PROPERTIES:
|
||||
:ID: 79758cba-a440-48b6-a637-efb88827acf2
|
||||
:END:
|
||||
|
||||
- Note taken on [2025-09-25 Thu 10:37] \\
|
||||
|
||||
@ -429,12 +473,635 @@ https://app.todoist.com/app/task/add-a-csv-endpoint-for-users-book-reads-that-li
|
||||
an hour for part of the year. Also, we'd need to adjust any old scrobbles that
|
||||
took place with DST off to roll them back by an hour.
|
||||
|
||||
** TODO [#A] When creating org-mode tasks, don't copy comments :vrobbler:bug:scrobbles:tasks:
|
||||
** TODO Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment:
|
||||
** TODO Fix various artist album problem with Superwolves (track with multiple artists) :vrobbler:project:music:bug:artists:
|
||||
|
||||
*** Description
|
||||
|
||||
This is a long-standing problem when daylight saving time takes effect. Time is
|
||||
manually set on a KoReader device (or at least, always saved in local time). So
|
||||
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 [#B] Make IMAP and WebDAV configurable :webdav:feature:imap:importers:
|
||||
:PROPERTIES:
|
||||
:ID: b1426d92-2feb-4d15-9738-d5b7b0594f96
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Currently we have webdav able to import post types of file-based incoming data,
|
||||
usually in the form of CSVs but also gpx files, bgstats json files, and
|
||||
audioscrobbler TSV files.
|
||||
|
||||
What if the user could specify via their profile (settings) which imports they
|
||||
wanted to use IMAP for and which ones they wanted to use WebDAV for.
|
||||
|
||||
Then we'd have two celery tasks that would be kicked off periodically via
|
||||
celerybeat, one for IMAP imports every 12 minutes and one for WebDAV every 3
|
||||
minutes. Both would be responsible for checking if a user has an configured
|
||||
imports of their type, check if an import needs to run, and dispatch the
|
||||
needed import celery task. This is how the WebDAV celery task currently works.
|
||||
|
||||
This would also be an opporunity to clean up the code around WebDAV imports
|
||||
and make them more re-usable for other import services.
|
||||
|
||||
* Version 46.0 [1/1]
|
||||
** DONE [#C] Add sentiment parsing for Scrobbles with notes :scrobbles:sentiment:
|
||||
:PROPERTIES:
|
||||
:ID: 37781d6a-f3b0-48b2-bf98-33c2c791cf85
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Not sure how useful this would be, but I wonder if we can add a `sentiment` JSONField
|
||||
on each scrobble that can store the output of VADER over the notes in a scrobble with notes.
|
||||
|
||||
I'm not sure that the value prop here is worth the storage and processing time.
|
||||
|
||||
But if we do add it, it should be a process that scans for scrobbles with both notes and no
|
||||
sentiment field value (unless --overwrite is used) and just run periodically.
|
||||
|
||||
|
||||
* Version 45.1 [1/1]
|
||||
** DONE [#B] Mopidy favorites or monthly playlist adds should look at all scrobbles :bug:mopidy:favorites:tracks:
|
||||
:PROPERTIES:
|
||||
:ID: 0be7d11e-e268-2fd5-836a-e5b4d210e0fa
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
When favoriting a track and trying to add it to the Moidy favorite playlist, it
|
||||
sometimes happens that one scrobble did not come from Mopidy, but an earlier or
|
||||
later one did.
|
||||
|
||||
Can we scan all the scrobbles of the track for a given user to see if any have
|
||||
`mopidy_uri` in the log and if so, use that to send along to Mopidy?
|
||||
|
||||
|
||||
* Version 45.0 [1/1]
|
||||
** DONE [#B] Add ability to add mopidy tracks to Monthly playlists :feature:favorites:tracks:
|
||||
:PROPERTIES:
|
||||
:ID: c872ff0a-e71f-415f-b5a6-e62ea9634d14
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Now that we can favorite a mopidy track and have it added to a Favorites playlist, it would
|
||||
be great if we could also populate a monthly_mopidy_playlist_pattern in a user profile and,
|
||||
if configured, you could press "Add to monthly playlist" button on a given track that has
|
||||
a mopidy_uri in it's log, and it would be added to the playlist.
|
||||
|
||||
The patterns would be based on traditional Django date formatting patterns: https://gregbrown.co/code/date-format
|
||||
|
||||
So "Y m F" would yield "2026 05 May" if the link is clicked in May of 2026.
|
||||
|
||||
|
||||
* Version 44.0 [1/1]
|
||||
** DONE [#B] Add favorite feature for scrobbles :feature:favorites:scrobbles:
|
||||
:PROPERTIES:
|
||||
:ID: 2780ae5f-fe23-49a5-8b33-d19e7f3e8ec6
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Would be great to have a FavoriteMedia data model that would accept any media_type ID and a user_id
|
||||
marking that media as a favorite for that user.
|
||||
|
||||
Additionally, for tracks, we should add the ability to set a "favorites_mopidy_playlist" in a user profile
|
||||
and if populated, and a track media type is favorited, and the track has a mopidy_uri value in a scrobble log,
|
||||
send a POST to the mopidy server RPC endpoint for the favorite playlist and add the track.
|
||||
|
||||
|
||||
|
||||
* Version 43.0 [5/5]
|
||||
** DONE [#B] Can we show a graph of all past Weigh-in tasks :scale:tasks:graphs:javascript:
|
||||
:PROPERTIES:
|
||||
:ID: ae499d87-03bf-4e48-9b2c-1a421a46af11
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
I wonder if, as a special type of task, Weigh-in's could show a graph of the
|
||||
metrics that are stored against all the past weigh-ins?
|
||||
|
||||
The graph would contain all Weigh-in scrobbles for that user, no matter which
|
||||
date is being viewed, and the highlighted value on the graph would be the date
|
||||
being viewed.
|
||||
|
||||
Probably could use something like chart.js although maybe that's too heavy?
|
||||
|
||||
And can we have each metric overlayed on the same graph?
|
||||
|
||||
** DONE [#B] When viewing scrobbles by tag, sum the total time :scrobbles:tags:
|
||||
:PROPERTIES:
|
||||
:ID: d51f23df-c2c5-4e1a-b000-67c89032af02
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
On scrobbles filtered by tags, we should see a sum of the time spent doing those tasks, in a human
|
||||
readable format like "X days, X hours, X minutes and X seconds"
|
||||
|
||||
** DONE [#A] Orgmode tasks are not updated if in progress :tasks:orgmode:bug:
|
||||
:PROPERTIES:
|
||||
:ID: 7dcebb2c-7c4c-4ac5-bee6-c2e36c3811f9
|
||||
:END:
|
||||
|
||||
*** 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
|
||||
|
||||
** DONE [#A] Ignore tag 'inprogress' for Tasks :bug:tasks:tags:
|
||||
:PROPERTIES:
|
||||
:ID: cd37c1ec-e2fc-b93c-daf8-6b329712c3f1
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
When scrobbling tasks from Todoist, the tag `inprogress` is always in the
|
||||
payload, because that's how we parse tasks starting from the Todoist webhooks.
|
||||
|
||||
But we don't really need anything tagged as `inprogress` Can we ignore this tag
|
||||
when applying tags to Task scrobbles coming from Todoist?`
|
||||
|
||||
|
||||
|
||||
** DONE [#A] Deploys are now throwing an unknown version error :bug:tooling:releases:
|
||||
:PROPERTIES:
|
||||
:ID: 3870f9d3-b5ed-4b87-9e8c-9bf905bfb766
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Almost everything is working, but for some reason `__version__` does not seem to
|
||||
exist.
|
||||
|
||||
#+begin_src sh
|
||||
out: Installing collected packages: vrobbler
|
||||
out: Successfully installed vrobbler-42.0
|
||||
err: WARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv
|
||||
err: Traceback (most recent call last):
|
||||
err: File "<string>", line 1, in <module>
|
||||
err: AttributeError: module 'vrobbler' has no attribute '__version__'
|
||||
2026/06/04 17:18:15 Process exited with status 1
|
||||
failed to remove container: Error response from daemon: removal of container c8ac64bee9b6bf5978d2c16f299e5ac271d8bbf7192b7a4023c3712bc2444f8b is already in progress
|
||||
❌ Failure - Main Install wheel and restart services
|
||||
exit with `FAILURE`: 1
|
||||
#+end_src
|
||||
|
||||
|
||||
* Version 42.0 [1/1]
|
||||
** DONE [#B] Add ability to add track to current Mopidy queue :feature:mopidy:tracks:
|
||||
:PROPERTIES:
|
||||
:ID: 79d5b580-4ea6-461b-4c6c-2c950d8b3e4c
|
||||
:END:
|
||||
|
||||
|
||||
* Version 41.0 [5/5]
|
||||
** DONE [#B] For any scrobble detail page with notes display them better :templates:notes:scrobbles:
|
||||
:PROPERTIES:
|
||||
:ID: c0dcf9da-227f-4a22-bcd9-9d46053607d9
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Currently notes are displayed as little post-it notes. This is cute, but not terribly useful.
|
||||
|
||||
We should update note rendering to be a simple newest to oldest display in a
|
||||
single column with the timestamp has a small header, and the content rendered as
|
||||
markdown with a small bar or horizontal divider marking them from the next note.
|
||||
|
||||
** DONE [#A] Imports should send notifications :feature:notifications:imports:
|
||||
:PROPERTIES:
|
||||
:ID: 6f78f8d5-ecaa-4d8a-a666-ae4e27653191
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Currently importing board games sends out a ntfy message when a scrobble is
|
||||
created.
|
||||
|
||||
We should do the same thing for other import types; namely: gpx, ebird, and scale.
|
||||
|
||||
** DONE [#A] Board game imports send duplicate ntfy message :bug:notifications:boardgames:
|
||||
:PROPERTIES:
|
||||
:ID: 8f067432-0399-4b79-9e93-727edcccedbd
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
When a board game scrobble is created via a bgstats import, ntfy messages are
|
||||
sent.
|
||||
|
||||
But right now they are duplicated (two are sent at the same time). Can we review
|
||||
the code to see why this is happening and fix it?
|
||||
|
||||
** DONE [#A] Too many geolocation notifications go out :bug:notifications:geolocations:
|
||||
:PROPERTIES:
|
||||
:ID: 6357ad7a-fe4e-49dd-a063-55d87e459c17
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Currently ntfy gets overwhelemed when there's more than a hundred or so messages left in a queue on a client.
|
||||
|
||||
It would be nice if we could not spam ntfy, and this is especially true with Geolocations, where we really
|
||||
don't need to alert folks unless they have a named Geolocation (has a title). Can we adjust the ntfy
|
||||
sending for Geolocations to only send if the scrobbled location has a title?
|
||||
|
||||
|
||||
** DONE [#C] Fix bug where Weigh-in imports do not set title :bug:tasks:scale:
|
||||
:PROPERTIES:
|
||||
:ID: 622e354a-8e66-4ecd-9e1c-a53f0a2ec362
|
||||
:END:
|
||||
|
||||
*** 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]
|
||||
** DONE [#B] Clean up org-mode tasks metadata :bug:tasks:metadata:
|
||||
:PROPERTIES:
|
||||
:ID: 0c762d09-fc69-4e75-be40-7eaaf04f178e
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Org-mode tasks have a `Description` subheader, which should populate the
|
||||
"Description" of a task.
|
||||
|
||||
The title should come from the actual TODO content, with tags coming from the
|
||||
tags in colons after the task. This behaviour should already work.
|
||||
|
||||
Next, any comments should go under a `Comments` subheader. Under these should be a list tag like:
|
||||
|
||||
"Note taken on [2026-05-31 Sun 20:03]"
|
||||
|
||||
Which declares it's a note with a timestamp like "YYYY-MM-DD d HH:MM".
|
||||
|
||||
When scrobbling a task from org-mode, the description should always be copied to
|
||||
the scrobble's log["description"].
|
||||
|
||||
Any comments that exist on the task when the scrobble is first created, should
|
||||
be ignored.
|
||||
|
||||
Any comments on a task that is updated (a re-POST'd while the task is in
|
||||
progress, should add a comment in the log["notes"], a dictionary, keyed off the
|
||||
timestamp the note string. If a note for that datetime already exists, the
|
||||
content should be replaced with the new comment.
|
||||
|
||||
Additionally, when a task is re-POST'd while the task is in progress, the
|
||||
description should also be compared, and if different, the newest description
|
||||
should be used.
|
||||
|
||||
Note, the biggest change for this flow is making sure comments that already
|
||||
exist are not added to any new tasks and that both comments and descriptions are
|
||||
added or updated if the new values are different than what is already in the
|
||||
currently in-progress task.
|
||||
|
||||
If the task is completed, don't touch it.
|
||||
|
||||
*** Comments
|
||||
- Note taken on [2026-05-31 Sun 20:03]
|
||||
|
||||
** DONE [#A] Actually push branches up and add a just command to do it :release:justfile:tooling:
|
||||
:PROPERTIES:
|
||||
:ID: 50aa5daa-a802-6aa9-38a3-218b7a9d4b34
|
||||
:END:
|
||||
** DONE [#A] Try to fix deploy failing with bad release plan :release:tooling:pyproject:
|
||||
:PROPERTIES:
|
||||
:ID: 63dc633c-4382-e6a5-e663-b01871ce86ce
|
||||
:END:
|
||||
|
||||
* Version 38.0 [38/38]
|
||||
** DONE [#A] Fix release flow to be easier to trigger :pyproject:release:tooling:
|
||||
:PROPERTIES:
|
||||
:ID: e321f8a3-26ea-b316-8493-294b50c0b516
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
We should have a command like `just relesae <major|minor>` that can cut new
|
||||
releases on command by updating the PROJECT.org file with a release header for
|
||||
all DONE tasks and creating the appropriate release commits and tags based on
|
||||
either the new release version type.
|
||||
|
||||
It should also update the pyproject file.
|
||||
|
||||
** DONE [#A] Move imported retroarch lrtl files to processed/ directory on WebDAV :webdav:retroarch:importers:
|
||||
:PROPERTIES:
|
||||
:ID: 881b5af6-e96f-4e71-9e66-2d30824e5504
|
||||
:END:
|
||||
|
||||
- File: ~vrobbler/apps/scrobbles/importers/webdav.py~ (line 439)
|
||||
- Same pattern as the GPX importer: after importing a =.csv= file from
|
||||
WebDAV, move it to =var/retroarch/processed/= with a timestamp appended.
|
||||
|
||||
** DONE [#A] Add listenbrainz support for similar tracks :feature:music:metadata:
|
||||
:PROPERTIES:
|
||||
:ID: a3abba14-7603-db51-58e1-1941a47fa35e
|
||||
:END:
|
||||
** DONE [#B] Consolidate albums in the same musicbrainz_releasegroup_id :music:albums:metadata:
|
||||
:PROPERTIES:
|
||||
:ID: 6b331bdb-1516-bb86-7cb3-8cb9e3de59ce
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
When we look up albums, we should check if one already exists with the same
|
||||
musicbrainz_releasegroup_id and prefer that one, rather than creating a new
|
||||
album.
|
||||
|
||||
Also, we should create a data migration to clean up albums with matching
|
||||
musicbrainz_releasegroup_id fields by consolidating tracks around the first
|
||||
album found.
|
||||
|
||||
Whether the track was enriched by musicbrainz or not, the track should get
|
||||
tagged with `musicbrainz_enriched` or `musicbrainz_notfound`
|
||||
|
||||
** DONE [#A] Clean up metadata on music tracks :music:tracks:metadata:musicbrainz:
|
||||
:PROPERTIES:
|
||||
:ID: 3a007bd7-b901-43cb-892e-102d39fffc85
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
There's a over 3K tracks without a musicbrainz_id and almost 30K without a
|
||||
base_run_time_seconds. We should have a clean up script that can run through the
|
||||
ones missing musicbrainz_ids and see about getting metadata from musicbrainz and
|
||||
for the 30K without base_run_time_seconds, check with their musicbrainz_ids to
|
||||
see if we can look up the track length, and if not, tag the track with
|
||||
"missing-metadata" tag.
|
||||
|
||||
Also, if the tracks without musicbrainz_ids actually are not in the MB database,
|
||||
we should tag those with "not-in-musicbrainz"
|
||||
|
||||
And a big part of this work will probably involve checking for "Various Artists"
|
||||
tracks where the track erroneously got set with a generic artist. In those
|
||||
cases, we should try just looking up by track title.
|
||||
|
||||
** DONE [#B] Make artists_m2m field source of artist truth for albums :music:bug:albums:
|
||||
:PROPERTIES:
|
||||
:ID: 05a24455-0e71-45ef-ac6e-c1b7b843047d
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Albums have an FK for album_artist, but like artists, the M2M should be the
|
||||
source of truth. We should migrate all uses of album_artist to an `artist`
|
||||
property on the Album model and use a data migration to populate artists with
|
||||
the album_artist value.
|
||||
|
||||
** DONE [#A] Fix various artist album problem with Superwolves (track with multiple artists) :vrobbler:project:music:bug:artists:
|
||||
:PROPERTIES:
|
||||
:ID: 590bc038-745f-710b-8272-4d8a3d2efa01
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
We have an issue with tracks where there are two artists, like `Matt Sweeney &
|
||||
Bonnie "Prince" Billy`
|
||||
|
||||
I think we need to allow creating a single artist with both names, so the Artist
|
||||
is the full name. That said, the ampersand is usually used to split feature
|
||||
artsits (I think) so I'm not sure how this would work reliably.
|
||||
|
||||
Also, it's possible musicbrainz does not have dual artist listings. So it's
|
||||
possible the longer term solution is to allow multiple artists per track the way
|
||||
we now allow tracks to be on multiple albums. We could deprecate the Artist FK
|
||||
or at least make it optional, and then require a M2M between Track and Artist.
|
||||
|
||||
Then this one would be a Track by both `Matt Sweeney` and `Bonnie "Prince"
|
||||
Billy`
|
||||
|
||||
** DONE [#A] Move imported eBird CSV files to processed/ directory on WebDAV :webdav:ebird:importers:
|
||||
:PROPERTIES:
|
||||
:ID: 445e1253-d353-4b55-b1d8-39d0a0dcdd34
|
||||
:END:
|
||||
|
||||
- File: ~vrobbler/apps/scrobbles/importers/webdav.py~ (line 439)
|
||||
- Same pattern as the GPX importer: after importing a =.csv= file from
|
||||
WebDAV, move it to =var/ebird/processed/= with a timestamp appended.
|
||||
|
||||
** DONE [#A] Move imported Board Game CSV files to processed/ directory on WebDAV :webdav:boardgames:importers:
|
||||
:PROPERTIES:
|
||||
:ID: a3f8d30c-d2c3-4ee7-b062-f0f16bd9b0b4
|
||||
:END:
|
||||
|
||||
- File: ~vrobbler/apps/scrobbles/importers/webdav.py~ (line 496)
|
||||
- Same pattern as the GPX importer: after importing a =.csv= file from
|
||||
WebDAV, move it to =var/bgstats/processed/= with a timestamp appended.
|
||||
|
||||
** DONE [#A] Move imported Scale CSV files to processed/ directory on WebDAV :webdav:scale:importers:
|
||||
:PROPERTIES:
|
||||
:ID: 1a0de363-d1ea-466e-9966-e24941a6180b
|
||||
:END:
|
||||
- File: ~vrobbler/apps/scrobbles/importers/webdav.py~ (line 496)
|
||||
- Same pattern as the GPX importer: after importing a =.csv= file from
|
||||
WebDAV, move it to =var/scale/processed/= with a timestamp appended.
|
||||
|
||||
** DONE [#A] Allow special parameter to re-import already processed GPX files :imports:gpx:
|
||||
:PROPERTIES:
|
||||
:ID: 166c0809-c11a-4d02-8071-7a69dcb36e64
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Now that we stash imported GPX files in the processed/ subdirectory on import,
|
||||
it would be nice to have a flag like --include-processed on the webdav importer
|
||||
that would import files both in the root of gpx/ and also in the processed
|
||||
directory. This would aide testing imports in staging quickly without constantly
|
||||
moving files back and forth.
|
||||
|
||||
** DONE [#A] Move imported GPX files to processed/ directory on WebDAV :webdav:gpx:importers:
|
||||
:PROPERTIES:
|
||||
:ID: db2b02fc-817c-4c39-bd51-13f9b77c7888
|
||||
:END:
|
||||
- File: ~vrobbler/apps/scrobbles/importers/webdav.py~ (line 198)
|
||||
- After importing a GPX/FIT file from WebDAV, move it to a =processed/=
|
||||
subdirectory with a timestamp appended. This eliminates the DB lookup for
|
||||
already-imported filenames — any file present in the top-level directory
|
||||
is new. Also makes manual re-imports easy (just move a file back).
|
||||
|
||||
** DONE [#A] Add CSS Grid calendar view for scrobbles :vrobbler:personal:project:templates:feature:
|
||||
:PROPERTIES:
|
||||
:ID: be915acf-d803-466a-8770-823819ebf2a9
|
||||
:END:
|
||||
|
||||
*** Description
|
||||
|
||||
Calendar view at /scrobbles/calendar/ showing select media types (Tasks,
|
||||
Birding, Food, Trails, VideoGames, Books) in a CSS Grid month layout.
|
||||
|
||||
- Emoji badges per scrobble on each day cell, hover reveals title
|
||||
- Click emoji to navigate to scrobble detail
|
||||
- Prev/Next month navigation
|
||||
- Based on the CodePen CSS Grid calendar pattern:
|
||||
https://codepen.io/oliviale/pen/QYqybo
|
||||
|
||||
** DONE [#C] Come up with a possible flow using WebDAV and super-productivity for tasks :personal:feature:project:vrobbler:tasks:
|
||||
:PROPERTIES:
|
||||
:ID: dd751edf-6192-2ed0-cd85-905f423f82b0
|
||||
:END:
|
||||
** DONE [#B] Fix PuzzleLogData has no attribute form :vrobbler:puzzles:personal:project:logdata:
|
||||
:PROPERTIES:
|
||||
:ID: cb8e697a-9fc2-c463-5d4d-fa15d3e833a0
|
||||
:END:
|
||||
** DONE [#B] Add PuzzleLogData class with with_people and completed :vrobbler:feature:puzzles:logdata:personal:project:
|
||||
:PROPERTIES:
|
||||
:ID: 1b38c8a8-86d3-b73d-c584-dfb0497970dc
|
||||
:END:
|
||||
** DONE Add weather lookup to the mood check-in flow :vrobbler:project:moods:feature:checkin:
|
||||
:PROPERTIES:
|
||||
:ID: 175678e2-bfe1-0833-f2a9-f03b9323db78
|
||||
:END:
|
||||
<2026-05-20 Wed>
|
||||
** DONE Add importing of openScale CSV files to Tasks :vrobbler:project:personal:tasks:openscale:
|
||||
:PROPERTIES:
|
||||
:ID: 344d1f5d-dda6-a1fb-da53-e00eebdd2262
|
||||
:END:
|
||||
** DONE Add ability to track Birding sessions via BirdingLocation scrobbles :vrobbler:project:birds:feature:
|
||||
:PROPERTIES:
|
||||
:ID: 8200ce29-a691-5cf6-c11a-c77e3d8b64c6
|
||||
@ -639,6 +1306,7 @@ urllib.error.HTTPError: HTTP Error 500: Internal Server Error
|
||||
- Note taken on [2025-09-30 Tue 09:33]
|
||||
|
||||
This may have already been resolved ... need to just confirm it.
|
||||
|
||||
* Version 37.0 [4/4]
|
||||
** DONE [#A] Tasks from org-mode should properly update notes and leave them out of the body :vrobbler:bug:tasks:
|
||||
:PROPERTIES:
|
||||
|
||||
20
README.md
20
README.md
@ -21,3 +21,23 @@ VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS=True
|
||||
VROBBLER_DATABASE_URL="postgres://vrobbler:<pass>@db.service:5432/vrobbler"
|
||||
VROBBLER_REDIS_URL="redis://:<pass>@cache.service:6379/0"
|
||||
```
|
||||
|
||||
## Database Backup
|
||||
|
||||
A backup command is available via `./manage.py backup_database` (also runs on a cron schedule via Celery). It dumps the database with `pg_dump`, compresses with gzip, and optionally copies the backup to a remote host via SCP.
|
||||
|
||||
Configure these additional settings as needed:
|
||||
|
||||
```
|
||||
VROBBLER_DB_BACKUP_SSH_KEY="/path/to/ssh/private/key"
|
||||
VROBBLER_DB_BACKUP_SSH_DEST="user@backup.example.com:/remote/path/"
|
||||
VROBBLER_DB_BACKUP_NTFY_URL="https://ntfy.sh/your-topic"
|
||||
```
|
||||
|
||||
- `VROBBLER_DB_BACKUP_SSH_KEY` — Path to the SSH private key used for remote copy.
|
||||
- `VROBBLER_DB_BACKUP_SSH_DEST` — SCP destination (user@host:path). If set, the backup is copied to the remote host and old backups are pruned.
|
||||
- `VROBBLER_DB_BACKUP_LOCAL_DIR` — Local directory for backup storage. Defaults to `/var/backups/`. Backups are stored in a `vrobbler/` subdirectory.
|
||||
- `VROBBLER_DB_BACKUP_NTFY_URL` — ntfy.sh URL for success notifications. Defaults to `https://ntfy.unbl.ink/backups`.
|
||||
|
||||
Retention is hardcoded: keeps daily backups for 7 days, plus one per month for 12 months.
|
||||
```
|
||||
|
||||
BIN
data/sample-trail.fit
Normal file
BIN
data/sample-trail.fit
Normal file
Binary file not shown.
3360
data/sample_trail.gpx
Normal file
3360
data/sample_trail.gpx
Normal file
File diff suppressed because one or more lines are too long
2
data/scale-example.csv
Normal file
2
data/scale-example.csv
Normal file
@ -0,0 +1,2 @@
|
||||
DATE,TIME,BICEPS,BMI,BMR,BODY_FAT,BONE,CALIPER,CALIPER_1,CALIPER_2,CALIPER_3,CALORIES,CHEST,COMMENT,HEART_RATE,HIPS,LBM,MUSCLE,NECK,TDEE,THIGH,VISCERAL_FAT,WAIST,WATER,WEIGHT,WHR,WHTR
|
||||
2026-05-20,11:56:58.076,,31.09,1706.74,29.084072,3.438837,,,,,,,,,,,33.07067,,2645.46,,,,54.445187,192.68378,,
|
||||
|
9
justfile
9
justfile
@ -14,3 +14,12 @@ celery:
|
||||
|
||||
celery-beat:
|
||||
poetry run celery -A vrobbler beat -l info
|
||||
|
||||
push:
|
||||
git push && git push gitea
|
||||
git push --tags && git push --tags gitea
|
||||
|
||||
release kind="minor":
|
||||
poetry run python scripts/release.py {{kind}}
|
||||
just push
|
||||
|
||||
|
||||
313
poetry.lock
generated
313
poetry.lock
generated
@ -1796,6 +1796,17 @@ files = [
|
||||
packaging = ">=20"
|
||||
platformdirs = ">=4.3.6"
|
||||
|
||||
[[package]]
|
||||
name = "fitparse"
|
||||
version = "1.2.0"
|
||||
description = "Python library to parse ANT/Garmin .FIT files"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "fitparse-1.2.0.tar.gz", hash = "sha256:2d691022452dea6dabad13cc6e017ca467fe8a3a895cd3ac67a50a7bb716b4a9"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flake8"
|
||||
version = "7.3.0"
|
||||
@ -1953,6 +1964,18 @@ files = [
|
||||
{file = "frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpxpy"
|
||||
version = "1.6.2"
|
||||
description = "GPX file parser and GPS track manipulation library"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "gpxpy-1.6.2-py3-none-any.whl", hash = "sha256:289bc2d80f116c988d0a1e763fda22838f83005573ece2bbc6521817b26fb40a"},
|
||||
{file = "gpxpy-1.6.2.tar.gz", hash = "sha256:a72c484b97ec42b80834353b029cc8ee1b79f0ffca1179b2210bb3baf26c01ae"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gunicorn"
|
||||
version = "20.1.0"
|
||||
@ -2486,144 +2509,146 @@ zookeeper = ["kazoo (>=2.8.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "lxml"
|
||||
version = "5.4.0"
|
||||
version = "6.1.1"
|
||||
description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API."
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "lxml-5.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e7bc6df34d42322c5289e37e9971d6ed114e3776b45fa879f734bded9d1fea9c"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6854f8bd8a1536f8a1d9a3655e6354faa6406621cf857dc27b681b69860645c7"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:696ea9e87442467819ac22394ca36cb3d01848dad1be6fac3fb612d3bd5a12cf"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ef80aeac414f33c24b3815ecd560cee272786c3adfa5f31316d8b349bfade28"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b9c2754cef6963f3408ab381ea55f47dabc6f78f4b8ebb0f0b25cf1ac1f7609"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a62cc23d754bb449d63ff35334acc9f5c02e6dae830d78dab4dd12b78a524f4"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f82125bc7203c5ae8633a7d5d20bcfdff0ba33e436e4ab0abc026a53a8960b7"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:b67319b4aef1a6c56576ff544b67a2a6fbd7eaee485b241cabf53115e8908b8f"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:a8ef956fce64c8551221f395ba21d0724fed6b9b6242ca4f2f7beb4ce2f41997"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:0a01ce7d8479dce84fc03324e3b0c9c90b1ece9a9bb6a1b6c9025e7e4520e78c"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:91505d3ddebf268bb1588eb0f63821f738d20e1e7f05d3c647a5ca900288760b"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a3bcdde35d82ff385f4ede021df801b5c4a5bcdfb61ea87caabcebfc4945dc1b"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:aea7c06667b987787c7d1f5e1dfcd70419b711cdb47d6b4bb4ad4b76777a0563"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a7fb111eef4d05909b82152721a59c1b14d0f365e2be4c742a473c5d7372f4f5"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:43d549b876ce64aa18b2328faff70f5877f8c6dede415f80a2f799d31644d776"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-win32.whl", hash = "sha256:75133890e40d229d6c5837b0312abbe5bac1c342452cf0e12523477cd3aa21e7"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:de5b4e1088523e2b6f730d0509a9a813355b7f5659d70eb4f319c76beea2e250"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:98a3912194c079ef37e716ed228ae0dcb960992100461b704aea4e93af6b0bb9"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0ea0252b51d296a75f6118ed0d8696888e7403408ad42345d7dfd0d1e93309a7"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b92b69441d1bd39f4940f9eadfa417a25862242ca2c396b406f9272ef09cdcaa"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20e16c08254b9b6466526bc1828d9370ee6c0d60a4b64836bc3ac2917d1e16df"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7605c1c32c3d6e8c990dd28a0970a3cbbf1429d5b92279e37fda05fb0c92190e"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ecf4c4b83f1ab3d5a7ace10bafcb6f11df6156857a3c418244cef41ca9fa3e44"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cef4feae82709eed352cd7e97ae062ef6ae9c7b5dbe3663f104cd2c0e8d94ba"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:df53330a3bff250f10472ce96a9af28628ff1f4efc51ccba351a8820bca2a8ba"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:aefe1a7cb852fa61150fcb21a8c8fcea7b58c4cb11fbe59c97a0a4b31cae3c8c"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:ef5a7178fcc73b7d8c07229e89f8eb45b2908a9238eb90dcfc46571ccf0383b8"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d2ed1b3cb9ff1c10e6e8b00941bb2e5bb568b307bfc6b17dffbbe8be5eecba86"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:72ac9762a9f8ce74c9eed4a4e74306f2f18613a6b71fa065495a67ac227b3056"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f5cb182f6396706dc6cc1896dd02b1c889d644c081b0cdec38747573db88a7d7"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:3a3178b4873df8ef9457a4875703488eb1622632a9cee6d76464b60e90adbfcd"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e094ec83694b59d263802ed03a8384594fcce477ce484b0cbcd0008a211ca751"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-win32.whl", hash = "sha256:4329422de653cdb2b72afa39b0aa04252fca9071550044904b2e7036d9d97fe4"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd3be6481ef54b8cfd0e1e953323b7aa9d9789b94842d0e5b142ef4bb7999539"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b5aff6f3e818e6bdbbb38e5967520f174b18f539c2b9de867b1e7fde6f8d95a4"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:942a5d73f739ad7c452bf739a62a0f83e2578afd6b8e5406308731f4ce78b16d"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:460508a4b07364d6abf53acaa0a90b6d370fafde5693ef37602566613a9b0779"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529024ab3a505fed78fe3cc5ddc079464e709f6c892733e3f5842007cec8ac6e"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ca56ebc2c474e8f3d5761debfd9283b8b18c76c4fc0967b74aeafba1f5647f9"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a81e1196f0a5b4167a8dafe3a66aa67c4addac1b22dc47947abd5d5c7a3f24b5"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00b8686694423ddae324cf614e1b9659c2edb754de617703c3d29ff568448df5"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c5681160758d3f6ac5b4fea370495c48aac0989d6a0f01bb9a72ad8ef5ab75c4"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:2dc191e60425ad70e75a68c9fd90ab284df64d9cd410ba8d2b641c0c45bc006e"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:67f779374c6b9753ae0a0195a892a1c234ce8416e4448fe1e9f34746482070a7"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:79d5bfa9c1b455336f52343130b2067164040604e41f6dc4d8313867ed540079"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3d3c30ba1c9b48c68489dc1829a6eede9873f52edca1dda900066542528d6b20"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1af80c6316ae68aded77e91cd9d80648f7dd40406cef73df841aa3c36f6907c8"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4d885698f5019abe0de3d352caf9466d5de2baded00a06ef3f1216c1a58ae78f"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aea53d51859b6c64e7c51d522c03cc2c48b9b5d6172126854cc7f01aa11f52bc"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-win32.whl", hash = "sha256:d90b729fd2732df28130c064aac9bb8aff14ba20baa4aee7bd0795ff1187545f"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1dc4ca99e89c335a7ed47d38964abcb36c5910790f9bd106f2a8fa2ee0b909d2"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:773e27b62920199c6197130632c18fb7ead3257fce1ffb7d286912e56ddb79e0"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ce9c671845de9699904b1e9df95acfe8dfc183f2310f163cdaa91a3535af95de"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9454b8d8200ec99a224df8854786262b1bd6461f4280064c807303c642c05e76"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cccd007d5c95279e529c146d095f1d39ac05139de26c098166c4beb9374b0f4d"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fce1294a0497edb034cb416ad3e77ecc89b313cff7adbee5334e4dc0d11f422"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:24974f774f3a78ac12b95e3a20ef0931795ff04dbb16db81a90c37f589819551"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:497cab4d8254c2a90bf988f162ace2ddbfdd806fce3bda3f581b9d24c852e03c"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e794f698ae4c5084414efea0f5cc9f4ac562ec02d66e1484ff822ef97c2cadff"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2c62891b1ea3094bb12097822b3d44b93fc6c325f2043c4d2736a8ff09e65f60"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:142accb3e4d1edae4b392bd165a9abdee8a3c432a2cca193df995bc3886249c8"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1a42b3a19346e5601d1b8296ff6ef3d76038058f311902edd574461e9c036982"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4291d3c409a17febf817259cb37bc62cb7eb398bcc95c1356947e2871911ae61"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4f5322cf38fe0e21c2d73901abf68e6329dc02a4994e483adbcf92b568a09a54"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0be91891bdb06ebe65122aa6bf3fc94489960cf7e03033c6f83a90863b23c58b"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:15a665ad90054a3d4f397bc40f73948d48e36e4c09f9bcffc7d90c87410e478a"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-win32.whl", hash = "sha256:d5663bc1b471c79f5c833cffbc9b87d7bf13f87e055a5c86c363ccd2348d7e82"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:bcb7a1096b4b6b24ce1ac24d4942ad98f983cd3810f9711bcd0293f43a9d8b9f"},
|
||||
{file = "lxml-5.4.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:7be701c24e7f843e6788353c055d806e8bd8466b52907bafe5d13ec6a6dbaecd"},
|
||||
{file = "lxml-5.4.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb54f7c6bafaa808f27166569b1511fc42701a7713858dddc08afdde9746849e"},
|
||||
{file = "lxml-5.4.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97dac543661e84a284502e0cf8a67b5c711b0ad5fb661d1bd505c02f8cf716d7"},
|
||||
{file = "lxml-5.4.0-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:c70e93fba207106cb16bf852e421c37bbded92acd5964390aad07cb50d60f5cf"},
|
||||
{file = "lxml-5.4.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:9c886b481aefdf818ad44846145f6eaf373a20d200b5ce1a5c8e1bc2d8745410"},
|
||||
{file = "lxml-5.4.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:fa0e294046de09acd6146be0ed6727d1f42ded4ce3ea1e9a19c11b6774eea27c"},
|
||||
{file = "lxml-5.4.0-cp36-cp36m-win32.whl", hash = "sha256:61c7bbf432f09ee44b1ccaa24896d21075e533cd01477966a5ff5a71d88b2f56"},
|
||||
{file = "lxml-5.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:7ce1a171ec325192c6a636b64c94418e71a1964f56d002cc28122fceff0b6121"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:795f61bcaf8770e1b37eec24edf9771b307df3af74d1d6f27d812e15a9ff3872"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29f451a4b614a7b5b6c2e043d7b64a15bd8304d7e767055e8ab68387a8cacf4e"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:891f7f991a68d20c75cb13c5c9142b2a3f9eb161f1f12a9489c82172d1f133c0"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4aa412a82e460571fad592d0f93ce9935a20090029ba08eca05c614f99b0cc92"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:ac7ba71f9561cd7d7b55e1ea5511543c0282e2b6450f122672a2694621d63b7e"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:c5d32f5284012deaccd37da1e2cd42f081feaa76981f0eaa474351b68df813c5"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:ce31158630a6ac85bddd6b830cffd46085ff90498b397bd0a259f59d27a12188"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:31e63621e073e04697c1b2d23fcb89991790eef370ec37ce4d5d469f40924ed6"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-win32.whl", hash = "sha256:be2ba4c3c5b7900246a8f866580700ef0d538f2ca32535e991027bdaba944063"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:09846782b1ef650b321484ad429217f5154da4d6e786636c38e434fa32e94e49"},
|
||||
{file = "lxml-5.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:eaf24066ad0b30917186420d51e2e3edf4b0e2ea68d8cd885b14dc8afdcf6556"},
|
||||
{file = "lxml-5.4.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b31a3a77501d86d8ade128abb01082724c0dfd9524f542f2f07d693c9f1175f"},
|
||||
{file = "lxml-5.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e108352e203c7afd0eb91d782582f00a0b16a948d204d4dec8565024fafeea5"},
|
||||
{file = "lxml-5.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a11a96c3b3f7551c8a8109aa65e8594e551d5a84c76bf950da33d0fb6dfafab7"},
|
||||
{file = "lxml-5.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:ca755eebf0d9e62d6cb013f1261e510317a41bf4650f22963474a663fdfe02aa"},
|
||||
{file = "lxml-5.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:4cd915c0fb1bed47b5e6d6edd424ac25856252f09120e3e8ba5154b6b921860e"},
|
||||
{file = "lxml-5.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:226046e386556a45ebc787871d6d2467b32c37ce76c2680f5c608e25823ffc84"},
|
||||
{file = "lxml-5.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:b108134b9667bcd71236c5a02aad5ddd073e372fb5d48ea74853e009fe38acb6"},
|
||||
{file = "lxml-5.4.0-cp38-cp38-win32.whl", hash = "sha256:1320091caa89805df7dcb9e908add28166113dcd062590668514dbd510798c88"},
|
||||
{file = "lxml-5.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:073eb6dcdf1f587d9b88c8c93528b57eccda40209cf9be549d469b942b41d70b"},
|
||||
{file = "lxml-5.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bda3ea44c39eb74e2488297bb39d47186ed01342f0022c8ff407c250ac3f498e"},
|
||||
{file = "lxml-5.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9ceaf423b50ecfc23ca00b7f50b64baba85fb3fb91c53e2c9d00bc86150c7e40"},
|
||||
{file = "lxml-5.4.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:664cdc733bc87449fe781dbb1f309090966c11cc0c0cd7b84af956a02a8a4729"},
|
||||
{file = "lxml-5.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67ed8a40665b84d161bae3181aa2763beea3747f748bca5874b4af4d75998f87"},
|
||||
{file = "lxml-5.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b4a3bd174cc9cdaa1afbc4620c049038b441d6ba07629d89a83b408e54c35cd"},
|
||||
{file = "lxml-5.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:b0989737a3ba6cf2a16efb857fb0dfa20bc5c542737fddb6d893fde48be45433"},
|
||||
{file = "lxml-5.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:dc0af80267edc68adf85f2a5d9be1cdf062f973db6790c1d065e45025fa26140"},
|
||||
{file = "lxml-5.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:639978bccb04c42677db43c79bdaa23785dc7f9b83bfd87570da8207872f1ce5"},
|
||||
{file = "lxml-5.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5a99d86351f9c15e4a901fc56404b485b1462039db59288b203f8c629260a142"},
|
||||
{file = "lxml-5.4.0-cp39-cp39-win32.whl", hash = "sha256:3e6d5557989cdc3ebb5302bbdc42b439733a841891762ded9514e74f60319ad6"},
|
||||
{file = "lxml-5.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:a8c9b7f16b63e65bbba889acb436a1034a82d34fa09752d754f88d708eca80e1"},
|
||||
{file = "lxml-5.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1b717b00a71b901b4667226bba282dd462c42ccf618ade12f9ba3674e1fabc55"},
|
||||
{file = "lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27a9ded0f0b52098ff89dd4c418325b987feed2ea5cc86e8860b0f844285d740"},
|
||||
{file = "lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b7ce10634113651d6f383aa712a194179dcd496bd8c41e191cec2099fa09de5"},
|
||||
{file = "lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53370c26500d22b45182f98847243efb518d268374a9570409d2e2276232fd37"},
|
||||
{file = "lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c6364038c519dffdbe07e3cf42e6a7f8b90c275d4d1617a69bb59734c1a2d571"},
|
||||
{file = "lxml-5.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b12cb6527599808ada9eb2cd6e0e7d3d8f13fe7bbb01c6311255a15ded4c7ab4"},
|
||||
{file = "lxml-5.4.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5f11a1526ebd0dee85e7b1e39e39a0cc0d9d03fb527f56d8457f6df48a10dc0c"},
|
||||
{file = "lxml-5.4.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48b4afaf38bf79109bb060d9016fad014a9a48fb244e11b94f74ae366a64d252"},
|
||||
{file = "lxml-5.4.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de6f6bb8a7840c7bf216fb83eec4e2f79f7325eca8858167b68708b929ab2172"},
|
||||
{file = "lxml-5.4.0-pp37-pypy37_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5cca36a194a4eb4e2ed6be36923d3cffd03dcdf477515dea687185506583d4c9"},
|
||||
{file = "lxml-5.4.0-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b7c86884ad23d61b025989d99bfdd92a7351de956e01c61307cb87035960bcb1"},
|
||||
{file = "lxml-5.4.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:53d9469ab5460402c19553b56c3648746774ecd0681b1b27ea74d5d8a3ef5590"},
|
||||
{file = "lxml-5.4.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:56dbdbab0551532bb26c19c914848d7251d73edb507c3079d6805fa8bba5b706"},
|
||||
{file = "lxml-5.4.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14479c2ad1cb08b62bb941ba8e0e05938524ee3c3114644df905d2331c76cd57"},
|
||||
{file = "lxml-5.4.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32697d2ea994e0db19c1df9e40275ffe84973e4232b5c274f47e7c1ec9763cdd"},
|
||||
{file = "lxml-5.4.0-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:24f6df5f24fc3385f622c0c9d63fe34604893bc1a5bdbb2dbf5870f85f9a404a"},
|
||||
{file = "lxml-5.4.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:151d6c40bc9db11e960619d2bf2ec5829f0aaffb10b41dcf6ad2ce0f3c0b2325"},
|
||||
{file = "lxml-5.4.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:4025bf2884ac4370a3243c5aa8d66d3cb9e15d3ddd0af2d796eccc5f0244390e"},
|
||||
{file = "lxml-5.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9459e6892f59ecea2e2584ee1058f5d8f629446eab52ba2305ae13a32a059530"},
|
||||
{file = "lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47fb24cc0f052f0576ea382872b3fc7e1f7e3028e53299ea751839418ade92a6"},
|
||||
{file = "lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50441c9de951a153c698b9b99992e806b71c1f36d14b154592580ff4a9d0d877"},
|
||||
{file = "lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ab339536aa798b1e17750733663d272038bf28069761d5be57cb4a9b0137b4f8"},
|
||||
{file = "lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9776af1aad5a4b4a1317242ee2bea51da54b2a7b7b48674be736d463c999f37d"},
|
||||
{file = "lxml-5.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:63e7968ff83da2eb6fdda967483a7a023aa497d85ad8f05c3ad9b1f2e8c84987"},
|
||||
{file = "lxml-5.4.0.tar.gz", hash = "sha256:d12832e1dbea4be280b22fd0ea7c9b87f0d8fc51ba06e92dc62d52f804f78ebd"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:09dd5b7075dc2f7709654a46543ba1ea3c2e217b2ed8fbd413a8a945a0f40f60"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f6ac4ef4d82dff54670227a69c67782ae0b811b5cf6b17954f1e8f7502fc0d1d"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:556e94a63c9b04716f8e4de2abb65775061f846e89331b6c5be79183a24f98ea"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6bf403fbb3b3e348a561a5f4f0b9961835657981c802a1df03653eef8a9074"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dde6131244bba38a17c745836ba190bc753fd73c9291666287fd0a3fa3dcf30"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98fc784c2c1440667aeedf8465bdfe10208acf0ead656a2c68627299f546b315"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:add8cf6ddf9a65116119a28ece0f7886e30af27ba724a7594305f1d1b58a92a1"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cf9d57306d848218f3601fee7601fab1a327c942d56e2e97610583cb4dd74206"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88136950da4d13c318bde414ce10219931937851327f44328f2df4d2c4614067"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cecdd5dfdc87b1fd87dbf81d4b037a544f47f4c744200a67013771682d67686a"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cd312b9692e831d2ffcad61eab31d91d4b4655a962e61de8fb410472cbcd37aa"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5b7328b46d49fc9477d91ae8f6d55340347d827b7734ba3ea33faae0efef1383"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37a58976370f36d9329d118ad0b953c5aeb9119ac9c6a4e258942a225d0573a1"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-win32.whl", hash = "sha256:cea3f4c1af79af13cdb2da0c028111d8f8522d4f22a000c82385535f24e5cf3a"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:3abf332af33a74288675d936fe861fd4344da0dd6622193fbc4f2bfbb35536b5"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:8dadbe5b217ff35b6a8d16610dd710219b59b76d13f0e3f0d9f36786206e4485"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-win32.whl", hash = "sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c"},
|
||||
{file = "lxml-6.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6689e828a94eee4f139408c337bb198e014724bb8a8c26d3cfac49d119ed69a6"},
|
||||
{file = "lxml-6.1.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bdebcc8a75d38c7598dfb2c9ed852d7a9eb4a10d6e2d0764b919b802bf32ac88"},
|
||||
{file = "lxml-6.1.1-cp38-cp38-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8be8ad51249698103d24b0571df35a10990fbe93dd043b6c024172189485f5e3"},
|
||||
{file = "lxml-6.1.1-cp38-cp38-manylinux_2_28_i686.whl", hash = "sha256:76447f65250ed2501ead1a1552f5ce8edff159a86f308348e6a9c4acb5e1f1b4"},
|
||||
{file = "lxml-6.1.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ffecec8eb889b58ba9be5b95fb1cc78e22ea8eedea38e8736a1568fe1979250e"},
|
||||
{file = "lxml-6.1.1-cp38-cp38-win32.whl", hash = "sha256:c674693f055fa2495de12292cb45e9944199d8eaef5a2dec45175c7c61cb73e3"},
|
||||
{file = "lxml-6.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:55b03549819867ea141c0202242c4816c82e52ec36e7e648db9d8da5a3dc3ed6"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c9f79d5325907f13e1be0b3e4dacc1049d1dffc4aeee3c995284bea5fe0fab7d"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:83b6b30eb131da7a75b601f28c5d6971e6ed3e887919bf6b6a1ad3c2df289080"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:441dd227fa0690eb9fc81edabc63cdcefc212bba99b906dcf6e32cc1a9d3e533"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e07c65f443c887bbcf31cc1771d932ecc192a5273943589b3c7572b749f1ffb2"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5bec7d03d78d853597d6107854c2310ce3f761fd218fe9fe91d5101fcf6c2efe"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f76acfb5f68ba982635a53fd985a8044be98a35b43232c2a1ee235ffab3e1dd"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-manylinux_2_28_i686.whl", hash = "sha256:8d43ca737b20e106e4aebc42b2f3ae19f00ba63d7eb731698ee083d72d15646f"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:32ab449a5486f6c758e849bb86710d0e45edc24a04e250c01555f8f5653958f8"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53c909b62a0532183542fed00c5a7218258c56292d409bc789886fe1cb04c438"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:640f97d43d867bcb9c75b3af013b64850756b746cb6bce8ace83b70da3abba9d"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:469e3618338bd7ab5beb412d2439825479fcf0dab99e394ca563dbc4eaf6c834"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:aae97dfdb60715c164419ac2532a76d013c3918a665eb6cb7288098b5f349aaf"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c9a4b821dc7055bf9e05ff5719e18ec501f75c0f0bbfabd573b277559780833d"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-win32.whl", hash = "sha256:639f6c857d91d9be29bd7502348d6736dab168b54b5158cd899abf11684dc186"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:34c2d737beabfe35baada43941ed519251e9a12e779031496bcd5d539fcfd730"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-win_arm64.whl", hash = "sha256:07a4a68e286ee7a1ed7dfb8af83e615757c0ccfe9f18c6b4ea6771388d9ba8c9"},
|
||||
{file = "lxml-6.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e"},
|
||||
{file = "lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004"},
|
||||
{file = "lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e"},
|
||||
{file = "lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2"},
|
||||
{file = "lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf"},
|
||||
{file = "lxml-6.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84"},
|
||||
{file = "lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@ -2634,7 +2659,6 @@ cssselect = ["cssselect (>=0.7)"]
|
||||
html-clean = ["lxml_html_clean"]
|
||||
html5 = ["html5lib"]
|
||||
htmlsoup = ["BeautifulSoup4"]
|
||||
source = ["Cython (>=3.0.11,<3.1.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "lxml-html-clean"
|
||||
@ -5415,6 +5439,21 @@ brotli = ["brotli (==1.0.9) ; os_name != \"nt\" and python_version < \"3\" and p
|
||||
secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress ; python_version == \"2.7\"", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"]
|
||||
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "vadersentiment"
|
||||
version = "3.3.2"
|
||||
description = "VADER Sentiment Analysis. VADER (Valence Aware Dictionary and sEntiment Reasoner) is a lexicon and rule-based sentiment analysis tool that is specifically attuned to sentiments expressed in social media, and works well on texts from other domains."
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "vaderSentiment-3.3.2-py2.py3-none-any.whl", hash = "sha256:3bf1d243b98b1afad575b9f22bc2cb1e212b94ff89ca74f8a23a588d024ea311"},
|
||||
{file = "vaderSentiment-3.3.2.tar.gz", hash = "sha256:5d7c06e027fc8b99238edb0d53d970cf97066ef97654009890b83703849632f9"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
requests = "*"
|
||||
|
||||
[[package]]
|
||||
name = "vine"
|
||||
version = "5.1.0"
|
||||
@ -5980,5 +6019,5 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.11,<3.14"
|
||||
content-hash = "40ffa724f63298ca37279d7f72dc541f372d02244481c225fa748940da8ab468"
|
||||
python-versions = ">=3.11,<3.15"
|
||||
content-hash = "78ba52d0e6ea492efceb14fcd42ace25abfb66d42c3aff28f2fe1a31a9aa03b5"
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "0.16.1"
|
||||
version = "46.0"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.11,<3.14"
|
||||
python = ">=3.11,<3.15"
|
||||
Django = "^4.0.3"
|
||||
django-extensions = "^3.1.5"
|
||||
python-dateutil = "^2.8.2"
|
||||
@ -59,6 +59,10 @@ feedparser = "^6.0.12"
|
||||
titlecase = "^2.4.1"
|
||||
bgg-api = "^1.1.13"
|
||||
recipe-scrapers = "^15.11.0"
|
||||
gpxpy = "^1.6.2"
|
||||
fitparse = "^1.2.0"
|
||||
lxml = ">=5.5.0"
|
||||
vaderSentiment = "^3.3.2"
|
||||
|
||||
[tool.poetry.group.test]
|
||||
optional = true
|
||||
@ -104,6 +108,8 @@ exclude_dirs = ["*/tests/*", "*/migrations/*"]
|
||||
[tool.poetry.scripts]
|
||||
vrobbler = "vrobbler.cli:main"
|
||||
|
||||
[tool.poetry_bumpversion.file."vrobbler/__init__.py"]
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
217
scripts/release.py
Executable file
217
scripts/release.py
Executable file
@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cut a new release: collect DONE items from Backlog into a new Version section.
|
||||
|
||||
Usage:
|
||||
poetry run python scripts/release.py major
|
||||
poetry run python scripts/release.py minor
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_FILE = Path("PROJECT.org")
|
||||
PYPROJECT_FILE = Path("pyproject.toml")
|
||||
|
||||
BACKLOG_RE = re.compile(r"^\* Backlog\s+\[(\d+)/(\d+)\](.*)$")
|
||||
VERSION_RE = re.compile(r"^\* Version\s+(\d+\.\d+)\s+\[\d+/\d+\]")
|
||||
DONE_HEADER_RE = re.compile(r"^(\*\* DONE\s+)(.*)$")
|
||||
ITEM_HEADER_RE = re.compile(r"^\*\* ")
|
||||
|
||||
|
||||
def parse_done_line(line):
|
||||
"""Extract a clean title from a ** DONE line, stripping priority and tags."""
|
||||
rest = line[8:].strip() # remove "** DONE "
|
||||
# strip priority marker like [#A]
|
||||
rest = re.sub(r"^\[#[A-C]\]\s+", "", rest, count=1)
|
||||
# strip org-mode tags at end (space-colon-tags)
|
||||
rest = re.sub(r"\s+:\S.*:\s*$", "", rest)
|
||||
return rest
|
||||
|
||||
|
||||
def bump_version(current_major, current_minor, kind):
|
||||
if kind == "major":
|
||||
return current_major + 1, 0
|
||||
elif kind == "minor":
|
||||
return current_major, current_minor + 1
|
||||
else:
|
||||
raise ValueError(f"Unknown bump kind: {kind}")
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in ("major", "minor"):
|
||||
print(f"Usage: {sys.argv[0]} <major|minor>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
kind = sys.argv[1]
|
||||
|
||||
lines = PROJECT_FILE.read_text().splitlines(keepends=True)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 1. Identify top-level sections
|
||||
# ---------------------------------------------------------------
|
||||
section_starts = []
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("* ") and not line.startswith("** "):
|
||||
section_starts.append(i)
|
||||
section_starts.append(len(lines))
|
||||
|
||||
backlog_idx = None
|
||||
version_idx = None
|
||||
|
||||
for idx, start in enumerate(section_starts[:-1]):
|
||||
header = lines[start].strip()
|
||||
if header.startswith("* Backlog"):
|
||||
backlog_idx = idx
|
||||
if header.startswith("* Version"):
|
||||
version_idx = idx # last occurrence wins
|
||||
|
||||
if backlog_idx is None:
|
||||
print("ERROR: no Backlog section found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if version_idx is None:
|
||||
print("ERROR: no Version section found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
backlog_start = section_starts[backlog_idx]
|
||||
backlog_end = section_starts[backlog_idx + 1]
|
||||
|
||||
# Find the newest Version section (first after Backlog) that matches
|
||||
# our expected format (e.g. "37.0" not "0.11.4").
|
||||
version_start = None
|
||||
for idx in range(backlog_idx + 1, version_idx + 1):
|
||||
header = lines[section_starts[idx]].strip()
|
||||
if VERSION_RE.match(header):
|
||||
version_start = section_starts[idx]
|
||||
break
|
||||
|
||||
if version_start is None:
|
||||
print("ERROR: no parseable Version header found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
version_header = lines[version_start].strip()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 2. Parse current version from the newest * Version header
|
||||
# ---------------------------------------------------------------
|
||||
vm = VERSION_RE.match(version_header)
|
||||
current_version = vm.group(1)
|
||||
major_str, minor_str = current_version.split(".")
|
||||
current_major = int(major_str)
|
||||
current_minor = int(minor_str)
|
||||
new_major, new_minor = bump_version(current_major, current_minor, kind)
|
||||
new_version = f"{new_major}.{new_minor}"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 3. Collect ** DONE items from the Backlog section
|
||||
# ---------------------------------------------------------------
|
||||
backlog_lines = lines[backlog_start:backlog_end]
|
||||
|
||||
# Split Backlog into items at each ** line (skip the section header)
|
||||
items = [] # list of (start_idx, end_idx, is_done)
|
||||
item_start = None
|
||||
for i in range(1, len(backlog_lines)):
|
||||
if ITEM_HEADER_RE.match(backlog_lines[i]):
|
||||
if item_start is not None:
|
||||
items.append((item_start, i, backlog_lines[item_start].startswith("** DONE")))
|
||||
item_start = i
|
||||
if item_start is not None:
|
||||
items.append((item_start, len(backlog_lines), backlog_lines[item_start].startswith("** DONE")))
|
||||
|
||||
done_items = [(s, e) for s, e, is_done in items if is_done]
|
||||
kept_items = [(s, e) for s, e, is_done in items if not is_done]
|
||||
|
||||
if not done_items:
|
||||
print("No DONE items found in Backlog — nothing to release.")
|
||||
sys.exit(1)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 4. Build the new Version section text
|
||||
# ---------------------------------------------------------------
|
||||
version_section_lines = [f"* Version {new_version} [{len(done_items)}/{len(done_items)}]\n"]
|
||||
for s, e in done_items:
|
||||
version_section_lines.extend(backlog_lines[s:e])
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 5. Build updated Backlog section
|
||||
# ---------------------------------------------------------------
|
||||
backlog_header_line = backlog_lines[0]
|
||||
bm = BACKLOG_RE.match(backlog_header_line.strip())
|
||||
if not bm:
|
||||
print(f"ERROR: could not parse backlog header: {backlog_header_line!r}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
done_count = int(bm.group(1))
|
||||
total_count = int(bm.group(2))
|
||||
tags = bm.group(3)
|
||||
|
||||
new_done = done_count - len(done_items)
|
||||
new_total = total_count - len(done_items)
|
||||
new_backlog_header = f"* Backlog [{new_done}/{new_total}]{tags}\n"
|
||||
|
||||
backlog_body = []
|
||||
for s, e in kept_items:
|
||||
backlog_body.extend(backlog_lines[s:e])
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 6. Assemble the new file
|
||||
# ---------------------------------------------------------------
|
||||
before_backlog = lines[:backlog_start]
|
||||
after_backlog = lines[backlog_end:version_start]
|
||||
|
||||
# Everything from the first Version section onwards
|
||||
from_version = lines[version_start:]
|
||||
|
||||
output = (
|
||||
before_backlog
|
||||
+ [new_backlog_header]
|
||||
+ backlog_body
|
||||
+ version_section_lines
|
||||
+ ["\n"]
|
||||
+ after_backlog
|
||||
+ from_version
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 7. Update pyproject.toml
|
||||
# ---------------------------------------------------------------
|
||||
pyproject = PYPROJECT_FILE.read_text()
|
||||
pyproject = re.sub(
|
||||
r'^version = "[\d.]+"',
|
||||
f'version = "{new_version}"',
|
||||
pyproject,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 8. Write files
|
||||
# ---------------------------------------------------------------
|
||||
PROJECT_FILE.write_text("".join(output))
|
||||
PYPROJECT_FILE.write_text(pyproject)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 9. Build commit body from done item titles
|
||||
# ---------------------------------------------------------------
|
||||
commit_lines = []
|
||||
for s, e in done_items:
|
||||
title = parse_done_line(backlog_lines[s])
|
||||
if title:
|
||||
commit_lines.append(f"- {title}")
|
||||
|
||||
commit_body = "\n".join(commit_lines)
|
||||
commit_message = f"[release] Bump to version {new_version}\n\n{commit_body}"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 10. Git commit + tag
|
||||
# ---------------------------------------------------------------
|
||||
subprocess.run(["git", "add", str(PROJECT_FILE), str(PYPROJECT_FILE)], check=True)
|
||||
subprocess.run(["git", "commit", "-m", commit_message], check=True)
|
||||
subprocess.run(["git", "tag", new_version], check=True)
|
||||
|
||||
print(f"\nReleased v{new_version} — tag {new_version} created.")
|
||||
print(f"Moved {len(done_items)} DONE item(s) from Backlog to Version section.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,7 +1,7 @@
|
||||
from birds.models import BirdingCSVImport
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
from scrobbles.models import EBirdCSVImport
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@ -35,8 +35,8 @@ class TestBirdingCSVImportViews:
|
||||
user = User.objects.create(email="birder@example.com")
|
||||
client = Client()
|
||||
client.force_login(user)
|
||||
imp = BirdingCSVImport.objects.create(user=user)
|
||||
imp = EBirdCSVImport.objects.create(user=user)
|
||||
response = client.get(
|
||||
reverse("birds:csv_import_detail", kwargs={"slug": imp.uuid})
|
||||
reverse("scrobbles:ebird-csv-import-detail", kwargs={"slug": imp.uuid})
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@ -143,9 +143,10 @@ def test_mopidy_track_webhook_creates_track_and_scrobble(
|
||||
album = Album.objects.create(name="Sublime", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Same in the End",
|
||||
artist=artist,
|
||||
artist_fk=artist,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.artists.add(artist)
|
||||
track.albums.add(album)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
@ -181,9 +182,10 @@ def test_jellyfin_track_webhook_creates_track_and_scrobble(
|
||||
album = Album.objects.create(name="Emotion", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Emotion",
|
||||
artist=artist,
|
||||
artist_fk=artist,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.artists.add(artist)
|
||||
track.albums.add(album)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
@ -225,9 +227,10 @@ def test_mopidy_track_webhook_stores_raw_data(
|
||||
album = Album.objects.create(name="Sublime", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Same in the End",
|
||||
artist=artist,
|
||||
artist_fk=artist,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.artists.add(artist)
|
||||
track.albums.add(album)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
@ -265,10 +268,11 @@ def test_mopidy_track_webhook_stores_album_id(
|
||||
album = Album.objects.create(name="Sublime", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Same in the End",
|
||||
artist=artist,
|
||||
artist_fk=artist,
|
||||
album=album,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.artists.add(artist)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
mock_track_fc.return_value = track
|
||||
@ -303,9 +307,10 @@ def test_jellyfin_track_webhook_stores_raw_data(
|
||||
album = Album.objects.create(name="Emotion", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Emotion",
|
||||
artist=artist,
|
||||
artist_fk=artist,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.artists.add(artist)
|
||||
track.albums.add(album)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
@ -347,11 +352,11 @@ def test_jellyfin_track_webhook_stores_album_id(
|
||||
album = Album.objects.create(name="Emotion", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Emotion",
|
||||
artist=artist,
|
||||
artist_fk=artist,
|
||||
album=album,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.albums.add(album)
|
||||
track.artists.add(artist)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
mock_track_fc.return_value = track
|
||||
@ -602,7 +607,8 @@ def test_scrobble_detail_view_post_updates_log(client):
|
||||
|
||||
scrobble.refresh_from_db()
|
||||
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")
|
||||
|
||||
0
tests/trails_tests/__init__.py
Normal file
0
tests/trails_tests/__init__.py
Normal file
281
tests/trails_tests/test_gpx_importer.py
Normal file
281
tests/trails_tests/test_gpx_importer.py
Normal file
@ -0,0 +1,281 @@
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.files import File
|
||||
|
||||
from locations.models import GeoLocation
|
||||
from scrobbles.importers.trail_gpx import (
|
||||
compute_trail_stats,
|
||||
find_route_waypoint,
|
||||
import_trail_gpx,
|
||||
parse_trackpoints,
|
||||
)
|
||||
from scrobbles.models import Scrobble, TrailGPXImport
|
||||
from trails.models import Trail, TrailLogData
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
SAMPLE_GPX = os.path.join(
|
||||
os.path.dirname(__file__), "..", "..", "data", "sample_trail.gpx"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(db):
|
||||
return User.objects.create(email="trailblazer@example.com")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_gpx_path():
|
||||
return SAMPLE_GPX
|
||||
|
||||
|
||||
class TestParseTrackpoints:
|
||||
def test_parses_gpx(self, sample_gpx_path):
|
||||
result = parse_trackpoints(sample_gpx_path)
|
||||
points = result["points"]
|
||||
assert len(points) == 837
|
||||
assert result["name"] == "Morning Run ⛅"
|
||||
assert result["description"] == "Run"
|
||||
lat, lon, ele, t = points[0]
|
||||
assert round(lat, 6) == 34.190598
|
||||
assert round(lon, 6) == -118.844015
|
||||
assert ele == 305.3
|
||||
assert t is not None
|
||||
|
||||
def test_first_and_last_times(self, sample_gpx_path):
|
||||
result = parse_trackpoints(sample_gpx_path)
|
||||
points = result["points"]
|
||||
first_time = points[0][3]
|
||||
last_time = points[-1][3]
|
||||
duration = (last_time - first_time).total_seconds()
|
||||
assert duration == pytest.approx(3770, abs=5)
|
||||
|
||||
def test_gpx_extra_metadata(self, sample_gpx_path):
|
||||
result = parse_trackpoints(sample_gpx_path)
|
||||
extra = result["extra"]
|
||||
assert extra["avg_heartrate"] == 159
|
||||
assert extra["max_heartrate"] == 183
|
||||
assert extra["avg_speed_kmh"] == pytest.approx(9.82, abs=0.1)
|
||||
assert extra["activity_type"] == "Run"
|
||||
assert extra["moving_time_seconds"] == 3008
|
||||
assert extra["total_elevation_gain_m"] == 246.4
|
||||
|
||||
|
||||
class TestImportTrailGPX:
|
||||
def test_creates_trail(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
assert Trail.objects.filter(title="Morning Run ⛅").exists()
|
||||
|
||||
def test_creates_geolocation(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
assert GeoLocation.objects.filter(lat=34.190598, lon=-118.844015).exists()
|
||||
|
||||
def test_sets_trailhead(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
trail = Trail.objects.filter(title="Morning Run ⛅").first()
|
||||
assert trail.trailhead_location is not None
|
||||
assert round(trail.trailhead_location.lat, 6) == 34.190598
|
||||
|
||||
def test_creates_scrobble(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
assert Scrobble.objects.filter(source="GPX Import").count() == 1
|
||||
|
||||
def test_scrobble_timestamps(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.timestamp.isoformat().startswith("2022-06-05T13:55:09")
|
||||
assert scrobble.stop_timestamp.isoformat().startswith("2022-06-05T14:57:59")
|
||||
assert scrobble.media_type == Scrobble.MediaType.TRAIL
|
||||
|
||||
def test_scrobble_has_trail_fk(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.trail is not None
|
||||
assert scrobble.trail.title == "Morning Run ⛅"
|
||||
|
||||
def test_scrobble_has_gpx_file(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.gpx_file
|
||||
assert scrobble.gpx_file.name.endswith(".gpx")
|
||||
|
||||
def test_lookup_existing_trail_by_trailhead(self, user, sample_gpx_path):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
trail = Trail.objects.create(title="Existing Trail", trailhead_location=geo)
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.trail.id == trail.id
|
||||
|
||||
def test_dedup(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
assert Scrobble.objects.filter(source="GPX Import").count() == 1
|
||||
|
||||
def test_scrobble_log_has_stats(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
log = scrobble.log
|
||||
assert log["distance_km"] == pytest.approx(8.2, abs=0.2)
|
||||
assert log["elevation_gain_m"] == pytest.approx(260, abs=20)
|
||||
assert log["moving_time_seconds"] == pytest.approx(3770, abs=10)
|
||||
assert log["avg_speed_kmh"] == pytest.approx(7.8, abs=0.5)
|
||||
assert log["description"] == "Run"
|
||||
|
||||
def test_scrobble_playback_position(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.playback_position_seconds == pytest.approx(3770, abs=5)
|
||||
|
||||
def test_scrobble_has_timezone(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.timezone is not None
|
||||
assert isinstance(scrobble.timezone, str)
|
||||
|
||||
def test_scrobble_log_extra_metadata(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
log = scrobble.log
|
||||
assert log["avg_heartrate"] == 159
|
||||
assert log["max_heartrate"] == 183
|
||||
assert log["activity_type"] == "Run"
|
||||
|
||||
def test_scrobble_log_no_calories_in_gpx(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.log.get("calories") is None
|
||||
|
||||
|
||||
class TestComputeTrailStats:
|
||||
def test_computes_distance_and_elevation(self, sample_gpx_path):
|
||||
result = parse_trackpoints(sample_gpx_path)
|
||||
stats = compute_trail_stats(result["points"])
|
||||
assert stats["distance_km"] == pytest.approx(8.2, abs=0.2)
|
||||
assert stats["elevation_gain_m"] == pytest.approx(260, abs=20)
|
||||
assert stats["moving_time_seconds"] == pytest.approx(3770, abs=10)
|
||||
assert stats["avg_speed_kmh"] == pytest.approx(7.8, abs=0.5)
|
||||
|
||||
|
||||
class TestTrailGPXImportModel:
|
||||
def test_create_import_model(self, db, user, sample_gpx_path):
|
||||
imp = TrailGPXImport.objects.create(
|
||||
user=user,
|
||||
original_filename="test_trail.gpx",
|
||||
)
|
||||
assert imp.uuid is not None
|
||||
assert imp.import_type == "Trail GPX"
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_process_via_model(self, user, sample_gpx_path):
|
||||
imp = TrailGPXImport.objects.create(
|
||||
user=user,
|
||||
original_filename="Morning Run.gpx",
|
||||
)
|
||||
with open(sample_gpx_path, "rb") as f:
|
||||
imp.gpx_file.save("Morning Run.gpx", File(f), save=True)
|
||||
imp.process()
|
||||
imp.refresh_from_db()
|
||||
assert imp.process_count == 1
|
||||
assert imp.processed_finished is not None
|
||||
|
||||
|
||||
class TestFindRouteWaypoint:
|
||||
def test_returns_halfway_point(self, sample_gpx_path):
|
||||
result = parse_trackpoints(sample_gpx_path)
|
||||
pt = find_route_waypoint(result["points"])
|
||||
assert pt is not None
|
||||
lat, lon = pt
|
||||
assert lat == pytest.approx(34.177853, abs=0.001)
|
||||
assert lon == pytest.approx(-118.829944, abs=0.001)
|
||||
|
||||
def test_returns_last_point_for_short_track(self):
|
||||
points = [(34.0, -118.0, None, None), (34.001, -118.001, None, None)]
|
||||
pt = find_route_waypoint(points)
|
||||
assert pt == (34.001, -118.001)
|
||||
|
||||
def test_returns_none_for_empty_points(self):
|
||||
assert find_route_waypoint([]) is None
|
||||
|
||||
|
||||
class TestFindByTrailhead:
|
||||
def test_exact_match(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
trail = Trail.objects.create(title="Test Trail", trailhead_location=geo)
|
||||
found = Trail.find_by_trailhead(34.190598, -118.844015)
|
||||
assert found == trail
|
||||
|
||||
def test_within_tolerance(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
trail = Trail.objects.create(title="Nearby Trail", trailhead_location=geo)
|
||||
found = Trail.find_by_trailhead(34.191000, -118.844000, tolerance_m=100)
|
||||
assert found == trail
|
||||
|
||||
def test_beyond_tolerance(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
Trail.objects.create(title="Far Trail", trailhead_location=geo)
|
||||
found = Trail.find_by_trailhead(34.200000, -118.850000, tolerance_m=50)
|
||||
assert found is None
|
||||
|
||||
def test_no_trailhead_returns_none(self, db):
|
||||
Trail.objects.create(title="No Location")
|
||||
found = Trail.find_by_trailhead(34.190598, -118.844015)
|
||||
assert found is None
|
||||
|
||||
def test_same_trailhead_same_route_matches(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
trail = Trail.objects.create(
|
||||
title="Same Route Trail",
|
||||
trailhead_location=geo,
|
||||
route_lat=34.192167,
|
||||
route_lon=-118.843143,
|
||||
)
|
||||
found = Trail.find_by_trailhead(
|
||||
34.190598, -118.844015,
|
||||
route_lat=34.192167, route_lon=-118.843143,
|
||||
tolerance_m=100,
|
||||
)
|
||||
assert found == trail
|
||||
|
||||
def test_same_trailhead_different_route_does_not_match(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
Trail.objects.create(
|
||||
title="Different Route Trail",
|
||||
trailhead_location=geo,
|
||||
route_lat=34.200000,
|
||||
route_lon=-118.850000,
|
||||
)
|
||||
found = Trail.find_by_trailhead(
|
||||
34.190598, -118.844015,
|
||||
route_lat=34.192167, route_lon=-118.843143,
|
||||
tolerance_m=100,
|
||||
)
|
||||
assert found is None
|
||||
|
||||
def test_legacy_trail_without_route_still_matches(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
trail = Trail.objects.create(
|
||||
title="Legacy Trail",
|
||||
trailhead_location=geo,
|
||||
)
|
||||
found = Trail.find_by_trailhead(
|
||||
34.190598, -118.844015,
|
||||
route_lat=34.192167, route_lon=-118.843143,
|
||||
tolerance_m=100,
|
||||
)
|
||||
assert found == trail
|
||||
|
||||
|
||||
class TestFindOrCreate:
|
||||
def test_find_existing(self, db):
|
||||
Trail.objects.create(title="Existing Trail")
|
||||
trail = Trail.find_or_create("Existing Trail")
|
||||
assert trail.title == "Existing Trail"
|
||||
|
||||
def test_create_new(self, db):
|
||||
trail = Trail.find_or_create("New Trail")
|
||||
assert trail.title == "New Trail"
|
||||
assert Trail.objects.count() == 1
|
||||
@ -2,4 +2,5 @@
|
||||
# Django starts so that shared_task will use this app.
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ("celery_app",)
|
||||
__version__ = "42.0"
|
||||
__all__ = ("celery_app", "__version__")
|
||||
|
||||
@ -16,6 +16,7 @@ class BirdingLocationAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("uuid", "title")
|
||||
ordering = ("-created",)
|
||||
raw_id_fields = ("geo_location",)
|
||||
search_fields = ("title",)
|
||||
inlines = [
|
||||
ScrobbleInline,
|
||||
|
||||
@ -3,12 +3,12 @@ import logging
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
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()
|
||||
@ -36,11 +36,11 @@ def parse_coords(location_str):
|
||||
def parse_timestamp(date_str, time_str):
|
||||
try:
|
||||
dt = datetime.strptime(f"{date_str} {time_str}", "%B %d, %Y %I:%M %p")
|
||||
return dt.replace(tzinfo=ZoneInfo("UTC"))
|
||||
return dt
|
||||
except (ValueError, TypeError):
|
||||
try:
|
||||
dt = datetime.strptime(date_str, "%B %d, %Y")
|
||||
return dt.replace(tzinfo=ZoneInfo("UTC"))
|
||||
return dt
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(f"Could not parse date/time: {date_str} {time_str}")
|
||||
return None
|
||||
@ -87,6 +87,8 @@ def import_birding_csv(file_path, user_id):
|
||||
if not timestamp:
|
||||
continue
|
||||
|
||||
timestamp = user.profile.get_timestamp_with_tz(timestamp)
|
||||
|
||||
location_title = (
|
||||
LOCATION_COORDS_RE.sub("", location_str).strip().rstrip(",").strip()
|
||||
)
|
||||
@ -135,11 +137,33 @@ def import_birding_csv(file_path, user_id):
|
||||
|
||||
log_dict = logdata.asdict
|
||||
|
||||
weather_loc = location.geo_location
|
||||
if not weather_loc:
|
||||
last_loc = (
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
media_type=Scrobble.MediaType.GEO_LOCATION,
|
||||
geo_location__isnull=False,
|
||||
)
|
||||
.order_by("-timestamp")
|
||||
.first()
|
||||
)
|
||||
if last_loc:
|
||||
weather_loc = last_loc.geo_location
|
||||
if weather_loc:
|
||||
weather = weather_loc.current_weather
|
||||
if weather:
|
||||
log_dict["weather"] = weather["description"]
|
||||
log_dict["temperature"] = weather["temp"]
|
||||
|
||||
stop_timestamp = timestamp + timedelta(minutes=duration_minutes) if duration_minutes else None
|
||||
|
||||
tz = getattr(timestamp.tzinfo, "name", None)
|
||||
|
||||
scrobble = Scrobble(
|
||||
user=user,
|
||||
timestamp=timestamp,
|
||||
timezone=tz,
|
||||
stop_timestamp=stop_timestamp,
|
||||
source="Birding CSV Import",
|
||||
birding_location=location,
|
||||
@ -160,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
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
from birds.models import Bird, BirdingCSVImport, BirdingLocation
|
||||
from birds.models import Bird, BirdingLocation
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse_lazy
|
||||
from django.views import generic
|
||||
from scrobbles.models import EBirdCSVImport as BirdingCSVImport
|
||||
from scrobbles.views import (
|
||||
ScrobbleableDetailView,
|
||||
ScrobbleableListView,
|
||||
@ -40,6 +41,9 @@ class BirdingCSVImportCreateView(
|
||||
def form_valid(self, form):
|
||||
self.object = form.save(commit=False)
|
||||
self.object.user = self.request.user
|
||||
self.object.original_filename = (
|
||||
form.cleaned_data["csv_file"].name
|
||||
)
|
||||
self.object.save()
|
||||
self.object.process()
|
||||
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER"))
|
||||
@ -55,5 +59,5 @@ class BirdingCSVImportDetailView(LoginRequiredMixin, generic.DetailView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context_data = super().get_context_data(**kwargs)
|
||||
context_data["title"] = "Birding CSV Import"
|
||||
context_data["title"] = "eBird CSV Import"
|
||||
return context_data
|
||||
|
||||
@ -90,6 +90,26 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
||||
"variant",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def override_fields(cls) -> dict:
|
||||
from scrobbles.forms import NotesDictField
|
||||
|
||||
fields = {}
|
||||
for base in cls.mro()[1:]:
|
||||
if hasattr(base, "override_fields"):
|
||||
base_fields = base.override_fields()
|
||||
fields.update(base_fields)
|
||||
custom_fields = {
|
||||
"notes": NotesDictField(required=False),
|
||||
"location_id": forms.ModelChoiceField(
|
||||
queryset=BoardGameLocation.objects.all(),
|
||||
required=False,
|
||||
widget=forms.Select(),
|
||||
),
|
||||
}
|
||||
fields.update(custom_fields)
|
||||
return fields
|
||||
|
||||
@cached_property
|
||||
def location(self):
|
||||
if not self.location_id:
|
||||
@ -133,23 +153,6 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
||||
|
||||
return "".join(html_parts)
|
||||
|
||||
@classmethod
|
||||
def override_fields(cls) -> dict:
|
||||
fields = {}
|
||||
for base in cls.mro()[1:]:
|
||||
if hasattr(base, "override_fields"):
|
||||
base_fields = base.override_fields()
|
||||
fields.update(base_fields)
|
||||
custom_fields = {
|
||||
"location_id": forms.ModelChoiceField(
|
||||
queryset=BoardGameLocation.objects.all(),
|
||||
required=False,
|
||||
widget=forms.Select(),
|
||||
)
|
||||
}
|
||||
fields.update(custom_fields)
|
||||
return fields
|
||||
|
||||
|
||||
class BoardGamePublisher(TimeStampedModel):
|
||||
name = models.CharField(max_length=255)
|
||||
|
||||
@ -113,6 +113,8 @@ def import_chess_games_for_all_users():
|
||||
scrobbles_to_create = []
|
||||
for user in User.objects.filter(profile__lichess_username__isnull=False):
|
||||
scrobble_dict = import_chess_games_for_user_id(user.id)
|
||||
if not scrobble_dict:
|
||||
continue
|
||||
scrobbles_to_create.append(Scrobble(**scrobble_dict))
|
||||
|
||||
if scrobbles_to_create:
|
||||
|
||||
@ -179,6 +179,13 @@ class Book(LongPlayScrobblableMixin):
|
||||
return f"{self.title} - Volume {self.volume_number}"
|
||||
return f"{self.title}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.pages:
|
||||
self.base_run_time_seconds = int(self.pages) * int(
|
||||
self.AVG_PAGE_READING_SECONDS
|
||||
)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def subtitle(self):
|
||||
return f" by {self.author}"
|
||||
@ -201,6 +208,10 @@ class Book(LongPlayScrobblableMixin):
|
||||
def get_absolute_url(self):
|
||||
return reverse("books:book_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
@property
|
||||
def resume_start_url(self):
|
||||
return reverse("scrobbles:start", kwargs={"uuid": self.uuid}) + "?resume=1"
|
||||
|
||||
@classmethod
|
||||
def get_from_comicvine(
|
||||
cls, title: str, overwrite: bool = False, force_new: bool = False
|
||||
@ -413,11 +424,6 @@ class Book(LongPlayScrobblableMixin):
|
||||
fname = f"{self.title}_{self.uuid}.jpg"
|
||||
self.cover.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
if self.pages:
|
||||
self.base_run_time_seconds = int(self.pages) * int(
|
||||
self.AVG_PAGE_READING_SECONDS
|
||||
)
|
||||
|
||||
self.save()
|
||||
|
||||
def fix_authors_metadata(self, openlibrary_author_id):
|
||||
|
||||
@ -110,14 +110,13 @@ class ChartRecord(TimeStampedModel):
|
||||
|
||||
@property
|
||||
def period_str(self) -> str:
|
||||
period = str(self.year)
|
||||
if self.month:
|
||||
period = f"{calendar.month_name[self.month]} {period}"
|
||||
if self.week:
|
||||
period = f"Week {self.week}, {period}"
|
||||
if self.day:
|
||||
period = f"{calendar.month_name[self.month]} {self.day}, {period}"
|
||||
return period
|
||||
return f"{calendar.month_name[self.month]} {self.day}, {self.year}"
|
||||
if self.week:
|
||||
return f"Week {self.week}, {self.year}"
|
||||
if self.month:
|
||||
return f"{calendar.month_name[self.month]} {self.year}"
|
||||
return str(self.year)
|
||||
|
||||
@property
|
||||
def period_type(self) -> str:
|
||||
|
||||
@ -83,6 +83,12 @@
|
||||
{% endfor %}
|
||||
<a href="#" class="btn btn-xs btn-outline-secondary" style="padding:1px 3px;font-size:9px;" onclick="var w=prompt('Week number (1-52):');if(w)this.href='/charts/?date={{ year }}-W'+w.padStart(2,'0');return false;">+</a>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<a href="{% url 'charts:spotify-tracks' %}" class="btn btn-sm btn-outline-secondary">🎵 Spotify Tracks</a>
|
||||
<a href="{% url 'charts:bandcamp-tracks' %}" class="btn btn-sm btn-outline-secondary">🎵 Bandcamp Tracks</a>
|
||||
</div>
|
||||
</div>
|
||||
{% if chart_type == "maloja" %}
|
||||
{% include "scrobbles/_top_charts.html" %}
|
||||
{% else %}
|
||||
@ -337,7 +343,7 @@
|
||||
{% for chart in charts.trail %}
|
||||
<tr>
|
||||
<td>{{chart.rank}}</td>
|
||||
<td><a href="{{chart.trail.get_absolute_url}}">{{chart.trail.name}}</a></td>
|
||||
<td><a href="{{chart.trail.get_absolute_url}}">{{chart.trail.title}}</a></td>
|
||||
<td>{{chart.count}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
from charts.views import ChartRecordView, SpotifyTracksView
|
||||
from charts.views import (
|
||||
BandcampTracksView,
|
||||
BirdsChartView,
|
||||
ChartDetailView,
|
||||
ChartRecordView,
|
||||
SpotifyTracksView,
|
||||
)
|
||||
from django.urls import path
|
||||
|
||||
app_name = "charts"
|
||||
@ -6,4 +12,7 @@ app_name = "charts"
|
||||
urlpatterns = [
|
||||
path("charts/", ChartRecordView.as_view(), name="charts-home"),
|
||||
path("charts/spotify/", SpotifyTracksView.as_view(), name="spotify-tracks"),
|
||||
path("charts/bandcamp/", BandcampTracksView.as_view(), name="bandcamp-tracks"),
|
||||
path("charts/birds/", BirdsChartView.as_view(), name="birds-chart"),
|
||||
path("charts/<slug:media_type>/", ChartDetailView.as_view(), name="chart-detail"),
|
||||
]
|
||||
|
||||
@ -104,8 +104,8 @@ def build_charts(
|
||||
|
||||
media_config = {
|
||||
"artist": {
|
||||
"filter": Q(track__isnull=False) & Q(track__artist__isnull=False),
|
||||
"values": "track__artist",
|
||||
"filter": Q(track__isnull=False) & Q(track__artists__isnull=False),
|
||||
"values": "track__artists",
|
||||
"annotate": Count("id", distinct=True),
|
||||
},
|
||||
"album": {
|
||||
|
||||
@ -3,6 +3,7 @@ from datetime import timedelta
|
||||
|
||||
from charts.models import ChartRecord
|
||||
from django.db.models import Count, Q
|
||||
from django.http import Http404
|
||||
from django.utils import timezone
|
||||
from django.views.generic import TemplateView
|
||||
from profiles.utils import now_user_timezone
|
||||
@ -220,6 +221,7 @@ class ChartRecordView(TemplateView):
|
||||
context["period"] = "current"
|
||||
context["year"] = current_year
|
||||
context["month"] = current_month
|
||||
context["month_name"] = calendar.month_name[current_month]
|
||||
context["week"] = current_week
|
||||
context["day"] = current_day
|
||||
|
||||
@ -300,6 +302,7 @@ class ChartRecordView(TemplateView):
|
||||
context["period"] = "historical"
|
||||
context["year"] = year
|
||||
context["month"] = month
|
||||
context["month_name"] = calendar.month_name[month] if month else None
|
||||
context["week"] = week
|
||||
context["day"] = day
|
||||
|
||||
@ -415,6 +418,20 @@ class ChartRecordView(TemplateView):
|
||||
),
|
||||
}
|
||||
|
||||
bird_data = self.get_bird_chart_data(
|
||||
user,
|
||||
year=context.get("year"),
|
||||
month=context.get("month"),
|
||||
week=context.get("week"),
|
||||
day=context.get("day"),
|
||||
)
|
||||
context["birds_chart"] = bird_data["top_birds"]
|
||||
context["bird_stats"] = {
|
||||
"total_species": bird_data["total_species"],
|
||||
"total_outings": bird_data["total_outings"],
|
||||
"total_individuals": bird_data["total_individuals"],
|
||||
}
|
||||
|
||||
context["chart_years"] = self.get_available_years(user)
|
||||
context["period_type"] = self.get_period_type()
|
||||
context["prev_period"] = self.get_prev_period_url(user)
|
||||
@ -558,17 +575,176 @@ class ChartRecordView(TemplateView):
|
||||
return f"/charts/?date={year + 1}"
|
||||
return None
|
||||
|
||||
def get_bird_chart_data(
|
||||
self, user, year=None, month=None, week=None, day=None, limit=20
|
||||
):
|
||||
from birds.models import Bird
|
||||
|
||||
filters = {
|
||||
"user": user,
|
||||
"media_type": Scrobble.MediaType.BIRDING_LOCATION,
|
||||
}
|
||||
if year:
|
||||
filters["timestamp__year"] = year
|
||||
if month:
|
||||
filters["timestamp__month"] = month
|
||||
if week:
|
||||
filters["timestamp__week"] = week
|
||||
if day:
|
||||
filters["timestamp__day"] = day
|
||||
|
||||
scrobbles = Scrobble.objects.filter(**filters)
|
||||
|
||||
bird_counts = {}
|
||||
outings = 0
|
||||
for scrobble in scrobbles.iterator():
|
||||
outings += 1
|
||||
birds_data = scrobble.log.get("birds", []) if scrobble.log else []
|
||||
for entry in birds_data:
|
||||
bird_id = entry.get("bird_id")
|
||||
quantity = entry.get("quantity", 1)
|
||||
if bird_id:
|
||||
bird_counts[bird_id] = bird_counts.get(bird_id, 0) + quantity
|
||||
|
||||
sorted_birds = sorted(bird_counts.items(), key=lambda x: x[1], reverse=True)[
|
||||
:limit
|
||||
]
|
||||
|
||||
bird_ids = [bid for bid, _ in sorted_birds]
|
||||
birds = Bird.objects.filter(id__in=bird_ids)
|
||||
bird_map = {b.id: b for b in birds}
|
||||
|
||||
top_birds = [
|
||||
{"bird": bird_map[bid], "count": count}
|
||||
for bid, count in sorted_birds
|
||||
if bid in bird_map
|
||||
]
|
||||
|
||||
return {
|
||||
"top_birds": top_birds,
|
||||
"total_outings": outings,
|
||||
"total_species": len(bird_counts),
|
||||
"total_individuals": sum(bird_counts.values()),
|
||||
}
|
||||
|
||||
|
||||
MEDIA_TYPE_LABELS = {
|
||||
"artist": ("🎤", "Top Artists"),
|
||||
"album": ("💿", "Top Albums"),
|
||||
"track": ("🎵", "Top Tracks"),
|
||||
"tv_series": ("📺", "Top TV Series"),
|
||||
"video": ("🎬", "Top Videos"),
|
||||
"podcast": ("🎙️", "Top Podcasts"),
|
||||
"podcast_episode": ("🎙️", "Top Podcast Episodes"),
|
||||
"board_game": ("🎲", "Top Board Games"),
|
||||
"book": ("📚", "Top Books"),
|
||||
"food": ("🍽️", "Top Foods"),
|
||||
"trail": ("🥾", "Top Trails"),
|
||||
"geo_location": ("📍", "Top Locations"),
|
||||
}
|
||||
|
||||
|
||||
class ChartDetailView(TemplateView):
|
||||
template_name = "charts/chart_detail.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
user = self.request.user
|
||||
media_type = kwargs.get("media_type")
|
||||
|
||||
if media_type not in MEDIA_TYPE_FILTERS:
|
||||
raise Http404
|
||||
|
||||
date_param = self.request.GET.get("date")
|
||||
now = timezone.now()
|
||||
if user.is_authenticated:
|
||||
now = now_user_timezone(user.profile)
|
||||
today = now.date()
|
||||
|
||||
year = today.year
|
||||
month = None
|
||||
week = None
|
||||
day = None
|
||||
|
||||
if date_param:
|
||||
parts = date_param.split("-")
|
||||
year = int(parts[0])
|
||||
if len(parts) >= 2 and parts[1].startswith("W"):
|
||||
week = int(parts[1].lstrip("W"))
|
||||
month = None
|
||||
elif len(parts) >= 2:
|
||||
try:
|
||||
month = int(parts[1])
|
||||
except ValueError:
|
||||
pass
|
||||
if len(parts) >= 3:
|
||||
if parts[2].startswith("W"):
|
||||
week = int(parts[2].lstrip("W"))
|
||||
else:
|
||||
day = int(parts[2])
|
||||
|
||||
params = {"user": user}
|
||||
if year:
|
||||
params["year"] = year
|
||||
if month:
|
||||
params["month"] = month
|
||||
if week:
|
||||
params["week"] = week
|
||||
if day:
|
||||
params["day"] = day
|
||||
|
||||
media_filter = MEDIA_TYPE_FILTERS.get(media_type, Q())
|
||||
qs = ChartRecord.objects.filter(media_filter, **params)
|
||||
|
||||
if day is not None:
|
||||
qs = qs.filter(day__isnull=False)
|
||||
elif week is not None:
|
||||
qs = qs.filter(week__isnull=False, day__isnull=True)
|
||||
elif month is not None:
|
||||
qs = qs.filter(month__isnull=False, week__isnull=True, day__isnull=True)
|
||||
else:
|
||||
qs = qs.filter(month__isnull=True, week__isnull=True, day__isnull=True)
|
||||
|
||||
context["page_charts"] = qs.order_by("rank")
|
||||
context["media_type"] = media_type
|
||||
emoji, label = MEDIA_TYPE_LABELS.get(media_type, ("", media_type))
|
||||
context["media_label"] = label
|
||||
context["media_emoji"] = emoji
|
||||
context["year"] = year
|
||||
context["month"] = month
|
||||
context["week"] = week
|
||||
context["day"] = day
|
||||
|
||||
context["chart_years"] = list(
|
||||
ChartRecord.objects.filter(Q(user=user) & media_filter)
|
||||
.values_list("year", flat=True)
|
||||
.distinct()
|
||||
.order_by("-year")
|
||||
)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class SpotifyTracksView(TemplateView):
|
||||
template_name = "charts/spotify_tracks.html"
|
||||
|
||||
def get_spotify_tracks(self, user, limit=50):
|
||||
non_spotify_mopidy_tracks = (
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
source="Mopidy",
|
||||
track__isnull=False,
|
||||
)
|
||||
.exclude(log__mopidy_source="spotify")
|
||||
.values("track")
|
||||
)
|
||||
track_ids = (
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
track__isnull=False,
|
||||
)
|
||||
.filter(Q(source="Last.fm") | Q(log__mopidy_source="spotify"))
|
||||
.exclude(track__in=non_spotify_mopidy_tracks)
|
||||
.values("track")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")[:limit]
|
||||
@ -594,3 +770,144 @@ class SpotifyTracksView(TemplateView):
|
||||
user = self.request.user
|
||||
context["spotify_tracks"] = self.get_spotify_tracks(user)
|
||||
return context
|
||||
|
||||
|
||||
class BandcampTracksView(TemplateView):
|
||||
template_name = "charts/bandcamp_tracks.html"
|
||||
|
||||
def get_bandcamp_tracks(self, user, limit=50):
|
||||
non_bandcamp_mopidy_tracks = (
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
source="Mopidy",
|
||||
track__isnull=False,
|
||||
)
|
||||
.exclude(log__mopidy_source="bandcamp")
|
||||
.values("track")
|
||||
)
|
||||
track_ids = (
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
track__isnull=False,
|
||||
log__mopidy_source="bandcamp",
|
||||
)
|
||||
.exclude(track__in=non_bandcamp_mopidy_tracks)
|
||||
.values("track")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")[:limit]
|
||||
)
|
||||
from music.models import Track
|
||||
|
||||
track_id_list = [item["track"] for item in track_ids]
|
||||
tracks = Track.objects.filter(id__in=track_id_list)
|
||||
track_map = {t.id: t for t in tracks}
|
||||
return [
|
||||
{
|
||||
"track": track_map[tid],
|
||||
"count": next(
|
||||
(item["count"] for item in track_ids if item["track"] == tid), 0
|
||||
),
|
||||
}
|
||||
for tid in track_id_list
|
||||
if tid in track_map
|
||||
]
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
user = self.request.user
|
||||
context["bandcamp_tracks"] = self.get_bandcamp_tracks(user)
|
||||
return context
|
||||
|
||||
|
||||
class BirdsChartView(TemplateView):
|
||||
template_name = "charts/birds_chart.html"
|
||||
|
||||
def get_bird_data(self, user, year=None, month=None, week=None, day=None, limit=50):
|
||||
from birds.models import Bird
|
||||
|
||||
filters = {
|
||||
"user": user,
|
||||
"media_type": Scrobble.MediaType.BIRDING_LOCATION,
|
||||
}
|
||||
if year:
|
||||
filters["timestamp__year"] = year
|
||||
if month:
|
||||
filters["timestamp__month"] = month
|
||||
if week:
|
||||
filters["timestamp__week"] = week
|
||||
if day:
|
||||
filters["timestamp__day"] = day
|
||||
|
||||
scrobbles = Scrobble.objects.filter(**filters)
|
||||
|
||||
bird_counts = {}
|
||||
outings = 0
|
||||
for scrobble in scrobbles.iterator():
|
||||
outings += 1
|
||||
birds_data = scrobble.log.get("birds", []) if scrobble.log else []
|
||||
for entry in birds_data:
|
||||
bird_id = entry.get("bird_id")
|
||||
quantity = entry.get("quantity", 1)
|
||||
if bird_id:
|
||||
bird_counts[bird_id] = bird_counts.get(bird_id, 0) + quantity
|
||||
|
||||
sorted_birds = sorted(bird_counts.items(), key=lambda x: x[1], reverse=True)[
|
||||
:limit
|
||||
]
|
||||
|
||||
bird_ids = [bid for bid, _ in sorted_birds]
|
||||
birds = Bird.objects.filter(id__in=bird_ids)
|
||||
bird_map = {b.id: b for b in birds}
|
||||
|
||||
top_birds = [
|
||||
{"bird": bird_map[bid], "count": count}
|
||||
for bid, count in sorted_birds
|
||||
if bid in bird_map
|
||||
]
|
||||
|
||||
return {
|
||||
"top_birds": top_birds,
|
||||
"total_outings": outings,
|
||||
"total_species": len(bird_counts),
|
||||
"total_individuals": sum(bird_counts.values()),
|
||||
}
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
user = self.request.user
|
||||
|
||||
date_param = self.request.GET.get("date")
|
||||
now = timezone.now()
|
||||
if user.is_authenticated:
|
||||
now = now_user_timezone(user.profile)
|
||||
today = now.date()
|
||||
|
||||
year = None
|
||||
month = None
|
||||
week = None
|
||||
day = None
|
||||
|
||||
if date_param:
|
||||
parts = date_param.split("-")
|
||||
year = int(parts[0])
|
||||
if len(parts) >= 2 and parts[1].startswith("W"):
|
||||
week = int(parts[1].lstrip("W"))
|
||||
elif len(parts) >= 2:
|
||||
try:
|
||||
month = int(parts[1])
|
||||
except ValueError:
|
||||
pass
|
||||
if len(parts) >= 3:
|
||||
if parts[2].startswith("W"):
|
||||
week = int(parts[2].lstrip("W"))
|
||||
else:
|
||||
day = int(parts[2])
|
||||
|
||||
bird_data = self.get_bird_data(user, year=year, month=month, week=week, day=day)
|
||||
context["birds_chart"] = bird_data["top_birds"]
|
||||
context["bird_stats"] = {
|
||||
"total_species": bird_data["total_species"],
|
||||
"total_outings": bird_data["total_outings"],
|
||||
"total_individuals": bird_data["total_individuals"],
|
||||
}
|
||||
return context
|
||||
|
||||
@ -16,6 +16,7 @@ class GeoLocationAdmin(admin.ModelAdmin):
|
||||
"altitude",
|
||||
)
|
||||
ordering = ("-created",)
|
||||
search_fields = ("title",)
|
||||
inlines = [
|
||||
ScrobbleInline,
|
||||
]
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from typing import Dict
|
||||
from typing import Dict, Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
@ -107,6 +107,12 @@ class GeoLocation(ScrobblableMixin):
|
||||
def strings(self) -> ScrobblableConstants:
|
||||
return ScrobblableConstants(verb="Going", tags="world_map", priority="low")
|
||||
|
||||
@property
|
||||
def current_weather(self) -> Optional[dict]:
|
||||
from locations.utils import fetch_current_weather
|
||||
|
||||
return fetch_current_weather(self.lat, self.lon)
|
||||
|
||||
def loc_diff(self, old_lat_lon: tuple) -> tuple:
|
||||
return (
|
||||
abs(Decimal(old_lat_lon[0]) - Decimal(self.lat)),
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
from django.utils import timezone
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
@ -196,3 +199,76 @@ def detect_movement(
|
||||
return result
|
||||
|
||||
return result
|
||||
|
||||
|
||||
NWS_URL = "https://forecast.weather.gov/MapClick.php"
|
||||
|
||||
|
||||
def fetch_current_weather(lat: float, lon: float) -> Optional[dict]:
|
||||
"""Fetch current weather from NWS DWML feed for the given lat/lon.
|
||||
|
||||
Returns dict with 'temp' and 'description' keys, or None on failure.
|
||||
"""
|
||||
params = {
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"unit": 0,
|
||||
"lg": "english",
|
||||
"FcstType": "dwml",
|
||||
}
|
||||
url = f"{NWS_URL}?{urlencode(params)}"
|
||||
|
||||
try:
|
||||
resp = requests.get(url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
except requests.RequestException as e:
|
||||
logger.warning("Failed to fetch NWS weather data: %s", e)
|
||||
return None
|
||||
|
||||
try:
|
||||
root = ET.fromstring(resp.content)
|
||||
except ET.ParseError as e:
|
||||
logger.warning("Failed to parse NWS XML: %s", e)
|
||||
return None
|
||||
|
||||
# Find current observations data block
|
||||
data_blocks = root.findall("data")
|
||||
obs_block = None
|
||||
for block in data_blocks:
|
||||
if block.get("type") == "current observations":
|
||||
obs_block = block
|
||||
break
|
||||
|
||||
if obs_block is None:
|
||||
logger.warning("No current observations block in NWS response")
|
||||
return None
|
||||
|
||||
params_el = obs_block.find("parameters")
|
||||
if params_el is None:
|
||||
return None
|
||||
|
||||
# Temperature (apparent / feels-like)
|
||||
temp = None
|
||||
for temp_el in params_el.findall("temperature"):
|
||||
if temp_el.get("type") == "apparent":
|
||||
val_el = temp_el.find("value")
|
||||
if val_el is not None and val_el.text:
|
||||
try:
|
||||
temp = int(round(float(val_el.text)))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
break
|
||||
|
||||
# Weather description
|
||||
description = None
|
||||
weather_el = params_el.find("weather")
|
||||
if weather_el is not None:
|
||||
cond_el = weather_el.find("weather-conditions")
|
||||
if cond_el is not None:
|
||||
description = cond_el.get("weather-summary")
|
||||
|
||||
if temp is None and description is None:
|
||||
logger.warning("No weather data found in NWS response")
|
||||
return None
|
||||
|
||||
return {"temp": temp, "description": description}
|
||||
|
||||
@ -8,7 +8,7 @@ from scrobbles.views import ChartContextMixin
|
||||
|
||||
class GeoLocationListView(generic.ListView):
|
||||
model = GeoLocation
|
||||
paginate_by = 75
|
||||
paginate_by = 100
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
|
||||
@ -37,6 +37,8 @@ class MoodLogData(BaseLogData):
|
||||
mood_type: Optional[str] = None
|
||||
mood_reason_ids: Optional[list[int]] = None
|
||||
mood_quality: Optional[int] = None
|
||||
weather_temp: Optional[float] = None
|
||||
weather_description: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def override_fields(cls) -> dict:
|
||||
|
||||
@ -66,6 +66,22 @@ def checkin(request):
|
||||
mood_reason_ids=[int(reason_id)] if reason_id else [],
|
||||
)
|
||||
|
||||
# Attempt to add current weather from most recent location
|
||||
last_loc = (
|
||||
Scrobble.objects.filter(
|
||||
user=request.user,
|
||||
media_type=Scrobble.MediaType.GEO_LOCATION,
|
||||
geo_location__isnull=False,
|
||||
)
|
||||
.order_by("-timestamp")
|
||||
.first()
|
||||
)
|
||||
if last_loc and last_loc.geo_location:
|
||||
weather = last_loc.geo_location.current_weather
|
||||
if weather:
|
||||
log_data.weather_temp = weather["temp"]
|
||||
log_data.weather_description = weather["description"]
|
||||
|
||||
scrobble = Scrobble.objects.create(
|
||||
user=request.user,
|
||||
mood=mood,
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from music.models import Artist, Album, Track
|
||||
|
||||
from music.models import Album, Artist, Track
|
||||
from scrobbles.admin import ScrobbleInline
|
||||
|
||||
|
||||
@ -11,7 +9,7 @@ class AlbumAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"name",
|
||||
"year",
|
||||
"album_artist",
|
||||
"artist",
|
||||
"theaudiodb_genre",
|
||||
"theaudiodb_mood",
|
||||
"musicbrainz_id",
|
||||
@ -54,12 +52,12 @@ class TrackAdmin(admin.ModelAdmin):
|
||||
"artist",
|
||||
"musicbrainz_id",
|
||||
)
|
||||
raw_id_fields = ("artist", "albums", "album")
|
||||
list_filter = ("album", "artist")
|
||||
raw_id_fields = ("artist_fk", "artists", "albums", "album")
|
||||
search_fields = ("title",)
|
||||
ordering = ("-created",)
|
||||
filter_horizontal = [
|
||||
"albums",
|
||||
"artists",
|
||||
]
|
||||
inlines = [
|
||||
ScrobbleInline,
|
||||
|
||||
@ -149,7 +149,7 @@ def live_charts(
|
||||
|
||||
|
||||
def artist_scrobble_count(artist_id: int, filter: str = "today") -> int:
|
||||
return Scrobble.objects.filter(track__artist=artist_id).count()
|
||||
return Scrobble.objects.filter(track__artists=artist_id).count()
|
||||
|
||||
|
||||
def live_tv_charts(
|
||||
@ -158,7 +158,7 @@ def live_tv_charts(
|
||||
limit: int = 15,
|
||||
) -> QuerySet:
|
||||
from django.db.models import OuterRef, Subquery
|
||||
from videos.models import Video, Series
|
||||
from videos.models import Series, Video
|
||||
|
||||
now = timezone.now()
|
||||
tzinfo = now.tzinfo
|
||||
@ -215,7 +215,7 @@ def live_youtube_channel_charts(
|
||||
limit: int = 15,
|
||||
) -> QuerySet:
|
||||
from django.db.models import OuterRef, Subquery
|
||||
from videos.models import Video, Channel
|
||||
from videos.models import Channel, Video
|
||||
|
||||
now = timezone.now()
|
||||
tzinfo = now.tzinfo
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
from taggit.serializers import TaggitSerializer, TagListSerializerField
|
||||
|
||||
from music.models import Album, Artist, Track
|
||||
from rest_framework import serializers
|
||||
|
||||
@ -14,7 +16,25 @@ class AlbumSerializer(serializers.HyperlinkedModelSerializer):
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class TrackSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class TrackSerializer(TaggitSerializer, serializers.HyperlinkedModelSerializer):
|
||||
tags = TagListSerializerField()
|
||||
genre = TagListSerializerField()
|
||||
|
||||
class Meta:
|
||||
model = Track
|
||||
fields = "__all__"
|
||||
fields = [
|
||||
"url",
|
||||
"id",
|
||||
"uuid",
|
||||
"title",
|
||||
"artist_fk",
|
||||
"artists",
|
||||
"album",
|
||||
"albums",
|
||||
"musicbrainz_id",
|
||||
"genre",
|
||||
"tags",
|
||||
"base_run_time_seconds",
|
||||
"created",
|
||||
"modified",
|
||||
]
|
||||
|
||||
90
vrobbler/apps/music/listenbrainz.py
Normal file
90
vrobbler/apps/music/listenbrainz.py
Normal file
@ -0,0 +1,90 @@
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LB_LABS_API = "https://labs.api.listenbrainz.org"
|
||||
STANDARD_ALGORITHM = "session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30"
|
||||
MLHD_ALGORITHM = "session_based_mlhd_session_300_contribution_5_threshold_15_limit_50_skip_30"
|
||||
SIMILAR_ARTISTS_ALGORITHM = "session_based_days_1825_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30"
|
||||
|
||||
|
||||
def get_similar_recordings(recording_mbid: str) -> list[dict]:
|
||||
"""Fetch similar recordings from ListenBrainz.
|
||||
|
||||
Tries the standard dataset first, falls back to MLHD dataset.
|
||||
Returns [] on error or no data.
|
||||
"""
|
||||
if not recording_mbid:
|
||||
return []
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{LB_LABS_API}/similar-recordings/json",
|
||||
json=[
|
||||
{
|
||||
"recording_mbids": [recording_mbid],
|
||||
"algorithm": STANDARD_ALGORITHM,
|
||||
}
|
||||
],
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data:
|
||||
return data
|
||||
|
||||
resp = requests.post(
|
||||
f"{LB_LABS_API}/mlhd-similar-recordings/json",
|
||||
json=[
|
||||
{
|
||||
"recording_mbids": [recording_mbid],
|
||||
"algorithm": MLHD_ALGORITHM,
|
||||
}
|
||||
],
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except requests.RequestException as e:
|
||||
logger.warning(
|
||||
"ListenBrainz similar recordings error",
|
||||
extra={
|
||||
"recording_mbid": recording_mbid,
|
||||
"error": str(e),
|
||||
},
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def get_similar_artists(artist_mbid: str) -> list[dict]:
|
||||
"""Fetch similar artists from ListenBrainz.
|
||||
|
||||
Returns [] on error or no data.
|
||||
"""
|
||||
if not artist_mbid:
|
||||
return []
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{LB_LABS_API}/similar-artists/json",
|
||||
json=[
|
||||
{
|
||||
"artist_mbids": [artist_mbid],
|
||||
"algorithm": SIMILAR_ARTISTS_ALGORITHM,
|
||||
}
|
||||
],
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except requests.RequestException as e:
|
||||
logger.warning(
|
||||
"ListenBrainz similar artists error",
|
||||
extra={
|
||||
"artist_mbid": artist_mbid,
|
||||
"error": str(e),
|
||||
},
|
||||
)
|
||||
return []
|
||||
0
vrobbler/apps/music/management/__init__.py
Normal file
0
vrobbler/apps/music/management/__init__.py
Normal file
0
vrobbler/apps/music/management/commands/__init__.py
Normal file
0
vrobbler/apps/music/management/commands/__init__.py
Normal file
@ -0,0 +1,249 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
import musicbrainzngs
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from music.musicbrainz import resolve_track, get_track_metadata_with_artist, extract_featured_artists
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VARIOUS_ARTISTS_NAMES = ["various artists", "va", "various"]
|
||||
|
||||
|
||||
def _is_various_artists(artist_name: str) -> bool:
|
||||
return artist_name.strip().casefold() in VARIOUS_ARTISTS_NAMES
|
||||
|
||||
|
||||
def _get_artist_from_scrobble_logs(track_id: int):
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
scrobbles = Scrobble.objects.filter(track_id=track_id)[:10]
|
||||
for scrobble in scrobbles:
|
||||
raw = scrobble.log.get("raw_data", {})
|
||||
if not raw:
|
||||
continue
|
||||
|
||||
provider_mbid = raw.get("Provider_musicbrainztrack")
|
||||
if provider_mbid:
|
||||
return {"artist_name": raw.get("Artist", ""), "recording_mbid": provider_mbid}
|
||||
|
||||
artist = raw.get("Artist", "")
|
||||
if artist:
|
||||
return {"artist_name": artist, "recording_mbid": None}
|
||||
return None
|
||||
|
||||
|
||||
def _lookup_recording_by_mbid(mbid: str):
|
||||
try:
|
||||
result = musicbrainzngs.get_recording_by_id(mbid, includes=["artists"])
|
||||
recording = result["recording"]
|
||||
length_ms = recording.get("length")
|
||||
artist_credit = recording.get("artist-credit", [{}])
|
||||
primary_artist = artist_credit[0].get("artist", {}) if artist_credit else {}
|
||||
return {
|
||||
"recording_mbid": mbid,
|
||||
"length_ms": int(length_ms) if length_ms else None,
|
||||
"artist_name": primary_artist.get("name", ""),
|
||||
"artist_mbid": primary_artist.get("id", ""),
|
||||
}
|
||||
except (musicbrainzngs.WebServiceError, musicbrainzngs.ResponseError) as e:
|
||||
logger.warning(f"MusicBrainz error looking up {mbid}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _lookup_length_by_mbid(mbid: str):
|
||||
try:
|
||||
result = musicbrainzngs.get_recording_by_id(mbid)
|
||||
recording = result["recording"]
|
||||
length_ms = recording.get("length")
|
||||
if length_ms:
|
||||
return int(length_ms)
|
||||
return None
|
||||
except (musicbrainzngs.WebServiceError, musicbrainzngs.ResponseError) as e:
|
||||
logger.warning(f"MusicBrainz error looking up {mbid}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Clean up metadata on music tracks from MusicBrainz"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Commit changes to the database",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=100,
|
||||
help="Number of tracks to process per batch (default: 100)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--only-missing-mbid",
|
||||
action="store_true",
|
||||
help="Only process tracks missing musicbrainz_id",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--only-missing-length",
|
||||
action="store_true",
|
||||
help="Only process tracks missing base_run_time_seconds",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from music.models import Track
|
||||
|
||||
commit = options["commit"]
|
||||
batch_size = options["batch_size"]
|
||||
|
||||
if not commit:
|
||||
self.stdout.write("Dry run — no changes will be saved. Use --commit to apply.")
|
||||
|
||||
musicbrainzngs.set_useragent("vrobbler", "0.3.0")
|
||||
|
||||
qs = Track.objects.all()
|
||||
|
||||
if options["only_missing_mbid"]:
|
||||
qs = qs.filter(musicbrainz_id__isnull=True)
|
||||
if options["only_missing_length"]:
|
||||
qs = qs.filter(base_run_time_seconds__isnull=True)
|
||||
|
||||
total = qs.count()
|
||||
self.stdout.write(f"Processing {total} tracks")
|
||||
|
||||
missing_mbid_qs = qs.filter(musicbrainz_id__isnull=True)
|
||||
missing_length_qs = qs.filter(
|
||||
musicbrainz_id__isnull=False, base_run_time_seconds__isnull=True
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
f" {missing_mbid_qs.count()} tracks without musicbrainz_id"
|
||||
)
|
||||
self.stdout.write(
|
||||
f" {missing_length_qs.count()} tracks with mbid but no run time"
|
||||
)
|
||||
|
||||
if not commit:
|
||||
self.stdout.write("\nSkipping API lookups in dry-run mode. Use --commit to run against MusicBrainz.")
|
||||
return
|
||||
|
||||
found_count = 0
|
||||
not_found_count = 0
|
||||
length_fixed_count = 0
|
||||
va_fixed_count = 0
|
||||
track_ids = list(missing_mbid_qs.values_list("pk", flat=True))
|
||||
i = 0
|
||||
|
||||
for batch_num, offset in enumerate(range(0, len(track_ids), batch_size)):
|
||||
batch_pks = track_ids[offset : offset + batch_size]
|
||||
with transaction.atomic():
|
||||
for track in Track.objects.filter(pk__in=batch_pks).iterator():
|
||||
i += 1
|
||||
artist_name = ""
|
||||
artist_obj = track.artist
|
||||
if artist_obj:
|
||||
artist_name = artist_obj.name
|
||||
|
||||
is_va = _is_various_artists(artist_name)
|
||||
result = None
|
||||
method = ""
|
||||
|
||||
if is_va:
|
||||
scrobble_info = _get_artist_from_scrobble_logs(track.id)
|
||||
if scrobble_info:
|
||||
if scrobble_info["recording_mbid"]:
|
||||
result = _lookup_recording_by_mbid(
|
||||
scrobble_info["recording_mbid"]
|
||||
)
|
||||
if result:
|
||||
method = "provider"
|
||||
self.stdout.write(
|
||||
f" VA track '{track.title}' matched via provider MBID "
|
||||
f"{scrobble_info['recording_mbid']} (artist: {scrobble_info['artist_name']})"
|
||||
)
|
||||
else:
|
||||
result, method = resolve_track(
|
||||
track.title, scrobble_info["artist_name"]
|
||||
)
|
||||
if result:
|
||||
self.stdout.write(
|
||||
f" VA track '{track.title}' matched via log artist "
|
||||
f"'{scrobble_info['artist_name']}' as {result['recording_mbid']} ({method})"
|
||||
)
|
||||
|
||||
if not result:
|
||||
result, method = resolve_track(track.title)
|
||||
if result:
|
||||
self.stdout.write(
|
||||
f" VA track '{track.title}' matched by title alone "
|
||||
f"as {result['recording_mbid']} ({method})"
|
||||
)
|
||||
|
||||
if not result:
|
||||
result, method = resolve_track(track.title, artist_name)
|
||||
|
||||
if result and result.get("recording_mbid"):
|
||||
track.musicbrainz_id = result["recording_mbid"]
|
||||
length_ms = result.get("length_ms")
|
||||
if length_ms and not track.base_run_time_seconds:
|
||||
track.base_run_time_seconds = int(int(length_ms) / 1000)
|
||||
track.save(update_fields=["musicbrainz_id", "base_run_time_seconds"])
|
||||
method_tag = f"musicbrainz-{method}" if method else "musicbrainz-enriched"
|
||||
track.tags.add(method_tag, "musicbrainz-enriched")
|
||||
from music.models import Artist
|
||||
cleaned_title, featured_names = extract_featured_artists(track.title)
|
||||
for feat_name in featured_names:
|
||||
artist = Artist.find_or_create(feat_name, track_name=track.title)
|
||||
if artist:
|
||||
track.artists.add(artist)
|
||||
self.stdout.write(
|
||||
f" [{i}] FOUND {track} — mbid={track.musicbrainz_id} ({method})"
|
||||
)
|
||||
found_count += 1
|
||||
else:
|
||||
track.tags.add("musicbrainz-notfound")
|
||||
self.stdout.write(
|
||||
f" [{i}] NOTFOUND {track}"
|
||||
)
|
||||
not_found_count += 1
|
||||
|
||||
if is_va and result:
|
||||
va_fixed_count += 1
|
||||
|
||||
self.stdout.write(
|
||||
f" Batch {batch_num + 1}: {offset + len(batch_pks)}/{total} processed, "
|
||||
f"found: {found_count}, not found: {not_found_count}"
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
length_ids = list(missing_length_qs.values_list("pk", flat=True))
|
||||
j = 0
|
||||
for batch_num, offset in enumerate(range(0, len(length_ids), batch_size)):
|
||||
batch_pks = length_ids[offset : offset + batch_size]
|
||||
with transaction.atomic():
|
||||
for track in Track.objects.filter(pk__in=batch_pks).iterator():
|
||||
j += 1
|
||||
length_ms = _lookup_length_by_mbid(track.musicbrainz_id)
|
||||
if length_ms:
|
||||
track.base_run_time_seconds = int(int(length_ms) / 1000)
|
||||
track.tags.add("musicbrainz-enriched")
|
||||
track.save(update_fields=["base_run_time_seconds"])
|
||||
self.stdout.write(
|
||||
f" [{j}] LENGTH {track} — {track.base_run_time_seconds}s"
|
||||
)
|
||||
length_fixed_count += 1
|
||||
|
||||
self.stdout.write(
|
||||
f" Batch {batch_num + 1}: {j}/{len(length_ids)} length lookups"
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
self.stdout.write(
|
||||
f"\nResults (commit={commit}):\n"
|
||||
f" Found on MusicBrainz: {found_count}\n"
|
||||
f" Not found / tagged musicbrainz-notfound: {not_found_count}\n"
|
||||
f" Run times filled from MBID: {length_fixed_count}\n"
|
||||
f" Various Artists tracks resolved: {va_fixed_count}"
|
||||
)
|
||||
@ -0,0 +1,123 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from music.listenbrainz import get_similar_artists
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Fetch similar artists from ListenBrainz for artists with a musicbrainz_id"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Commit changes to the database",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=100,
|
||||
help="Number of artists to process per batch (default: 100)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--artist-id",
|
||||
type=str,
|
||||
default="",
|
||||
help="Only process the artist with this musicbrainz_id",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--overwrite",
|
||||
action="store_true",
|
||||
help="Re-fetch similar artists even if already populated",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from music.models import Artist
|
||||
|
||||
commit = options["commit"]
|
||||
batch_size = options["batch_size"]
|
||||
artist_id = options["artist_id"]
|
||||
overwrite = options["overwrite"]
|
||||
|
||||
if not commit:
|
||||
self.stdout.write(
|
||||
"Dry run — no changes will be saved. Use --commit to apply."
|
||||
)
|
||||
|
||||
qs = Artist.objects.exclude(musicbrainz_id__isnull=True).exclude(
|
||||
musicbrainz_id=""
|
||||
)
|
||||
|
||||
if artist_id:
|
||||
qs = qs.filter(musicbrainz_id=artist_id)
|
||||
self.stdout.write(f"Filtering to artist with musicbrainz_id: {artist_id}")
|
||||
elif not overwrite:
|
||||
qs = qs.filter(similar_artists__isnull=True)
|
||||
else:
|
||||
qs = qs.all()
|
||||
|
||||
total = qs.count()
|
||||
if total == 0:
|
||||
self.stdout.write("No artists to process.")
|
||||
return
|
||||
|
||||
self.stdout.write(
|
||||
f"Found {total} artists with musicbrainz_id"
|
||||
+ (" (overwrite mode)" if overwrite else "")
|
||||
+ (" (--artist-id filter)" if artist_id else "")
|
||||
)
|
||||
|
||||
if not commit:
|
||||
self.stdout.write(
|
||||
"\nSkipping API lookups in dry-run mode. Use --commit to run against ListenBrainz."
|
||||
)
|
||||
return
|
||||
|
||||
found_count = 0
|
||||
empty_count = 0
|
||||
error_count = 0
|
||||
artist_ids = list(qs.values_list("pk", flat=True))
|
||||
i = 0
|
||||
|
||||
for batch_num, offset in enumerate(
|
||||
range(0, len(artist_ids), batch_size)
|
||||
):
|
||||
batch_pks = artist_ids[offset : offset + batch_size]
|
||||
for artist in Artist.objects.filter(pk__in=batch_pks).iterator():
|
||||
i += 1
|
||||
self.stdout.write(
|
||||
f" [{i}/{total}] Fetching similar artists for {artist}...",
|
||||
ending="",
|
||||
)
|
||||
try:
|
||||
similar = get_similar_artists(artist.musicbrainz_id)
|
||||
if similar:
|
||||
artist.similar_artists = similar
|
||||
artist.save(update_fields=["similar_artists"])
|
||||
self.stdout.write(f" {len(similar)} similar artists found")
|
||||
found_count += 1
|
||||
else:
|
||||
artist.similar_artists = []
|
||||
artist.save(update_fields=["similar_artists"])
|
||||
self.stdout.write(" none found")
|
||||
empty_count += 1
|
||||
except Exception as e:
|
||||
self.stdout.write(f" error: {e}")
|
||||
error_count += 1
|
||||
|
||||
self.stdout.write(
|
||||
f" Batch {batch_num + 1}: {offset + len(batch_pks)}/{total} processed, "
|
||||
f"found: {found_count}, empty: {empty_count}, errors: {error_count}"
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
self.stdout.write(
|
||||
f"\nResults (commit={commit}):\n"
|
||||
f" Similar artists found: {found_count}\n"
|
||||
f" No similar artists: {empty_count}\n"
|
||||
f" Errors: {error_count}"
|
||||
)
|
||||
@ -0,0 +1,125 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from music.listenbrainz import get_similar_recordings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Fetch similar recordings from ListenBrainz for tracks with a musicbrainz_id"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Commit changes to the database",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=100,
|
||||
help="Number of tracks to process per batch (default: 100)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--track-id",
|
||||
type=str,
|
||||
default="",
|
||||
help="Only process the track with this musicbrainz_id",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--overwrite",
|
||||
action="store_true",
|
||||
help="Re-fetch similar recordings even if already populated",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from music.models import Track
|
||||
|
||||
commit = options["commit"]
|
||||
batch_size = options["batch_size"]
|
||||
track_id = options["track_id"]
|
||||
overwrite = options["overwrite"]
|
||||
|
||||
if not commit:
|
||||
self.stdout.write(
|
||||
"Dry run — no changes will be saved. Use --commit to apply."
|
||||
)
|
||||
|
||||
qs = Track.objects.exclude(musicbrainz_id__isnull=True).exclude(
|
||||
musicbrainz_id=""
|
||||
)
|
||||
|
||||
if track_id:
|
||||
qs = qs.filter(musicbrainz_id=track_id)
|
||||
self.stdout.write(f"Filtering to track with musicbrainz_id: {track_id}")
|
||||
elif not overwrite:
|
||||
qs = qs.filter(similar_recordings__isnull=True)
|
||||
else:
|
||||
qs = qs.all()
|
||||
|
||||
total = qs.count()
|
||||
if total == 0:
|
||||
self.stdout.write("No tracks to process.")
|
||||
return
|
||||
|
||||
self.stdout.write(
|
||||
f"Found {total} tracks with musicbrainz_id"
|
||||
+ (" (overwrite mode)" if overwrite else "")
|
||||
+ (" (--track-id filter)" if track_id else "")
|
||||
)
|
||||
|
||||
if not commit:
|
||||
self.stdout.write(
|
||||
"\nSkipping API lookups in dry-run mode. Use --commit to run against ListenBrainz."
|
||||
)
|
||||
return
|
||||
|
||||
found_count = 0
|
||||
empty_count = 0
|
||||
error_count = 0
|
||||
track_ids = list(qs.values_list("pk", flat=True))
|
||||
i = 0
|
||||
|
||||
for batch_num, offset in enumerate(
|
||||
range(0, len(track_ids), batch_size)
|
||||
):
|
||||
batch_pks = track_ids[offset : offset + batch_size]
|
||||
for track in Track.objects.filter(pk__in=batch_pks).iterator():
|
||||
i += 1
|
||||
self.stdout.write(
|
||||
f" [{i}/{total}] Fetching similar recordings for {track}...",
|
||||
ending="",
|
||||
)
|
||||
try:
|
||||
similar = get_similar_recordings(track.musicbrainz_id)
|
||||
if similar:
|
||||
track.similar_recordings = similar
|
||||
track.save(update_fields=["similar_recordings"])
|
||||
self.stdout.write(
|
||||
f" {len(similar)} similar recordings found"
|
||||
)
|
||||
found_count += 1
|
||||
else:
|
||||
track.similar_recordings = []
|
||||
track.save(update_fields=["similar_recordings"])
|
||||
self.stdout.write(" none found")
|
||||
empty_count += 1
|
||||
except Exception as e:
|
||||
self.stdout.write(f" error: {e}")
|
||||
error_count += 1
|
||||
|
||||
self.stdout.write(
|
||||
f" Batch {batch_num + 1}: {offset + len(batch_pks)}/{total} processed, "
|
||||
f"found: {found_count}, empty: {empty_count}, errors: {error_count}"
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
self.stdout.write(
|
||||
f"\nResults (commit={commit}):\n"
|
||||
f" Similar recordings found: {found_count}\n"
|
||||
f" No similar recordings: {empty_count}\n"
|
||||
f" Errors: {error_count}"
|
||||
)
|
||||
@ -0,0 +1,32 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("music", "0031_alter_track_genre"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="track",
|
||||
old_name="artist",
|
||||
new_name="artist_fk",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="track",
|
||||
name="artist_fk",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="music.artist",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="track",
|
||||
name="artists",
|
||||
field=models.ManyToManyField(to="music.artist"),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,27 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def backfill_artist_fk_to_artists(apps, schema_editor):
|
||||
Track = apps.get_model("music", "Track")
|
||||
TrackArtist = Track.artists.through
|
||||
TrackArtist.objects.bulk_create(
|
||||
[
|
||||
TrackArtist(track_id=r["id"], artist_id=r["artist_fk_id"])
|
||||
for r in Track.objects.filter(artist_fk__isnull=False).values("id", "artist_fk_id")
|
||||
],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("music", "0032_track_artist_fk_and_artists"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
backfill_artist_fk_to_artists,
|
||||
reverse_code=migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,29 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def backfill_album_artist_to_artists(apps, schema_editor):
|
||||
Album = apps.get_model("music", "Album")
|
||||
AlbumArtist = Album.artists.through
|
||||
AlbumArtist.objects.bulk_create(
|
||||
[
|
||||
AlbumArtist(album_id=r["id"], artist_id=r["album_artist_id"])
|
||||
for r in Album.objects.filter(album_artist__isnull=False).values(
|
||||
"id", "album_artist_id"
|
||||
)
|
||||
],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("music", "0033_backfill_track_artists"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
backfill_album_artist_to_artists,
|
||||
reverse_code=migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.30 on 2026-05-29 23:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("music", "0034_backfill_album_artists"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="track",
|
||||
name="similar_recordings",
|
||||
field=models.JSONField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.30 on 2026-05-30 00:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("music", "0035_track_similar_recordings"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="artist",
|
||||
name="similar_artists",
|
||||
field=models.JSONField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -16,9 +16,11 @@ from imagekit.processors import ResizeToFit
|
||||
from music.allmusic import get_allmusic_slug, scrape_data_from_allmusic
|
||||
from music.bandcamp import get_bandcamp_slug
|
||||
from music.musicbrainz import (
|
||||
extract_featured_artists,
|
||||
get_album_metadata_with_artist,
|
||||
get_recording_mbid_exact,
|
||||
get_track_metadata_with_artist,
|
||||
resolve_track,
|
||||
)
|
||||
from music.theaudiodb import lookup_album_from_tadb, lookup_artist_from_tadb
|
||||
from music.utils import clean_artist_name
|
||||
@ -54,6 +56,7 @@ class Artist(TimeStampedModel):
|
||||
theaudiodb_genre = models.CharField(max_length=255, **BNULL)
|
||||
theaudiodb_mood = models.CharField(max_length=255, **BNULL)
|
||||
musicbrainz_id = models.CharField(max_length=255, **BNULL)
|
||||
similar_artists = models.JSONField(**BNULL)
|
||||
allmusic_id = models.CharField(max_length=100, **BNULL)
|
||||
bandcamp_id = models.CharField(max_length=100, **BNULL)
|
||||
thumbnail = models.ImageField(upload_to="artist/", **BNULL)
|
||||
@ -124,7 +127,7 @@ class Artist(TimeStampedModel):
|
||||
def charts(self):
|
||||
from scrobbles.models import ChartRecord
|
||||
|
||||
return ChartRecord.objects.filter(track__artist=self).order_by("-year")
|
||||
return ChartRecord.objects.filter(track__artists=self).order_by("-year")
|
||||
|
||||
def scrape_allmusic(self, force=False) -> None:
|
||||
if not self.allmusic_id or force:
|
||||
@ -294,7 +297,7 @@ class Album(TimeStampedModel):
|
||||
alt_names = models.TextField(**BNULL)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "{} by {}".format(self.name, self.album_artist)
|
||||
return "{} by {}".format(self.name, self.artist)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("music:album_detail", kwargs={"slug": self.uuid})
|
||||
@ -320,6 +323,10 @@ class Album(TimeStampedModel):
|
||||
.order_by("-scrobble_count")
|
||||
)
|
||||
|
||||
@property
|
||||
def artist(self):
|
||||
return self.artists.first() or self.album_artist
|
||||
|
||||
def fix_album_artist(self):
|
||||
from music.utils import get_or_create_various_artists
|
||||
|
||||
@ -338,10 +345,10 @@ class Album(TimeStampedModel):
|
||||
)
|
||||
return
|
||||
|
||||
if self.album_artist and (not self.allmusic_id or force):
|
||||
slug = get_allmusic_slug(self.album_artist.name, self.name)
|
||||
if self.artist and (not self.allmusic_id or force):
|
||||
slug = get_allmusic_slug(self.artist.name, self.name)
|
||||
if not slug:
|
||||
logger.info(f"No allmsuic link for {self} by {self.album_artist}")
|
||||
logger.info(f"No allmsuic link for {self} by {self.artist}")
|
||||
return
|
||||
self.allmusic_id = slug
|
||||
self.save(update_fields=["allmusic_id"])
|
||||
@ -351,7 +358,7 @@ class Album(TimeStampedModel):
|
||||
allmusic_data = scrape_data_from_allmusic(self.allmusic_link)
|
||||
|
||||
if not allmusic_data:
|
||||
logger.info(f"No allmsuic data for {self} by {self.album_artist}")
|
||||
logger.info(f"No allmsuic data for {self} by {self.artist}")
|
||||
return
|
||||
|
||||
self.allmusic_review = allmusic_data["review"]
|
||||
@ -360,8 +367,8 @@ class Album(TimeStampedModel):
|
||||
|
||||
def scrape_theaudiodb(self) -> None:
|
||||
artist = "Various Artists"
|
||||
if self.album_artist:
|
||||
artist = self.album_artist.name
|
||||
if self.artist:
|
||||
artist = self.artist.name
|
||||
album_data = lookup_album_from_tadb(self.name, artist)
|
||||
if not album_data.get("theaudiodb_id"):
|
||||
logger.info(f"No data for {self} found in TheAudioDB")
|
||||
@ -374,7 +381,7 @@ class Album(TimeStampedModel):
|
||||
|
||||
def scrape_bandcamp(self, force=False) -> None:
|
||||
if not self.bandcamp_id or force:
|
||||
slug = get_bandcamp_slug(self.album_artist.name, self.name)
|
||||
slug = get_bandcamp_slug(self.artist.name, self.name)
|
||||
if not slug:
|
||||
logger.info(f"No bandcamp link for {self}")
|
||||
return
|
||||
@ -422,7 +429,8 @@ class Album(TimeStampedModel):
|
||||
self.artists.add(new_artist)
|
||||
if not new_artist:
|
||||
for t in self.track_set.all():
|
||||
self.artists.add(t.artist)
|
||||
for a in t.artists.all():
|
||||
self.artists.add(a)
|
||||
if not self.cover_image or self.cover_image == "default-image-replace-me":
|
||||
self.fetch_artwork()
|
||||
self.fix_album_artist()
|
||||
@ -484,19 +492,19 @@ class Album(TimeStampedModel):
|
||||
|
||||
@property
|
||||
def rym_link(self):
|
||||
artist_slug = self.album_artist.name.lower().replace(" ", "-")
|
||||
artist_slug = self.artist.name.lower().replace(" ", "-")
|
||||
album_slug = self.name.lower().replace(" ", "-")
|
||||
return f"https://rateyourmusic.com/release/album/{artist_slug}/{album_slug}/"
|
||||
|
||||
@property
|
||||
def bandcamp_link(self):
|
||||
if self.bandcamp_id and self.album_artist.bandcamp_id:
|
||||
return f"https://{self.album_artist.bandcamp_id}.bandcamp.com/album/{self.bandcamp_id}"
|
||||
if self.bandcamp_id and self.artist.bandcamp_id:
|
||||
return f"https://{self.artist.bandcamp_id}.bandcamp.com/album/{self.bandcamp_id}"
|
||||
return ""
|
||||
|
||||
@property
|
||||
def bandcamp_search_link(self):
|
||||
artist = self.album_artist.name.lower()
|
||||
artist = self.artist.name.lower()
|
||||
album = self.name.lower()
|
||||
return f"https://bandcamp.com/search?q={album} {artist}&item_type=a"
|
||||
|
||||
@ -564,7 +572,7 @@ class Album(TimeStampedModel):
|
||||
album_artist=artist,
|
||||
alt_names=alt_name,
|
||||
)
|
||||
album.artists.add(*extra_artists)
|
||||
album.artists.add(artist, *extra_artists)
|
||||
album.fetch_artwork()
|
||||
|
||||
return album
|
||||
@ -573,14 +581,22 @@ class Album(TimeStampedModel):
|
||||
class Track(ScrobblableMixin):
|
||||
COMPLETION_PERCENT = getattr(settings, "MUSIC_COMPLETION_PERCENT", 100)
|
||||
|
||||
artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING)
|
||||
artist_fk = models.ForeignKey(
|
||||
Artist, on_delete=models.DO_NOTHING, **BNULL, related_name="+"
|
||||
)
|
||||
artists = models.ManyToManyField(Artist)
|
||||
albums = models.ManyToManyField(Album, related_name="tracks")
|
||||
album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL)
|
||||
musicbrainz_id = models.CharField(max_length=255, **BNULL)
|
||||
similar_recordings = models.JSONField(**BNULL)
|
||||
|
||||
class Meta:
|
||||
unique_together = [["album", "musicbrainz_id"]]
|
||||
|
||||
@property
|
||||
def artist(self):
|
||||
return self.artists.first() or self.artist_fk
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} by {self.artist}"
|
||||
|
||||
@ -615,8 +631,9 @@ class Track(ScrobblableMixin):
|
||||
@property
|
||||
def primary_image_url(self) -> str:
|
||||
url = ""
|
||||
if self.artist.thumbnail:
|
||||
url = self.artist.thumbnail_medium.url
|
||||
primary_artist = self.artist
|
||||
if primary_artist and primary_artist.thumbnail:
|
||||
url = primary_artist.thumbnail_medium.url
|
||||
if self.primary_album and self.primary_album.cover_image:
|
||||
url = self.primary_album.cover_image_medium.url
|
||||
return url
|
||||
@ -625,9 +642,11 @@ class Track(ScrobblableMixin):
|
||||
def find_or_create(
|
||||
cls,
|
||||
title: str = "",
|
||||
artist_names: list[str] | None = None,
|
||||
artist_name: str = "",
|
||||
album_name: str = "",
|
||||
run_time_seconds: int | None = None,
|
||||
mbid: str | None = None,
|
||||
enrich: bool = False,
|
||||
commit: bool = True,
|
||||
) -> "Track":
|
||||
@ -637,61 +656,79 @@ class Track(ScrobblableMixin):
|
||||
name
|
||||
|
||||
Optionally, we can update any found artists with overwrite."""
|
||||
from music.utils import parse_artist_names
|
||||
|
||||
if artist_names is None and artist_name:
|
||||
artist_names = parse_artist_names(artist_name)
|
||||
|
||||
if not artist_names:
|
||||
artist_names = []
|
||||
|
||||
album = None
|
||||
if album_name:
|
||||
logger.info("Looking up album for: {album_name}")
|
||||
album = Album.find_or_create(name=album_name, artist_name=artist_name)
|
||||
artist = album.album_artist
|
||||
else:
|
||||
artist = Artist.find_or_create(artist_name, track_name=title)
|
||||
if not artist:
|
||||
artist = Artist.find_or_create(artist_name)
|
||||
logger.info(f"Looking up album for: {album_name}")
|
||||
first_name = artist_names[0] if artist_names else ""
|
||||
album = Album.find_or_create(name=album_name, artist_name=first_name)
|
||||
|
||||
lookup_keys = {"title": title, "artist": artist}
|
||||
if run_time_seconds:
|
||||
lookup_keys["base_run_time_seconds"] = run_time_seconds
|
||||
logger.info(f"Looking up track using: {lookup_keys}")
|
||||
track = cls.objects.filter(**lookup_keys).first()
|
||||
if track:
|
||||
logger.info(
|
||||
"Found match for track by name and artist, not going to musicbrainz ",
|
||||
extra={
|
||||
"track_id": track.id,
|
||||
"title": title,
|
||||
"artist_name": artist_name,
|
||||
"run_time_seconds": run_time_seconds,
|
||||
},
|
||||
)
|
||||
return track
|
||||
artist_objs = []
|
||||
for name in artist_names:
|
||||
artist = Artist.find_or_create(name, track_name=title)
|
||||
if artist:
|
||||
artist_objs.append(artist)
|
||||
|
||||
track = None
|
||||
if artist_objs:
|
||||
track = cls.objects.filter(title=title, artists__in=artist_objs).first()
|
||||
|
||||
track = cls.objects.filter(title=title, artist=artist).first()
|
||||
if not track:
|
||||
track, _ = cls.objects.get_or_create(title=title, artist=artist)
|
||||
track = cls.objects.filter(title=title).first()
|
||||
|
||||
if not track:
|
||||
track = cls(title=title)
|
||||
track.save()
|
||||
track.refresh_from_db()
|
||||
|
||||
if artist_objs:
|
||||
track.artists.add(*artist_objs)
|
||||
|
||||
if album:
|
||||
track.albums.add(album)
|
||||
|
||||
if enrich or not track.base_run_time_seconds:
|
||||
logger.info(
|
||||
f"Enriching track {track}",
|
||||
extra={
|
||||
"title": title,
|
||||
"artist_name": artist_name,
|
||||
"track_id": track.id,
|
||||
},
|
||||
)
|
||||
try:
|
||||
mbid, length = get_recording_mbid_exact(title, artist_name, album_name)
|
||||
except Exception:
|
||||
print("No musicbrainz result found, cannot enrich")
|
||||
return track
|
||||
track.base_run_time_seconds = run_time_seconds or int(length / 1000)
|
||||
track.musicbrainz_id = mbid
|
||||
if mbid and run_time_seconds:
|
||||
track.base_run_time_seconds = run_time_seconds
|
||||
track.musicbrainz_id = mbid
|
||||
track.tags.add("musicbrainz-provider", "musicbrainz-enriched")
|
||||
else:
|
||||
artist_name_str = " & ".join(artist_names) if artist_names else ""
|
||||
logger.info(
|
||||
f"Enriching track {track}",
|
||||
extra={
|
||||
"title": title,
|
||||
"artist_name": artist_name_str,
|
||||
"track_id": track.id,
|
||||
},
|
||||
)
|
||||
result, method = resolve_track(title, artist_name_str, album_name)
|
||||
if result and result.get("recording_mbid"):
|
||||
track.musicbrainz_id = result["recording_mbid"]
|
||||
length_ms = result.get("length_ms")
|
||||
if length_ms and not track.base_run_time_seconds:
|
||||
track.base_run_time_seconds = int(int(length_ms) / 1000)
|
||||
method_tag = f"musicbrainz-{method}" if method else ""
|
||||
track.tags.add(method_tag, "musicbrainz-enriched")
|
||||
cleaned_title, featured_names = extract_featured_artists(title)
|
||||
for feat_name in featured_names:
|
||||
artist = Artist.find_or_create(feat_name, track_name=title)
|
||||
if artist:
|
||||
track.artists.add(artist)
|
||||
else:
|
||||
print("No musicbrainz result found, cannot enrich")
|
||||
track.tags.add("musicbrainz-notfound")
|
||||
return track
|
||||
if commit:
|
||||
track.save()
|
||||
|
||||
return track
|
||||
|
||||
def fix_metadata(self, force_update=False):
|
||||
|
||||
...
|
||||
def fix_metadata(self, force_update=False): ...
|
||||
|
||||
@ -1,11 +1,155 @@
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
import musicbrainzngs
|
||||
from dateutil.parser import parse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TRACK_SUFFIXES = [
|
||||
r" - Bonus Track",
|
||||
r" - Bonus",
|
||||
r" - Remix",
|
||||
r" - Live",
|
||||
r" - Radio Edit",
|
||||
r" - Extended Mix",
|
||||
r" - Instrumental",
|
||||
r" - Acoustic",
|
||||
r" - Mix",
|
||||
r" - Edit",
|
||||
r" \[Explicit\]",
|
||||
]
|
||||
|
||||
FEATURED_PATTERNS = [
|
||||
(r"\(feat\. (.+?)\)", re.IGNORECASE),
|
||||
(r"\(ft\. (.+?)\)", re.IGNORECASE),
|
||||
(r"featuring (.+)", re.IGNORECASE),
|
||||
(r"feat\. (.+)", re.IGNORECASE),
|
||||
(r"ft\. (.+)", re.IGNORECASE),
|
||||
]
|
||||
|
||||
|
||||
def strip_track_suffixes(title: str) -> str:
|
||||
for suffix in TRACK_SUFFIXES:
|
||||
title = re.sub(suffix, "", title, flags=re.IGNORECASE)
|
||||
return title.strip()
|
||||
|
||||
|
||||
def extract_featured_artists(title: str) -> tuple[str, list[str]]:
|
||||
featured = []
|
||||
cleaned = title
|
||||
for pattern, flags in FEATURED_PATTERNS:
|
||||
m = re.search(pattern, cleaned, flags)
|
||||
if m:
|
||||
featured.append(m.group(1).strip())
|
||||
cleaned = re.sub(pattern, "", cleaned, flags=flags).strip()
|
||||
return cleaned, featured
|
||||
|
||||
|
||||
def search_recordings(title: str, artist: str = "") -> list[dict]:
|
||||
kwargs: dict = {"recording": title, "limit": 10}
|
||||
if artist:
|
||||
kwargs["artist"] = artist
|
||||
try:
|
||||
return musicbrainzngs.search_recordings(**kwargs).get("recording-list", [])
|
||||
except (musicbrainzngs.WebServiceError, musicbrainzngs.ResponseError) as e:
|
||||
logger.warning(f"MusicBrainz search error: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def pick_best_recording(
|
||||
recordings: list[dict], title: str, artist: str = ""
|
||||
) -> tuple[dict | None, str]:
|
||||
query_title = title.strip().casefold()
|
||||
query_artist = artist.strip().casefold() if artist else ""
|
||||
best_score: tuple[dict | None, str] = (None, "")
|
||||
|
||||
for rec in recordings:
|
||||
rec_title = rec.get("title", "").strip()
|
||||
score = int(rec.get("ext:score", 0))
|
||||
|
||||
if rec_title.casefold() != query_title:
|
||||
if score >= 85 and not best_score[0]:
|
||||
best_score = (rec, "score")
|
||||
continue
|
||||
|
||||
if query_artist:
|
||||
artist_credit = rec.get("artist-credit", [])
|
||||
rec_artist = (
|
||||
artist_credit[0]["artist"]["name"].strip()
|
||||
if artist_credit
|
||||
else ""
|
||||
)
|
||||
if rec_artist.casefold() != query_artist:
|
||||
if score >= 85 and not best_score[0]:
|
||||
best_score = (rec, "score")
|
||||
continue
|
||||
|
||||
return (rec, "exact")
|
||||
|
||||
return best_score
|
||||
|
||||
|
||||
def resolve_track(
|
||||
title: str, artist: str = "", album: str = ""
|
||||
) -> tuple[dict | None, str]:
|
||||
if not title:
|
||||
return (None, "")
|
||||
|
||||
titles_to_try: list[tuple[str, str]] = [(title, "")]
|
||||
stripped = strip_track_suffixes(title)
|
||||
if stripped != title:
|
||||
titles_to_try.append((stripped, "stripped-"))
|
||||
|
||||
for attempt_title, prefix in titles_to_try:
|
||||
if album and not prefix:
|
||||
try:
|
||||
mbid, length = get_recording_mbid_exact(
|
||||
attempt_title, artist, album
|
||||
)
|
||||
return (
|
||||
{
|
||||
"recording_mbid": mbid,
|
||||
"length_ms": length,
|
||||
},
|
||||
"exact",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if artist:
|
||||
result = get_track_metadata_with_artist(attempt_title, artist)
|
||||
if result and result.get("recording_mbid"):
|
||||
return (result, f"{prefix}exact")
|
||||
|
||||
recordings = search_recordings(attempt_title, artist)
|
||||
result, tag = pick_best_recording(recordings, attempt_title, artist)
|
||||
if result:
|
||||
length_ms = result.get("length")
|
||||
return (
|
||||
{
|
||||
"recording_mbid": result["id"],
|
||||
"length_ms": int(length_ms) if length_ms else None,
|
||||
},
|
||||
f"{prefix}{tag}",
|
||||
)
|
||||
|
||||
recordings = search_recordings(attempt_title)
|
||||
result, tag = pick_best_recording(recordings, attempt_title)
|
||||
if result:
|
||||
tag = "title-only" if not prefix else f"{prefix}title-only"
|
||||
length_ms = result.get("length")
|
||||
return (
|
||||
{
|
||||
"recording_mbid": result["id"],
|
||||
"length_ms": int(length_ms) if length_ms else None,
|
||||
},
|
||||
tag,
|
||||
)
|
||||
|
||||
return (None, "")
|
||||
|
||||
musicbrainzngs.set_useragent("Vrobbler", "1.0", "help@unbl.ink")
|
||||
|
||||
|
||||
|
||||
@ -15,12 +15,26 @@ def clean_artist_name(name: str) -> str:
|
||||
name = re.split(" w. ", name, flags=re.IGNORECASE)[0].strip()
|
||||
if " featuring " in name.lower():
|
||||
name = re.split(" featuring ", name, flags=re.IGNORECASE)[0].strip()
|
||||
# if " & " in name.lower() and "of the wand" not in name.lower():
|
||||
# name = re.split("&", name, flags=re.IGNORECASE)[0].strip()
|
||||
|
||||
return name
|
||||
|
||||
|
||||
def parse_artist_names(name: str) -> list[str]:
|
||||
"""Split a combined artist string (e.g. 'A & B') into individual names.
|
||||
|
||||
First strips feat./featuring/w. prefixes, then splits on ' & ' to
|
||||
support collaboration-style credits like 'Matt Sweeney & Bonnie Prince Billy'.
|
||||
"""
|
||||
name = clean_artist_name(name)
|
||||
if " & " in name.lower():
|
||||
return [
|
||||
part.strip()
|
||||
for part in re.split(r"\s+&\s+", name, flags=re.IGNORECASE)
|
||||
if part.strip()
|
||||
]
|
||||
return [name]
|
||||
|
||||
|
||||
def get_or_create_various_artists() -> "Artist":
|
||||
from music.models import Artist
|
||||
|
||||
@ -35,19 +49,19 @@ def deduplicate_tracks(commit=False) -> int:
|
||||
from music.models import Track
|
||||
|
||||
duplicates = (
|
||||
Track.objects.values("artist", "title")
|
||||
Track.objects.values("title")
|
||||
.annotate(dup_count=models.Count("id"))
|
||||
.filter(dup_count__gt=1)
|
||||
)
|
||||
|
||||
query = models.Q()
|
||||
for dup in duplicates:
|
||||
query |= models.Q(artist=dup["artist"], title=dup["title"])
|
||||
query |= models.Q(title=dup["title"])
|
||||
|
||||
duplicate_tracks = Track.objects.filter(query)
|
||||
|
||||
for b in duplicate_tracks:
|
||||
tracks = Track.objects.filter(artist=b.artist, title=b.title)
|
||||
tracks = Track.objects.filter(title=b.title)
|
||||
first = tracks.first()
|
||||
for other in tracks.exclude(id=first.id):
|
||||
print("Moving scrobbles for", other.id, " to ", first.id)
|
||||
@ -74,7 +88,7 @@ def condense_albums(commit: bool = False):
|
||||
for track in Track.objects.all():
|
||||
albums_to_add = []
|
||||
duplicates = (
|
||||
Track.objects.filter(title=track.title, artist=track.artist)
|
||||
Track.objects.filter(title=track.title)
|
||||
.exclude(id=track.id)
|
||||
.exclude(id__in=processed_ids)
|
||||
)
|
||||
@ -88,7 +102,7 @@ def condense_albums(commit: bool = False):
|
||||
track.albums.add(dup_track.album)
|
||||
|
||||
# Find out if this track appears more than once
|
||||
duplicates = Track.objects.filter(title=track.title, artist=track.artist)
|
||||
duplicates = Track.objects.filter(title=track.title)
|
||||
if duplicates.count() > 1:
|
||||
logger.info(f"Track appears more than once, condensing: {track}")
|
||||
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
from charts.models import ChartRecord
|
||||
from django.db.models import Count
|
||||
from django.views import generic
|
||||
from music.models import Album, Artist, Track
|
||||
from charts.models import ChartRecord
|
||||
from scrobbles.stats import get_scrobble_count_qs
|
||||
|
||||
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
|
||||
from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView
|
||||
|
||||
|
||||
class TrackListView(ScrobbleableListView):
|
||||
@ -14,29 +13,30 @@ class TrackListView(ScrobbleableListView):
|
||||
class TrackDetailView(ScrobbleableDetailView):
|
||||
model = Track
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context_data = super().get_context_data(**kwargs)
|
||||
context_data["charts"] = ChartRecord.objects.filter(
|
||||
track=self.object, rank__in=[1, 2, 3]
|
||||
)
|
||||
return context_data
|
||||
|
||||
|
||||
class ArtistListView(generic.ListView):
|
||||
model = Artist
|
||||
paginate_by = 100
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.annotate(scrobble_count=Count("track__scrobble"))
|
||||
.order_by("-scrobble_count")
|
||||
)
|
||||
qs = super().get_queryset().annotate(scrobble_count=Count("track__scrobble"))
|
||||
genre = self.request.GET.get("genre")
|
||||
if genre:
|
||||
qs = qs.filter(theaudiodb_genre=genre)
|
||||
mood = self.request.GET.get("mood")
|
||||
if mood:
|
||||
qs = qs.filter(theaudiodb_mood=mood)
|
||||
return qs.order_by("-scrobble_count")
|
||||
|
||||
def get_context_data(self, *, object_list=None, **kwargs):
|
||||
context_data = super().get_context_data(object_list=object_list, **kwargs)
|
||||
context_data["view"] = self.request.GET.get("view")
|
||||
genre = self.request.GET.get("genre")
|
||||
if genre:
|
||||
context_data["active_filter"] = f"genre: {genre}"
|
||||
mood = self.request.GET.get("mood")
|
||||
if mood:
|
||||
context_data["active_filter"] = f"mood: {mood}"
|
||||
return context_data
|
||||
|
||||
|
||||
@ -57,18 +57,52 @@ class ArtistDetailView(generic.DetailView):
|
||||
]
|
||||
|
||||
context_data["tracks_ranked"] = tracks_ranked
|
||||
context_data["charts"] = ChartRecord.objects.filter(
|
||||
|
||||
charts_qs = ChartRecord.objects.filter(
|
||||
artist=self.object, rank__in=[1, 2, 3]
|
||||
)
|
||||
).exclude(day__isnull=False)
|
||||
from collections import OrderedDict
|
||||
|
||||
grouped = OrderedDict()
|
||||
for chart in charts_qs:
|
||||
grouped.setdefault(chart.period_type, []).append(chart)
|
||||
context_data["charts"] = grouped
|
||||
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
context_data["recent_scrobbles"] = (
|
||||
Scrobble.objects.filter(track__artist=artist)
|
||||
Scrobble.objects.filter(track__artists=artist)
|
||||
.select_related("track", "track__album")
|
||||
.order_by("-timestamp")[:100]
|
||||
)
|
||||
|
||||
similar = artist.similar_artists or []
|
||||
if similar:
|
||||
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 top:
|
||||
local = local_artists.get(sa.get("artist_mbid"))
|
||||
sa["local_url"] = local.get_absolute_url() if local else None
|
||||
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()
|
||||
if artist.theaudiodb_mood:
|
||||
context_data["mood_count"] = Artist.objects.filter(
|
||||
theaudiodb_mood=artist.theaudiodb_mood
|
||||
).count()
|
||||
|
||||
return context_data
|
||||
|
||||
|
||||
|
||||
@ -31,10 +31,14 @@ class UserProfileForm(forms.ModelForm):
|
||||
"webdav_auto_import",
|
||||
"ntfy_url",
|
||||
"ntfy_enabled",
|
||||
"mopidy_api_url",
|
||||
"favorites_mopidy_playlist",
|
||||
"monthly_mopidy_playlist_pattern",
|
||||
"redirect_to_webpage",
|
||||
"enable_public_widgets",
|
||||
"widget_custom_css",
|
||||
"home_scrobble_limit",
|
||||
"weigh_in_units",
|
||||
]
|
||||
widgets = {
|
||||
"lastfm_password": forms.PasswordInput(render_value=True),
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-21 13:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import profiles.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("profiles", "0031_add_home_scrobble_limit"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="weigh_in_units",
|
||||
field=models.CharField(
|
||||
choices=profiles.models.WeighUnit.choices,
|
||||
default=profiles.models.WeighUnit.METRIC,
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.30 on 2026-06-04 16:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("profiles", "0032_userprofile_weigh_in_units"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="mopidy_api_url",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.30 on 2026-06-05 14:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("profiles", "0033_userprofile_mopidy_api_url"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="favorites_mopidy_playlist",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Playlist name (e.g. 'Favorites'). Will map to m3u:Favorites.m3u8",
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.30 on 2026-06-05 17:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("profiles", "0034_userprofile_favorites_mopidy_playlist"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="monthly_mopidy_playlist_pattern",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Django date format pattern for monthly playlists (e.g. 'Y F')",
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -16,6 +16,11 @@ BNULL = {"blank": True, "null": True}
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WeighUnit(models.TextChoices):
|
||||
METRIC = "metric", "Metric (kg, cm)"
|
||||
IMPERIAL = "imperial", "Imperial (lbs, in)"
|
||||
|
||||
|
||||
class UserProfile(TimeStampedModel):
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
||||
timezone = models.CharField(
|
||||
@ -59,6 +64,16 @@ class UserProfile(TimeStampedModel):
|
||||
ntfy_url = models.CharField(max_length=255, **BNULL)
|
||||
ntfy_enabled = models.BooleanField(default=False)
|
||||
|
||||
mopidy_api_url = models.CharField(max_length=255, **BNULL)
|
||||
favorites_mopidy_playlist = models.CharField(
|
||||
max_length=255, **BNULL,
|
||||
help_text="Playlist name (e.g. 'Favorites'). Will map to m3u:Favorites.m3u8",
|
||||
)
|
||||
monthly_mopidy_playlist_pattern = models.CharField(
|
||||
max_length=255, **BNULL,
|
||||
help_text="Django date format pattern for monthly playlists (e.g. 'Y F')",
|
||||
)
|
||||
|
||||
redirect_to_webpage = models.BooleanField(default=True)
|
||||
|
||||
enable_public_widgets = models.BooleanField(default=False)
|
||||
@ -66,6 +81,12 @@ class UserProfile(TimeStampedModel):
|
||||
|
||||
home_scrobble_limit = models.IntegerField(default=20)
|
||||
|
||||
weigh_in_units = models.CharField(
|
||||
max_length=16,
|
||||
choices=WeighUnit.choices,
|
||||
default=WeighUnit.METRIC,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"User profile for {self.user}"
|
||||
|
||||
|
||||
@ -2,10 +2,15 @@ from django.contrib import admin
|
||||
|
||||
from scrobbles.models import (
|
||||
AudioScrobblerTSVImport,
|
||||
BGStatsImport,
|
||||
EBirdCSVImport,
|
||||
FavoriteMedia,
|
||||
KoReaderImport,
|
||||
LastFmImport,
|
||||
RetroarchImport,
|
||||
ScaleCSVImport,
|
||||
Scrobble,
|
||||
TrailGPXImport,
|
||||
)
|
||||
from scrobbles.mixins import Genre
|
||||
|
||||
@ -77,6 +82,26 @@ class RetroarchImportAdmin(ImportBaseAdmin):
|
||||
...
|
||||
|
||||
|
||||
@admin.register(BGStatsImport)
|
||||
class BGStatsImportAdmin(ImportBaseAdmin):
|
||||
...
|
||||
|
||||
|
||||
@admin.register(EBirdCSVImport)
|
||||
class EBirdCSVImportAdmin(ImportBaseAdmin):
|
||||
...
|
||||
|
||||
|
||||
@admin.register(ScaleCSVImport)
|
||||
class ScaleCSVImportAdmin(ImportBaseAdmin):
|
||||
...
|
||||
|
||||
|
||||
@admin.register(TrailGPXImport)
|
||||
class TrailGPXImportAdmin(ImportBaseAdmin):
|
||||
...
|
||||
|
||||
|
||||
@admin.register(Genre)
|
||||
class GenreAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
@ -140,3 +165,28 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request).exclude(timestamp__year=None)
|
||||
return qs
|
||||
|
||||
|
||||
@admin.register(FavoriteMedia)
|
||||
class FavoriteMediaAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "media_type", "sent_to_mopidy", "created")
|
||||
list_filter = ("media_type", "sent_to_mopidy", "user")
|
||||
date_hierarchy = "created"
|
||||
raw_id_fields = (
|
||||
"video",
|
||||
"track",
|
||||
"podcast_episode",
|
||||
"sport_event",
|
||||
"book",
|
||||
"video_game",
|
||||
"board_game",
|
||||
"geo_location",
|
||||
"task",
|
||||
"mood",
|
||||
"brick_set",
|
||||
"trail",
|
||||
"beer",
|
||||
"web_page",
|
||||
"life_event",
|
||||
"birding_location",
|
||||
)
|
||||
|
||||
@ -4,6 +4,26 @@ from django.utils import timezone
|
||||
from scrobbles.constants import EXCLUDE_FROM_NOW_PLAYING
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
MONTH_COLORS = [
|
||||
"#db7a7a", # Jan
|
||||
"#db847a", # Feb
|
||||
"#b0db7a", # Mar
|
||||
"#7adb82", # Apr
|
||||
"#7adbb3", # May
|
||||
"#7ab6db", # Jun
|
||||
"#7a8edb", # Jul
|
||||
"#977adb", # Aug
|
||||
"#c47adb", # Sep
|
||||
"#db7ac5", # Oct
|
||||
"#db7a90", # Nov
|
||||
"#db7a7a", # Dec
|
||||
]
|
||||
|
||||
|
||||
def month_color(request):
|
||||
from datetime import date
|
||||
return {"month_color": MONTH_COLORS[(date.today().month - 1) % 12]}
|
||||
|
||||
|
||||
def now_playing(request):
|
||||
user = request.user
|
||||
|
||||
@ -57,38 +57,136 @@ class BaseLogData(JSONDataclass):
|
||||
if not self.notes:
|
||||
return ""
|
||||
|
||||
if isinstance(self.notes, dict):
|
||||
lines = []
|
||||
for ts, text in self.notes.items():
|
||||
note_text = " ".join(text.strip().split())
|
||||
lines.append(f"{ts}: {note_text}")
|
||||
return separator.join(lines)
|
||||
|
||||
if isinstance(self.notes, str):
|
||||
return html.escape(re.sub(r"\s*\[start:\d+\|end:\d+\]$", "", self.notes))
|
||||
|
||||
if isinstance(self.notes, list):
|
||||
cleaned_notes = []
|
||||
for note in self.notes:
|
||||
cleaned = html.escape(re.sub(r"\s*\[start:\d+\|end:\d+\]$", "", note))
|
||||
cleaned_notes.append(cleaned)
|
||||
if isinstance(note, dict):
|
||||
timestamp, note_text = next(iter(note.items()))
|
||||
note_text = " ".join(note_text.strip().split())
|
||||
cleaned_notes.append(f"{timestamp}: {note_text}")
|
||||
elif isinstance(note, str):
|
||||
cleaned = html.escape(
|
||||
re.sub(r"\s*\[start:\d+\|end:\d+\]$", "", note)
|
||||
)
|
||||
cleaned_notes.append(cleaned)
|
||||
return separator.join(cleaned_notes)
|
||||
|
||||
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 ""
|
||||
|
||||
notes_list = []
|
||||
if isinstance(self.notes, str):
|
||||
notes_list = [self.notes]
|
||||
elif isinstance(self.notes, list):
|
||||
notes_list = self.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",
|
||||
]
|
||||
|
||||
html_notes = []
|
||||
for note in notes_list:
|
||||
for line in note.split("\n"):
|
||||
note_items = []
|
||||
|
||||
if isinstance(self.notes, dict):
|
||||
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>')
|
||||
return mark_safe("".join(html_notes))
|
||||
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_items.append((timestamp, note_text.strip()))
|
||||
elif isinstance(note, str):
|
||||
for line in note.split("\n"):
|
||||
if line.strip():
|
||||
note_items.append((None, line.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">{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
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import csv
|
||||
import tempfile
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
from django.db.models import Q
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
|
||||
def export_scrobbles(start_date=None, end_date=None, format="AS"):
|
||||
@ -50,7 +50,7 @@ def export_scrobbles(start_date=None, end_date=None, format="AS"):
|
||||
track = scrobble.track
|
||||
track_number = 0 # TODO Add track number
|
||||
track_rating = "S" # TODO implement ratings?
|
||||
track_artist = track.artist or track.album.album_artist
|
||||
track_artist = track.artist or track.album.artist
|
||||
row = [
|
||||
track_artist,
|
||||
track.album.name,
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import json
|
||||
from dataclasses import fields
|
||||
from typing import Union, get_args, get_origin
|
||||
|
||||
@ -89,7 +90,7 @@ def form_from_dataclass(dataclass):
|
||||
|
||||
form_cls = type(f"{dataclass.__name__}Form", (forms.Form,), form_fields)
|
||||
|
||||
if "notes" in form_cls.base_fields:
|
||||
if "notes" in form_cls.base_fields and "notes" not in override_fields:
|
||||
form_cls.base_fields["notes"] = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={"rows": 4}),
|
||||
@ -101,3 +102,54 @@ def form_from_dataclass(dataclass):
|
||||
|
||||
form_cls.clean_notes = clean_notes
|
||||
return form_cls
|
||||
|
||||
|
||||
class NotesDictWidget(forms.Widget):
|
||||
template_name = "tasks/task_notes_widget.html"
|
||||
|
||||
class Media:
|
||||
js = ("tasks/task_notes.js",)
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
timestamps = data.getlist(f"{name}_timestamps")
|
||||
if timestamps:
|
||||
contents = data.getlist(f"{name}_contents")
|
||||
result = {}
|
||||
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):
|
||||
context = super().get_context(name, value, attrs)
|
||||
notes = {}
|
||||
if value:
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
notes = json.loads(value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
notes = {}
|
||||
elif isinstance(value, dict):
|
||||
notes = value
|
||||
context["widget"]["notes"] = notes
|
||||
return context
|
||||
|
||||
|
||||
class NotesDictField(forms.Field):
|
||||
widget = NotesDictWidget
|
||||
|
||||
def clean(self, value):
|
||||
if not value:
|
||||
return {}
|
||||
|
||||
if isinstance(value, str):
|
||||
if value.strip():
|
||||
from scrobbles.utils import make_note_timestamp
|
||||
return {make_note_timestamp(): value.strip()}
|
||||
return {}
|
||||
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
|
||||
return {}
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
import calendar
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pylast
|
||||
import pytz
|
||||
from celery import shared_task
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from music.models import Track
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -22,6 +27,8 @@ PYLAST_ERRORS = tuple(
|
||||
|
||||
class LastFM:
|
||||
def __init__(self, user):
|
||||
self.user = None
|
||||
self.vrobbler_user = user
|
||||
try:
|
||||
self.client = pylast.LastFMNetwork(
|
||||
api_key=getattr(settings, "LASTFM_API_KEY"),
|
||||
@ -30,24 +37,28 @@ class LastFM:
|
||||
password_hash=pylast.md5(user.profile.lastfm_password),
|
||||
)
|
||||
self.user = self.client.get_user(user.profile.lastfm_username)
|
||||
self.vrobbler_user = user
|
||||
except PYLAST_ERRORS as e:
|
||||
logger.error(f"Error during Last.fm setup: {e}")
|
||||
raise
|
||||
|
||||
def import_from_lastfm(self, last_processed=None):
|
||||
def import_from_lastfm(self, last_processed=None, time_to=None):
|
||||
"""Given a last processed time, import all scrobbles from LastFM since then"""
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
new_scrobbles = []
|
||||
source = "Last.fm"
|
||||
lastfm_scrobbles = self.get_last_scrobbles(time_from=last_processed)
|
||||
lastfm_scrobbles = self.get_last_scrobbles(
|
||||
time_from=last_processed, time_to=time_to
|
||||
)
|
||||
|
||||
for lfm_scrobble in lastfm_scrobbles:
|
||||
track = Track.find_or_create(
|
||||
title=lfm_scrobble.get("title"),
|
||||
artist_name=lfm_scrobble.get("artist"),
|
||||
album_name=lfm_scrobble.get("album"),
|
||||
enrich=True,
|
||||
album_name=lfm_scrobble.get("album", ""),
|
||||
run_time_seconds=lfm_scrobble.get("run_time_seconds"),
|
||||
mbid=lfm_scrobble.get("mbid"),
|
||||
enrich=False,
|
||||
)
|
||||
|
||||
tz_timestamp = self.vrobbler_user.profile.get_timestamp_with_tz(
|
||||
@ -92,6 +103,20 @@ class LastFM:
|
||||
)
|
||||
return created
|
||||
|
||||
def get_earliest_scrobble_timestamp(self) -> datetime | None:
|
||||
"""Return the user's Last.fm registration date as the earliest
|
||||
possible scrobble timestamp.
|
||||
|
||||
Uses ``get_unixtime_registered()`` which is a single API call.
|
||||
Returns ``None`` if the user has no scrobbles at all.
|
||||
"""
|
||||
if not self.get_last_scrobbles(check=True):
|
||||
return None
|
||||
registered_ts = self.user.get_unixtime_registered()
|
||||
earliest = datetime.fromtimestamp(registered_ts, tz=pytz.utc)
|
||||
logger.info("Last.fm user %s registered on %s", self.user.name, earliest)
|
||||
return earliest
|
||||
|
||||
def get_last_scrobbles(self, time_from=None, time_to=None, check=False):
|
||||
"""Given a user, Last.fm api key, and secret key, grab a list of scrobbled
|
||||
tracks"""
|
||||
@ -102,14 +127,12 @@ class LastFM:
|
||||
if time_to:
|
||||
lfm_params["time_to"] = int(time_to.timestamp())
|
||||
|
||||
# if not time_from and not time_to:
|
||||
lfm_params["limit"] = None
|
||||
lfm_params["limit"] = 1 if check else None
|
||||
|
||||
found_scrobbles = self.user.get_recent_tracks(**lfm_params)
|
||||
# TOOD spin this out into a celery task over certain threshold of found scrobbles?
|
||||
|
||||
if check and found_scrobbles:
|
||||
return True
|
||||
if check:
|
||||
return bool(found_scrobbles)
|
||||
|
||||
for scrobble in found_scrobbles:
|
||||
logger.info(f"Processing {scrobble}")
|
||||
@ -163,3 +186,112 @@ class LastFM:
|
||||
}
|
||||
)
|
||||
return scrobbles
|
||||
|
||||
|
||||
@shared_task
|
||||
def dispatch_historical_imports(user_id):
|
||||
"""Find every un-imported month and dispatch a sub-task for each.
|
||||
|
||||
Walks backward month by month from the user's earliest completed
|
||||
``LastFmImport`` (or the current month if none exists) to their
|
||||
first Last.fm scrobble, creating a ``LastFmImport`` record and
|
||||
dispatching ``process_lastfm_import`` for each month that has
|
||||
scrobbles.
|
||||
"""
|
||||
from scrobbles.models import LastFmImport
|
||||
from scrobbles.tasks import process_lastfm_import
|
||||
|
||||
user = get_user_model().objects.filter(id=user_id).first()
|
||||
if not user:
|
||||
logger.error("User %s not found", user_id)
|
||||
return 0
|
||||
|
||||
try:
|
||||
lastfm = LastFM(user)
|
||||
except Exception:
|
||||
logger.exception("Failed to set up LastFM client for user %s", user_id)
|
||||
return 0
|
||||
|
||||
logger.info("Looking up earliest Last.fm scrobble for user %s", user_id)
|
||||
earliest_scrobble = lastfm.get_earliest_scrobble_timestamp()
|
||||
if earliest_scrobble is None:
|
||||
logger.info("User %s has no Last.fm scrobbles", user_id)
|
||||
return 0
|
||||
logger.info("Earliest Last.fm scrobble for user %s: %s", user_id, earliest_scrobble)
|
||||
|
||||
in_progress = LastFmImport.objects.filter(
|
||||
user_id=user_id, processed_finished__isnull=True
|
||||
).exists()
|
||||
if in_progress:
|
||||
logger.info(
|
||||
"User %s has an import still in progress, skipping dispatch",
|
||||
user_id,
|
||||
)
|
||||
return 0
|
||||
|
||||
def _first_of_month(dt):
|
||||
return dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
def _last_of_month(dt):
|
||||
_, last_day = calendar.monthrange(dt.year, dt.month)
|
||||
return dt.replace(
|
||||
day=last_day, hour=23, minute=59, second=59, microsecond=999999
|
||||
)
|
||||
|
||||
earliest = (
|
||||
LastFmImport.objects.filter(user_id=user_id, processed_finished__isnull=False)
|
||||
.order_by("processed_finished")
|
||||
.first()
|
||||
)
|
||||
if earliest:
|
||||
earliest_log_scrobble = earliest.scrobbles().order_by("timestamp").first()
|
||||
cursor = (
|
||||
_first_of_month(earliest_log_scrobble.timestamp)
|
||||
if earliest_log_scrobble
|
||||
else earliest.processed_finished
|
||||
)
|
||||
logger.info(
|
||||
"Found existing import; earliest scrobble %s, cursor set to %s",
|
||||
earliest_log_scrobble.timestamp if earliest_log_scrobble else None,
|
||||
cursor,
|
||||
)
|
||||
else:
|
||||
cursor = timezone.now()
|
||||
logger.info("No existing imports; cursor set to %s", cursor)
|
||||
|
||||
dispatched = 0
|
||||
while True:
|
||||
cursor = _first_of_month(cursor) - relativedelta(days=1)
|
||||
month_start = _first_of_month(cursor)
|
||||
month_end = _last_of_month(cursor)
|
||||
|
||||
if month_end < earliest_scrobble:
|
||||
logger.info("Reached earliest scrobble at %s, stopping", earliest_scrobble)
|
||||
break
|
||||
|
||||
logger.info(
|
||||
"Checking for Last.fm scrobbles in range %s to %s",
|
||||
month_start.date(),
|
||||
month_end.date(),
|
||||
)
|
||||
|
||||
has = lastfm.get_last_scrobbles(
|
||||
time_from=month_start, time_to=month_end, check=True
|
||||
)
|
||||
if not has:
|
||||
continue
|
||||
|
||||
lfm_import = LastFmImport.objects.create(user_id=user_id)
|
||||
process_lastfm_import.delay(
|
||||
lfm_import.id, time_from=month_start, time_to=month_end
|
||||
)
|
||||
logger.info(
|
||||
"%s – %s dispatched (import %s)",
|
||||
month_start.date(),
|
||||
month_end.date(),
|
||||
lfm_import.id,
|
||||
)
|
||||
dispatched += 1
|
||||
|
||||
logger.info("Dispatched %d monthly batch(es) for user %s", dispatched, user_id)
|
||||
return dispatched
|
||||
|
||||
121
vrobbler/apps/scrobbles/importers/scale.py
Normal file
121
vrobbler/apps/scrobbles/importers/scale.py
Normal file
@ -0,0 +1,121 @@
|
||||
import csv
|
||||
import logging
|
||||
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__)
|
||||
|
||||
FLOAT_COLUMNS = {
|
||||
"WEIGHT": "weight",
|
||||
"BMI": "bmi",
|
||||
"BODY_FAT": "body_fat",
|
||||
"BONE": "bone",
|
||||
"MUSCLE": "muscle",
|
||||
"WATER": "water",
|
||||
"VISCERAL_FAT": "visceral_fat",
|
||||
"WAIST": "waist",
|
||||
"CALORIES": "calories",
|
||||
"BMR": "bmr",
|
||||
"TDEE": "tdee",
|
||||
"HEART_RATE": "heart_rate",
|
||||
"CHEST": "chest",
|
||||
"BICEPS": "biceps",
|
||||
"NECK": "neck",
|
||||
"THIGH": "thigh",
|
||||
"HIPS": "hips",
|
||||
"CALIPER": "caliper",
|
||||
"CALIPER_1": "caliper_1",
|
||||
"CALIPER_2": "caliper_2",
|
||||
"CALIPER_3": "caliper_3",
|
||||
"LBM": "lbm",
|
||||
"WHR": "whr",
|
||||
"WHTR": "whtr",
|
||||
}
|
||||
|
||||
|
||||
def parse_float(value):
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return round(float(value), 2)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def import_scale_csv(file_path, user_id):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
weigh_in = Task.find_or_create("Weigh-in")
|
||||
new_scrobbles = []
|
||||
|
||||
with open(file_path, newline="", encoding="utf-8-sig") as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
date_str = (row.get("DATE") or "").strip()
|
||||
time_str = (row.get("TIME") or "").strip()
|
||||
if not date_str or not time_str:
|
||||
logger.warning("Skipping row with no DATE or TIME")
|
||||
continue
|
||||
|
||||
try:
|
||||
dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M:%S.%f")
|
||||
except ValueError:
|
||||
try:
|
||||
dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
logger.warning(f"Could not parse date/time: {date_str} {time_str}")
|
||||
continue
|
||||
|
||||
dt = dt.replace(microsecond=0)
|
||||
stop = user.profile.get_timestamp_with_tz(dt)
|
||||
start = stop - timedelta(minutes=5)
|
||||
|
||||
log_dict = {}
|
||||
for csv_col, log_key in FLOAT_COLUMNS.items():
|
||||
val = parse_float(row.get(csv_col))
|
||||
if val is not None:
|
||||
log_dict[log_key] = val
|
||||
|
||||
comment = (row.get("COMMENT") or "").strip()
|
||||
if comment:
|
||||
log_dict["comment"] = comment
|
||||
|
||||
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,
|
||||
user=user,
|
||||
).first()
|
||||
if existing:
|
||||
logger.debug(f"Skipping existing scrobble for {start}")
|
||||
continue
|
||||
|
||||
new_scrobble = Scrobble(
|
||||
user=user,
|
||||
timestamp=start,
|
||||
stop_timestamp=stop,
|
||||
timezone=stop.tzinfo.name,
|
||||
source="openScale CSV Import",
|
||||
task=weigh_in,
|
||||
log=log_dict,
|
||||
played_to_completion=True,
|
||||
in_progress=False,
|
||||
media_type=Scrobble.MediaType.TASK,
|
||||
)
|
||||
new_scrobbles.append(new_scrobble)
|
||||
|
||||
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
|
||||
334
vrobbler/apps/scrobbles/importers/trail_gpx.py
Normal file
334
vrobbler/apps/scrobbles/importers/trail_gpx.py
Normal file
@ -0,0 +1,334 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import timezone as dt_timezone
|
||||
from math import asin, cos, radians, sin, sqrt
|
||||
from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
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__)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def haversine(lat1, lon1, lat2, lon2):
|
||||
R = 6371000
|
||||
dlat = radians(lat2 - lat1)
|
||||
dlon = radians(lon2 - lon1)
|
||||
a = sin(dlat / 2) ** 2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon / 2) ** 2
|
||||
return R * 2 * asin(sqrt(a))
|
||||
|
||||
|
||||
def find_route_waypoint(points):
|
||||
if not points:
|
||||
return None
|
||||
total = 0.0
|
||||
for i in range(1, len(points)):
|
||||
lat1, lon1 = points[i - 1][0], points[i - 1][1]
|
||||
lat2, lon2 = points[i][0], points[i][1]
|
||||
if lat1 is not None and lon1 is not None and lat2 is not None and lon2 is not None:
|
||||
total += haversine(lat1, lon1, lat2, lon2)
|
||||
halfway = total / 2.0
|
||||
cumulative = 0.0
|
||||
for i in range(1, len(points)):
|
||||
lat1, lon1 = points[i - 1][0], points[i - 1][1]
|
||||
lat2, lon2 = points[i][0], points[i][1]
|
||||
if lat1 is not None and lon1 is not None and lat2 is not None and lon2 is not None:
|
||||
cumulative += haversine(lat1, lon1, lat2, lon2)
|
||||
if cumulative >= halfway:
|
||||
return (lat2, lon2)
|
||||
return (points[-1][0], points[-1][1])
|
||||
|
||||
|
||||
def compute_trail_stats(points):
|
||||
distance_m = 0.0
|
||||
elevation_gain = 0.0
|
||||
first_time = None
|
||||
last_time = None
|
||||
|
||||
for i in range(1, len(points)):
|
||||
lat1, lon1, ele1, t1 = points[i - 1]
|
||||
lat2, lon2, ele2, t2 = points[i]
|
||||
|
||||
if lat1 is not None and lon1 is not None and lat2 is not None and lon2 is not None:
|
||||
distance_m += haversine(lat1, lon1, lat2, lon2)
|
||||
|
||||
if ele1 is not None and ele2 is not None and ele2 > ele1:
|
||||
elevation_gain += ele2 - ele1
|
||||
|
||||
if first_time is None and t1 is not None:
|
||||
first_time = t1
|
||||
if t2 is not None:
|
||||
last_time = t2
|
||||
|
||||
moving_time = (
|
||||
(last_time - first_time).total_seconds()
|
||||
if first_time and last_time
|
||||
else 0
|
||||
)
|
||||
avg_speed_kmh = (distance_m / 1000) / (moving_time / 3600) if moving_time > 0 else 0.0
|
||||
|
||||
return {
|
||||
"distance_km": round(distance_m / 1000, 2),
|
||||
"elevation_gain_m": round(elevation_gain, 1),
|
||||
"moving_time_seconds": int(moving_time),
|
||||
"avg_speed_kmh": round(avg_speed_kmh, 2),
|
||||
}
|
||||
|
||||
|
||||
def parse_trackpoints(file_path):
|
||||
_, ext = os.path.splitext(file_path)
|
||||
ext = ext.lower()
|
||||
|
||||
if ext == ".gpx":
|
||||
return _parse_gpx(file_path)
|
||||
elif ext == ".fit":
|
||||
return _parse_fit(file_path)
|
||||
else:
|
||||
raise ValueError(f"Unsupported file type: {ext}")
|
||||
|
||||
|
||||
def _parse_gpx(file_path):
|
||||
import gpxpy
|
||||
|
||||
with open(file_path) as f:
|
||||
gpx = gpxpy.parse(f)
|
||||
|
||||
points = []
|
||||
track_name = None
|
||||
description = None
|
||||
extra = {}
|
||||
|
||||
for track in gpx.tracks:
|
||||
if track.name and not track_name:
|
||||
track_name = track.name
|
||||
if track.description and not description:
|
||||
description = track.description
|
||||
if track.comment and not extra:
|
||||
try:
|
||||
cmt_data = json.loads(track.comment)
|
||||
data = cmt_data.get("activity_dict") or cmt_data
|
||||
hr_avg = data.get("average_heartrate")
|
||||
if hr_avg is not None:
|
||||
extra["avg_heartrate"] = int(round(hr_avg))
|
||||
hr_max = data.get("max_heartrate")
|
||||
if hr_max is not None:
|
||||
extra["max_heartrate"] = int(round(hr_max))
|
||||
extra["calories"] = data.get("calories")
|
||||
extra["moving_time_seconds"] = data.get("moving_time")
|
||||
extra["total_elevation_gain_m"] = data.get("total_elevation_gain")
|
||||
avg_speed = data.get("average_speed")
|
||||
if avg_speed is not None:
|
||||
extra["avg_speed_kmh"] = round(avg_speed * 3.6, 2)
|
||||
extra["activity_type"] = cmt_data.get("type") or data.get("type")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
for seg in track.segments:
|
||||
for pt in seg.points:
|
||||
points.append((pt.latitude, pt.longitude, pt.elevation, pt.time))
|
||||
|
||||
return {
|
||||
"points": points,
|
||||
"name": track_name or os.path.splitext(os.path.basename(file_path))[0],
|
||||
"description": description,
|
||||
"extra": extra,
|
||||
}
|
||||
|
||||
|
||||
def _parse_fit(file_path):
|
||||
import fitparse
|
||||
|
||||
fitfile = fitparse.FitFile(file_path)
|
||||
messages = list(fitfile.get_messages("record"))
|
||||
|
||||
points = []
|
||||
track_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
extra = {}
|
||||
|
||||
session_msgs = list(fitfile.get_messages("session"))
|
||||
for msg in session_msgs:
|
||||
for field in msg:
|
||||
if field.name == "sport" and field.value:
|
||||
track_name = str(field.value)
|
||||
elif field.name == "total_distance" and field.value:
|
||||
extra["distance_km"] = round(field.value / 1000, 2)
|
||||
elif field.name == "total_timer_time" and field.value:
|
||||
extra["moving_time_seconds"] = int(field.value)
|
||||
elif field.name == "total_elapsed_time" and field.value:
|
||||
extra.setdefault("moving_time_seconds", int(field.value))
|
||||
elif field.name == "total_ascent" and field.value:
|
||||
extra["total_elevation_gain_m"] = field.value
|
||||
elif field.name == "avg_heart_rate" and field.value:
|
||||
extra["avg_heartrate"] = int(round(field.value))
|
||||
elif field.name == "max_heart_rate" and field.value:
|
||||
extra["max_heartrate"] = int(round(field.value))
|
||||
elif field.name == "avg_speed" and field.value:
|
||||
extra["avg_speed_kmh"] = round(field.value * 3.6, 2)
|
||||
elif field.name == "total_calories" and field.value:
|
||||
extra["calories"] = int(field.value)
|
||||
|
||||
for msg in messages:
|
||||
lat = None
|
||||
lon = None
|
||||
ele = None
|
||||
t = None
|
||||
for field in msg:
|
||||
if field.name == "position_lat":
|
||||
lat = field.value * (180.0 / (2**31)) if field.value else None
|
||||
elif field.name == "position_long":
|
||||
lon = field.value * (180.0 / (2**31)) if field.value else None
|
||||
elif field.name == "altitude":
|
||||
ele = field.value
|
||||
elif field.name == "timestamp":
|
||||
t = field.value.replace(tzinfo=dt_timezone.utc) if field.value else None
|
||||
if lat is not None and lon is not None:
|
||||
points.append((lat, lon, ele, t))
|
||||
|
||||
return {
|
||||
"points": points,
|
||||
"name": track_name,
|
||||
"description": None,
|
||||
"extra": extra,
|
||||
}
|
||||
|
||||
|
||||
def convert_fit_to_gpx(file_path):
|
||||
import gpxpy.gpx
|
||||
|
||||
result = _parse_fit(file_path)
|
||||
points = result["points"]
|
||||
track_name = result["name"]
|
||||
gpx = gpxpy.gpx.GPX()
|
||||
gpx_track = gpxpy.gpx.GPXTrack(name=track_name)
|
||||
gpx.tracks.append(gpx_track)
|
||||
gpx_seg = gpxpy.gpx.GPXTrackSegment()
|
||||
gpx_track.segments.append(gpx_seg)
|
||||
|
||||
for lat, lon, ele, t in points:
|
||||
gpx_seg.points.append(gpxpy.gpx.GPXTrackPoint(lat, lon, elevation=ele, time=t))
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".gpx", delete=False)
|
||||
tmp.write(gpx.to_xml())
|
||||
tmp.close()
|
||||
return tmp.name
|
||||
|
||||
|
||||
def import_trail_gpx(file_path, user_id, original_filename=None):
|
||||
user = User.objects.get(id=user_id)
|
||||
new_scrobbles = []
|
||||
|
||||
result = parse_trackpoints(file_path)
|
||||
points = result["points"]
|
||||
track_name = result["name"]
|
||||
description = result.get("description")
|
||||
extra = result.get("extra", {})
|
||||
|
||||
if not points:
|
||||
logger.warning(f"No trackpoints found in {file_path}")
|
||||
return []
|
||||
|
||||
first_lat, first_lon, _, first_time = points[0]
|
||||
_, _, _, last_time = points[-1]
|
||||
|
||||
if first_time is None:
|
||||
logger.warning(f"No timestamps in {file_path}")
|
||||
return []
|
||||
|
||||
route_pt = find_route_waypoint(points)
|
||||
|
||||
geo, _ = GeoLocation.objects.get_or_create(
|
||||
lat=round(first_lat, 6),
|
||||
lon=round(first_lon, 6),
|
||||
defaults={"altitude": None},
|
||||
)
|
||||
|
||||
trail = Trail.find_by_trailhead(
|
||||
first_lat, first_lon,
|
||||
route_lat=route_pt[0] if route_pt else None,
|
||||
route_lon=route_pt[1] if route_pt else None,
|
||||
tolerance_m=100,
|
||||
)
|
||||
if not trail:
|
||||
trail = Trail.objects.create(
|
||||
title=track_name,
|
||||
trailhead_location=geo,
|
||||
route_lat=route_pt[0] if route_pt else None,
|
||||
route_lon=route_pt[1] if route_pt else None,
|
||||
)
|
||||
|
||||
timestamp = first_time
|
||||
stop_timestamp = last_time
|
||||
|
||||
existing = Scrobble.objects.filter(
|
||||
timestamp=timestamp,
|
||||
trail=trail,
|
||||
user=user,
|
||||
).first()
|
||||
if existing:
|
||||
logger.debug(f"Skipping existing scrobble for trail {trail}")
|
||||
return []
|
||||
|
||||
computed = compute_trail_stats(points)
|
||||
|
||||
logdata = TrailLogData(
|
||||
description=description,
|
||||
distance_km=computed["distance_km"],
|
||||
elevation_gain_m=computed["elevation_gain_m"],
|
||||
moving_time_seconds=computed["moving_time_seconds"],
|
||||
activity_type=extra.get("activity_type"),
|
||||
avg_speed_kmh=computed["avg_speed_kmh"],
|
||||
avg_heartrate=extra.get("avg_heartrate"),
|
||||
max_heartrate=extra.get("max_heartrate"),
|
||||
calories=extra.get("calories"),
|
||||
)
|
||||
|
||||
duration = int((stop_timestamp - timestamp).total_seconds()) if stop_timestamp else 0
|
||||
|
||||
scrobble = Scrobble(
|
||||
user=user,
|
||||
timestamp=timestamp,
|
||||
stop_timestamp=stop_timestamp,
|
||||
playback_position_seconds=duration,
|
||||
source="GPX Import",
|
||||
trail=trail,
|
||||
log=logdata.asdict,
|
||||
timezone=user.profile.timezone or settings.TIME_ZONE,
|
||||
played_to_completion=True,
|
||||
in_progress=False,
|
||||
media_type=Scrobble.MediaType.TRAIL,
|
||||
)
|
||||
|
||||
_, ext = os.path.splitext(file_path)
|
||||
if ext.lower() == ".fit":
|
||||
gpx_path = convert_fit_to_gpx(file_path)
|
||||
with open(gpx_path, "rb") as f:
|
||||
scrobble.gpx_file.save(
|
||||
f"{original_filename or 'trail'}.gpx",
|
||||
File(f),
|
||||
save=False,
|
||||
)
|
||||
os.unlink(gpx_path)
|
||||
else:
|
||||
with open(file_path, "rb") as f:
|
||||
scrobble.gpx_file.save(
|
||||
original_filename or os.path.basename(file_path),
|
||||
File(f),
|
||||
save=False,
|
||||
)
|
||||
|
||||
new_scrobbles.append(scrobble)
|
||||
|
||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||
logger.info(f"Created {len(created)} trail scrobbles")
|
||||
for scrobble in created:
|
||||
ScrobbleNtfyNotification(scrobble).send()
|
||||
return created
|
||||
@ -1,81 +1,813 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
|
||||
from books.koreader import fetch_file_from_webdav
|
||||
from profiles.models import UserProfile
|
||||
from scrobbles.models import KoReaderImport
|
||||
from scrobbles.tasks import process_koreader_import
|
||||
from scrobbles.models import KoReaderImport, TrailGPXImport
|
||||
from scrobbles.tasks import process_koreader_import, process_trail_gpx_import
|
||||
from scrobbles.utils import get_file_md5_hash
|
||||
from webdav.client import get_webdav_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_GPX_PATH = "var/gpx/"
|
||||
DEFAULT_KOREADER_PATH = "var/koreader/"
|
||||
DEFAULT_RETROARCH_PATH = "var/retroarch/"
|
||||
DEFAULT_BGSTATS_PATH = "var/bgstats/"
|
||||
DEFAULT_EBIRD_PATH = "var/ebird/"
|
||||
DEFAULT_SCALE_PATH = "var/scale/"
|
||||
|
||||
def import_from_webdav_for_all_users(restart=False):
|
||||
"""Grab a list of all users with WebDAV enabled and kickoff imports for them"""
|
||||
|
||||
# WebDavImport = apps.get_model("scrobbles", "WebDavImport")
|
||||
def import_from_webdav_for_all_users(
|
||||
restart=False,
|
||||
update_retroarch_hash=False,
|
||||
update_koreader_etag=False,
|
||||
include_processed=False,
|
||||
):
|
||||
"""Iterate all WebDAV-enabled users, scanning each media-type directory."""
|
||||
webdav_enabled_user_ids = UserProfile.objects.filter(
|
||||
webdav_url__isnull=False,
|
||||
webdav_user__isnull=False,
|
||||
webdav_pass__isnull=False,
|
||||
webdav_auto_import=True,
|
||||
).values_list("user_id", flat=True)
|
||||
if include_processed:
|
||||
logger.warning(
|
||||
"Re-importing previously-processed files from processed/ subdirectories "
|
||||
"— this may create duplicate scrobbles."
|
||||
)
|
||||
|
||||
logger.info(f"Start import of {webdav_enabled_user_ids.count()} webdav accounts")
|
||||
|
||||
koreader_import_count = 0
|
||||
ko_count = 0
|
||||
gpx_count = 0
|
||||
retro_count = 0
|
||||
bgstats_count = 0
|
||||
ebird_count = 0
|
||||
scale_count = 0
|
||||
|
||||
for user_id in webdav_enabled_user_ids:
|
||||
webdav_client = get_webdav_client(user_id)
|
||||
client = get_webdav_client(user_id)
|
||||
logger.info("Scanning WebDAV for user %s", user_id)
|
||||
|
||||
try:
|
||||
webdav_client.info("var/koreader/statistics.sqlite3")
|
||||
koreader_found = True
|
||||
except:
|
||||
koreader_found = False
|
||||
logger.info("Scanning WebDAV koreader for user %s", user_id)
|
||||
ko_count += scan_webdav_for_koreader(
|
||||
client, user_id, restart, update_etag_only=update_koreader_etag
|
||||
)
|
||||
logger.info("Scanning WebDAV gpx for user %s", user_id)
|
||||
gpx_count += scan_webdav_for_gpx(
|
||||
client, user_id, include_processed=include_processed
|
||||
)
|
||||
logger.info("Scanning WebDAV retroarch for user %s", user_id)
|
||||
retro_count += scan_webdav_for_retroarch(
|
||||
client,
|
||||
user_id,
|
||||
update_hash_only=update_retroarch_hash,
|
||||
include_processed=include_processed,
|
||||
)
|
||||
logger.info("Scanning WebDAV bgstats for user %s", user_id)
|
||||
bgstats_count += scan_webdav_for_bgstats(
|
||||
client, user_id, include_processed=include_processed
|
||||
)
|
||||
logger.info("Scanning WebDAV ebird for user %s", user_id)
|
||||
ebird_count += scan_webdav_for_ebird(
|
||||
client, user_id, include_processed=include_processed
|
||||
)
|
||||
logger.info("Scanning WebDAV scale for user %s", user_id)
|
||||
scale_count += scan_webdav_for_scale(client, user_id)
|
||||
|
||||
logger.info(
|
||||
"Started %d KOReader, %d Trail GPX, %d Retroarch, %d BGStats, %d eBird, %d Scale WebDAV imports",
|
||||
ko_count,
|
||||
gpx_count,
|
||||
retro_count,
|
||||
bgstats_count,
|
||||
ebird_count,
|
||||
scale_count,
|
||||
extra={
|
||||
"koreader": ko_count,
|
||||
"trail_gpx": gpx_count,
|
||||
"retroarch": retro_count,
|
||||
"bgstats": bgstats_count,
|
||||
"ebird": ebird_count,
|
||||
"scale": scale_count,
|
||||
},
|
||||
)
|
||||
return ko_count, gpx_count, retro_count, bgstats_count, ebird_count, scale_count
|
||||
|
||||
|
||||
def scan_webdav_for_koreader(
|
||||
webdav_client, user_id, restart=False, update_etag_only=False
|
||||
):
|
||||
"""Check for koreader statistics.sqlite3 and queue import if changed.
|
||||
|
||||
Uses the remote file's ETag (from a single PROPFIND) to detect changes
|
||||
without downloading the file, making the common no-change case fast.
|
||||
|
||||
When *update_etag_only* is True, stores the remote ETag on the last
|
||||
completed import without re-downloading — useful when migrating to the
|
||||
ETag-based check to avoid a one-time re-import.
|
||||
"""
|
||||
if not update_etag_only:
|
||||
# Don't queue if one is still being processed
|
||||
if KoReaderImport.objects.filter(
|
||||
user_id=user_id, processed_finished__isnull=True
|
||||
).exists():
|
||||
logger.info(
|
||||
"No koreader stats file found on webdav",
|
||||
"KoReader import already pending for user",
|
||||
extra={"user_id": user_id},
|
||||
)
|
||||
return 0
|
||||
|
||||
try:
|
||||
files = webdav_client.list(DEFAULT_KOREADER_PATH, get_info=True)
|
||||
except:
|
||||
logger.info(
|
||||
"No var/koreader/ directory on webdav",
|
||||
extra={"user_id": user_id},
|
||||
)
|
||||
return 0
|
||||
|
||||
sqlite_info = None
|
||||
for f in files:
|
||||
if "statistics.sqlite3" in f.get("path"):
|
||||
sqlite_info = f
|
||||
break
|
||||
|
||||
if not sqlite_info:
|
||||
logger.info(
|
||||
"No statistics.sqlite3 found in var/koreader/",
|
||||
extra={"user_id": user_id},
|
||||
)
|
||||
return 0
|
||||
|
||||
remote_etag = sqlite_info.get("etag") or ""
|
||||
|
||||
last_import = (
|
||||
KoReaderImport.objects.filter(user_id=user_id, processed_finished__isnull=False)
|
||||
.order_by("processed_finished")
|
||||
.last()
|
||||
)
|
||||
|
||||
if update_etag_only:
|
||||
if last_import and remote_etag:
|
||||
last_import.webdav_etag = remote_etag
|
||||
last_import.save(update_fields=["webdav_etag"])
|
||||
logger.info(
|
||||
"Updated KoReader webdav_etag (%s) without re-import",
|
||||
remote_etag[:16],
|
||||
extra={"user_id": user_id, "last_import_id": last_import.id},
|
||||
)
|
||||
return 0
|
||||
|
||||
if last_import and last_import.webdav_etag and remote_etag:
|
||||
if last_import.webdav_etag == remote_etag:
|
||||
logger.info(
|
||||
"koreader stats file unchanged (ETag match)",
|
||||
extra={
|
||||
"user_id": user_id,
|
||||
"etag": remote_etag,
|
||||
"last_import_id": last_import.id,
|
||||
},
|
||||
)
|
||||
return 0
|
||||
|
||||
file_path = fetch_file_from_webdav(user_id)
|
||||
if not file_path:
|
||||
logger.warning(
|
||||
"Could not fetch koreader file from webdav",
|
||||
extra={"user_id": user_id},
|
||||
)
|
||||
return 0
|
||||
|
||||
new_hash = get_file_md5_hash(file_path)
|
||||
old_hash = last_import.file_md5_hash() if last_import else None
|
||||
|
||||
if last_import and not remote_etag and new_hash == old_hash:
|
||||
logger.info(
|
||||
"koreader stats file has not changed (content hash match)",
|
||||
extra={
|
||||
"user_id": user_id,
|
||||
"new_hash": new_hash,
|
||||
"old_hash": old_hash,
|
||||
"last_import_id": last_import.id,
|
||||
},
|
||||
)
|
||||
os.unlink(file_path)
|
||||
return 0
|
||||
|
||||
koreader_import, created = KoReaderImport.objects.get_or_create(
|
||||
user_id=user_id, processed_finished__isnull=True
|
||||
)
|
||||
|
||||
if not created and not restart:
|
||||
logger.info(
|
||||
f"Not resuming failed KoReader import {koreader_import.id} "
|
||||
f"for user {user_id}, use restart=True to restart"
|
||||
)
|
||||
os.unlink(file_path)
|
||||
return 0
|
||||
|
||||
koreader_import.webdav_etag = remote_etag
|
||||
koreader_import.save_sqlite_file_to_self(file_path)
|
||||
process_koreader_import.delay(koreader_import.id)
|
||||
os.unlink(file_path)
|
||||
return 1
|
||||
|
||||
|
||||
def scan_webdav_for_gpx(webdav_client, user_id, include_processed=False):
|
||||
gpx_path = DEFAULT_GPX_PATH # TODO allow this to be configured in user settings
|
||||
try:
|
||||
webdav_client.info(DEFAULT_GPX_PATH)
|
||||
logger.info("var/gpx/ found, listing files")
|
||||
except:
|
||||
logger.info("No var/gpx/ directory on webdav", extra={"user_id": user_id})
|
||||
return 0
|
||||
|
||||
try:
|
||||
files = webdav_client.list(gpx_path)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not list var/gpx/", extra={"user_id": user_id, "error": str(e)}
|
||||
)
|
||||
return 0
|
||||
|
||||
gpx_extensions = (".gpx", ".fit")
|
||||
processed_dir = f"{gpx_path}processed/"
|
||||
try:
|
||||
webdav_client.mkdir(processed_dir, recursive=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
new_imports = 0
|
||||
|
||||
for fname in files:
|
||||
fname = os.path.basename(fname)
|
||||
if not fname.lower().endswith(gpx_extensions):
|
||||
continue
|
||||
if fname == "processed":
|
||||
continue
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname)
|
||||
try:
|
||||
webdav_client.download_sync(
|
||||
remote_path=f"{gpx_path}{fname}", local_path=tmp.name
|
||||
)
|
||||
imp = TrailGPXImport.objects.create(
|
||||
user_id=user_id,
|
||||
original_filename=fname,
|
||||
)
|
||||
with open(tmp.name, "rb") as f:
|
||||
imp.gpx_file.save(fname, f, save=True)
|
||||
|
||||
stem, ext = os.path.splitext(fname)
|
||||
ts = datetime.now().strftime("%Y%m%dT%H%M%S")
|
||||
webdav_client.move(
|
||||
f"{gpx_path}{fname}",
|
||||
f"{processed_dir}{stem}_{ts}{ext}",
|
||||
)
|
||||
|
||||
process_trail_gpx_import.delay(imp.id)
|
||||
new_imports += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to import {fname}: {e}")
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
|
||||
if include_processed:
|
||||
try:
|
||||
processed_files = webdav_client.list(processed_dir)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not list var/gpx/processed/",
|
||||
extra={"user_id": user_id, "error": str(e)},
|
||||
)
|
||||
return new_imports
|
||||
|
||||
for fname in processed_files:
|
||||
fname = os.path.basename(fname)
|
||||
if not fname.lower().endswith(gpx_extensions):
|
||||
continue
|
||||
|
||||
remote_path = f"{processed_dir}{fname}"
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname)
|
||||
try:
|
||||
webdav_client.download_sync(
|
||||
remote_path=remote_path, local_path=tmp.name
|
||||
)
|
||||
imp = TrailGPXImport.objects.create(
|
||||
user_id=user_id,
|
||||
original_filename=fname,
|
||||
)
|
||||
with open(tmp.name, "rb") as f:
|
||||
imp.gpx_file.save(fname, f, save=True)
|
||||
|
||||
process_trail_gpx_import.delay(imp.id)
|
||||
new_imports += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to import processed GPX file {fname}: {e}")
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
|
||||
return new_imports
|
||||
|
||||
|
||||
def scan_webdav_for_retroarch(
|
||||
webdav_client, user_id, update_hash_only=False, include_processed=False
|
||||
):
|
||||
"""Check for new .lrtl files on WebDAV, download+import if changed.
|
||||
|
||||
Uses ETags from a single PROPFIND to detect changes without downloading
|
||||
any file content, making the common no-change case very fast.
|
||||
|
||||
When *update_hash_only* is True, mismatched hashes update the last import's
|
||||
stored hash without re-downloading — useful when migrating to a new hash
|
||||
scheme to avoid a one-time re-import.
|
||||
|
||||
When *include_processed* is True, also imports files from the processed/
|
||||
subdirectory. These are sorted chronologically by their YYYYMMDDHHMM-
|
||||
timestamp prefix so that the importer always sees the earliest snapshot
|
||||
first, which is critical for correct incremental scrobbling since
|
||||
retroarch overwrites the same .lrtl filename.
|
||||
"""
|
||||
import hashlib
|
||||
import shutil
|
||||
import re
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
|
||||
from scrobbles.models import RetroarchImport
|
||||
from scrobbles.tasks import process_retroarch_import
|
||||
|
||||
retroarch_path = DEFAULT_RETROARCH_PATH
|
||||
processed_dir = f"{retroarch_path}processed/"
|
||||
try:
|
||||
webdav_client.info(retroarch_path)
|
||||
except:
|
||||
logger.info("No var/retroarch/ directory on webdav", extra={"user_id": user_id})
|
||||
return 0
|
||||
|
||||
try:
|
||||
webdav_client.mkdir(processed_dir, recursive=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Collect files from root directory
|
||||
try:
|
||||
root_files = webdav_client.list(retroarch_path, get_info=True)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not list var/retroarch/",
|
||||
extra={"user_id": user_id, "error": str(e)},
|
||||
)
|
||||
return 0
|
||||
|
||||
# Extract basename from path since the webdav adapter returns name=None
|
||||
for f in root_files:
|
||||
if f.get("path") and not f.get("name"):
|
||||
f["name"] = os.path.basename(f["path"])
|
||||
|
||||
lrtl_files = [
|
||||
f for f in root_files
|
||||
if (f.get("name") or "").lower().endswith(".lrtl")
|
||||
]
|
||||
|
||||
# Optionally include historical files from processed/
|
||||
if include_processed:
|
||||
try:
|
||||
processed_files = webdav_client.list(processed_dir, get_info=True)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not list var/retroarch/processed/",
|
||||
extra={"user_id": user_id, "error": str(e)},
|
||||
)
|
||||
else:
|
||||
for f in processed_files:
|
||||
if f.get("path") and not f.get("name"):
|
||||
f["name"] = os.path.basename(f["path"])
|
||||
lrtl_files.extend([
|
||||
f for f in processed_files
|
||||
if (f.get("name") or "").lower().endswith(".lrtl")
|
||||
])
|
||||
|
||||
if not lrtl_files:
|
||||
logger.info("No .lrtl files found on webdav", extra={"user_id": user_id})
|
||||
return 0
|
||||
|
||||
# Sort chronologically: processed files (timestamp prefixed) first,
|
||||
# current root files last.
|
||||
ts_pattern = re.compile(r"^(\d{12})-")
|
||||
def sort_key(f):
|
||||
name = f.get("name") or ""
|
||||
m = ts_pattern.match(name)
|
||||
if m:
|
||||
return m.group(1)
|
||||
# Root files (no timestamp) go after all historical snapshots
|
||||
return "999999999999"
|
||||
|
||||
lrtl_files.sort(key=sort_key)
|
||||
|
||||
# Don't queue if one is still being processed
|
||||
if RetroarchImport.objects.filter(
|
||||
user_id=user_id, processed_finished__isnull=True
|
||||
).exists():
|
||||
logger.info(
|
||||
"Retroarch import already pending for user",
|
||||
extra={"user_id": user_id},
|
||||
)
|
||||
return 0
|
||||
|
||||
# Compute hash from filenames + ETags (no downloads needed)
|
||||
hasher = hashlib.md5()
|
||||
for f in lrtl_files:
|
||||
hasher.update((f.get("name") or "").encode())
|
||||
hasher.update((f.get("etag") or f.get("modified") or "").encode())
|
||||
content_hash = hasher.hexdigest()
|
||||
|
||||
last_import = (
|
||||
RetroarchImport.objects.filter(
|
||||
user_id=user_id, processed_finished__isnull=False
|
||||
)
|
||||
.order_by("-processed_finished")
|
||||
.first()
|
||||
)
|
||||
|
||||
# Skip unchanged root files only, never skip when re-processing history
|
||||
if not include_processed and last_import and last_import.files_hash == content_hash:
|
||||
logger.info(
|
||||
"Retroarch lrtl files unchanged for user, skipping",
|
||||
extra={"user_id": user_id},
|
||||
)
|
||||
return 0
|
||||
|
||||
if update_hash_only and last_import:
|
||||
logger.info(
|
||||
"Updating retroarch files_hash (%s) without re-import",
|
||||
content_hash[:12],
|
||||
extra={"user_id": user_id},
|
||||
)
|
||||
last_import.files_hash = content_hash
|
||||
last_import.save(update_fields=["files_hash"])
|
||||
return 0
|
||||
|
||||
# Something changed — download everything
|
||||
download_dir = tempfile.mkdtemp()
|
||||
try:
|
||||
downloaded = []
|
||||
imported_filenames = []
|
||||
for f in lrtl_files:
|
||||
basename = f.get("name")
|
||||
# Determine source directory: processed files already live under processed/
|
||||
is_processed = ts_pattern.match(basename)
|
||||
source_dir = processed_dir if is_processed else retroarch_path
|
||||
|
||||
remote_path = f"{source_dir}{basename}"
|
||||
dst = os.path.join(download_dir, basename)
|
||||
try:
|
||||
webdav_client.download_sync(
|
||||
remote_path=remote_path,
|
||||
local_path=dst,
|
||||
)
|
||||
downloaded.append(basename)
|
||||
|
||||
if is_processed:
|
||||
imported_filenames.append(basename)
|
||||
else:
|
||||
ts = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
processed_name = f"{ts}-{basename}"
|
||||
imported_filenames.append(processed_name)
|
||||
webdav_client.move(
|
||||
remote_path,
|
||||
f"{processed_dir}{processed_name}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to download retroarch %s: %s", basename, e)
|
||||
|
||||
if not downloaded:
|
||||
return 0
|
||||
|
||||
zip_path = os.path.join(
|
||||
download_dir,
|
||||
f"retroarch-batch-{datetime.now().strftime('%Y%m%d%H%M%S')}.zip",
|
||||
)
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for basename in downloaded:
|
||||
zf.write(os.path.join(download_dir, basename), basename)
|
||||
|
||||
imp = RetroarchImport.objects.create(
|
||||
user_id=user_id,
|
||||
original_filename=", ".join(imported_filenames),
|
||||
files_hash=content_hash,
|
||||
)
|
||||
with open(zip_path, "rb") as f:
|
||||
imp.lrtl_file.save(f"retroarch-batch-{imp.uuid}.zip", f, save=True)
|
||||
process_retroarch_import.delay(imp.id)
|
||||
logger.info(
|
||||
"Queued retroarch import %s with %d file(s) (hash=%s)",
|
||||
imp.uuid,
|
||||
len(downloaded),
|
||||
content_hash[:12],
|
||||
)
|
||||
return 1
|
||||
finally:
|
||||
shutil.rmtree(download_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def scan_webdav_for_bgstats(webdav_client, user_id, include_processed=False):
|
||||
"""Download .bgsplay files from WebDAV and queue imports for new files.
|
||||
|
||||
After importing, files are moved to var/bgstats/processed/ so they are
|
||||
not re-imported on subsequent scans unless *include_processed* is True.
|
||||
"""
|
||||
from scrobbles.models import BGStatsImport
|
||||
from scrobbles.tasks import process_bgstats_import
|
||||
|
||||
bgstats_path = DEFAULT_BGSTATS_PATH
|
||||
try:
|
||||
webdav_client.info(bgstats_path)
|
||||
except:
|
||||
logger.info("No var/bgstats/ directory on webdav", extra={"user_id": user_id})
|
||||
return 0
|
||||
|
||||
try:
|
||||
files = webdav_client.list(bgstats_path)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not list var/bgstats/",
|
||||
extra={"user_id": user_id, "error": str(e)},
|
||||
)
|
||||
return 0
|
||||
|
||||
processed_dir = f"{bgstats_path}processed/"
|
||||
try:
|
||||
webdav_client.mkdir(processed_dir, recursive=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
new_imports = 0
|
||||
already_imported = set(
|
||||
BGStatsImport.objects.filter(user_id=user_id).values_list(
|
||||
"original_filename", flat=True
|
||||
)
|
||||
)
|
||||
|
||||
for fname in files:
|
||||
fname = os.path.basename(fname)
|
||||
if not fname.lower().endswith(".bgsplay"):
|
||||
continue
|
||||
if fname == "processed":
|
||||
continue
|
||||
if fname in already_imported:
|
||||
logger.debug(f"Skipping already-imported {fname}")
|
||||
continue
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname)
|
||||
try:
|
||||
webdav_client.download_sync(
|
||||
remote_path=f"{bgstats_path}{fname}", local_path=tmp.name
|
||||
)
|
||||
imp = BGStatsImport.objects.create(
|
||||
user_id=user_id,
|
||||
original_filename=fname,
|
||||
)
|
||||
with open(tmp.name, "rb") as f:
|
||||
imp.bgsplay_file.save(fname, f, save=True)
|
||||
|
||||
stem, ext = os.path.splitext(fname)
|
||||
ts = datetime.now().strftime("%Y%m%dT%H%M%S")
|
||||
webdav_client.move(
|
||||
f"{bgstats_path}{fname}",
|
||||
f"{processed_dir}{stem}_{ts}{ext}",
|
||||
)
|
||||
|
||||
process_bgstats_import.delay(imp.id)
|
||||
new_imports += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to import BG Stats file {fname}: {e}")
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
|
||||
if include_processed:
|
||||
try:
|
||||
processed_files = webdav_client.list(processed_dir)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not list var/bgstats/processed/",
|
||||
extra={"user_id": user_id, "error": str(e)},
|
||||
)
|
||||
return new_imports
|
||||
|
||||
for fname in processed_files:
|
||||
fname = os.path.basename(fname)
|
||||
if not fname.lower().endswith(".bgsplay"):
|
||||
continue
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname)
|
||||
try:
|
||||
webdav_client.download_sync(
|
||||
remote_path=f"{processed_dir}{fname}", local_path=tmp.name
|
||||
)
|
||||
imp = BGStatsImport.objects.create(
|
||||
user_id=user_id,
|
||||
original_filename=fname,
|
||||
)
|
||||
with open(tmp.name, "rb") as f:
|
||||
imp.bgsplay_file.save(fname, f, save=True)
|
||||
process_bgstats_import.delay(imp.id)
|
||||
new_imports += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to import processed BG Stats file {fname}: {e}")
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
|
||||
return new_imports
|
||||
|
||||
|
||||
def scan_webdav_for_ebird(webdav_client, user_id, include_processed=False):
|
||||
"""Download .csv files from WebDAV var/ebird/ and queue imports for new files.
|
||||
|
||||
After importing, files are moved to var/ebird/processed/ so they are
|
||||
not re-imported on subsequent scans unless *include_processed* is True.
|
||||
"""
|
||||
from scrobbles.models import EBirdCSVImport
|
||||
from scrobbles.tasks import process_ebird_csv_import
|
||||
|
||||
ebird_path = DEFAULT_EBIRD_PATH
|
||||
try:
|
||||
webdav_client.info(ebird_path)
|
||||
except:
|
||||
logger.info("No var/ebird/ directory on webdav", extra={"user_id": user_id})
|
||||
return 0
|
||||
|
||||
try:
|
||||
files = webdav_client.list(ebird_path)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not list var/ebird/",
|
||||
extra={"user_id": user_id, "error": str(e)},
|
||||
)
|
||||
return 0
|
||||
|
||||
processed_dir = f"{ebird_path}processed/"
|
||||
try:
|
||||
webdav_client.mkdir(processed_dir, recursive=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
new_imports = 0
|
||||
already_imported = set(
|
||||
EBirdCSVImport.objects.filter(user_id=user_id).values_list(
|
||||
"original_filename", flat=True
|
||||
)
|
||||
)
|
||||
|
||||
for fname in files:
|
||||
fname = os.path.basename(fname)
|
||||
if not fname.lower().endswith(".csv"):
|
||||
continue
|
||||
if fname == "processed":
|
||||
continue
|
||||
if fname in already_imported:
|
||||
logger.debug(f"Skipping already-imported {fname}")
|
||||
continue
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname)
|
||||
try:
|
||||
webdav_client.download_sync(
|
||||
remote_path=f"{ebird_path}{fname}", local_path=tmp.name
|
||||
)
|
||||
imp = EBirdCSVImport.objects.create(
|
||||
user_id=user_id,
|
||||
original_filename=fname,
|
||||
)
|
||||
with open(tmp.name, "rb") as f:
|
||||
imp.csv_file.save(fname, f, save=True)
|
||||
|
||||
stem, ext = os.path.splitext(fname)
|
||||
ts = datetime.now().strftime("%Y%m%dT%H%M%S")
|
||||
webdav_client.move(
|
||||
f"{ebird_path}{fname}",
|
||||
f"{processed_dir}{stem}_{ts}{ext}",
|
||||
)
|
||||
|
||||
process_ebird_csv_import.delay(imp.id)
|
||||
new_imports += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to import eBird CSV file {fname}: {e}")
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
|
||||
if include_processed:
|
||||
try:
|
||||
processed_files = webdav_client.list(processed_dir)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not list var/ebird/processed/",
|
||||
extra={"user_id": user_id, "error": str(e)},
|
||||
)
|
||||
return new_imports
|
||||
|
||||
for fname in processed_files:
|
||||
fname = os.path.basename(fname)
|
||||
if not fname.lower().endswith(".csv"):
|
||||
continue
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname)
|
||||
try:
|
||||
webdav_client.download_sync(
|
||||
remote_path=f"{processed_dir}{fname}", local_path=tmp.name
|
||||
)
|
||||
imp = EBirdCSVImport.objects.create(
|
||||
user_id=user_id,
|
||||
original_filename=fname,
|
||||
)
|
||||
with open(tmp.name, "rb") as f:
|
||||
imp.csv_file.save(fname, f, save=True)
|
||||
process_ebird_csv_import.delay(imp.id)
|
||||
new_imports += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to import processed eBird CSV file {fname}: {e}")
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
|
||||
return new_imports
|
||||
|
||||
|
||||
def scan_webdav_for_scale(webdav_client, user_id):
|
||||
"""Download .csv files from WebDAV var/scale/ and queue imports for new files.
|
||||
|
||||
Because the scale CSV always has the same filename but grows by appending
|
||||
rows, we detect changes by downloading the file and comparing its content
|
||||
hash against the last completed import, rather than checking the filename.
|
||||
"""
|
||||
from scrobbles.models import ScaleCSVImport
|
||||
from scrobbles.tasks import process_scale_csv_import
|
||||
|
||||
scale_path = (
|
||||
DEFAULT_SCALE_PATH # TODO Allow this to be configured in a user profile setting
|
||||
)
|
||||
try:
|
||||
webdav_client.info(scale_path)
|
||||
except:
|
||||
logger.info("No var/scale/ directory on webdav", extra={"user_id": user_id})
|
||||
return 0
|
||||
|
||||
try:
|
||||
files = webdav_client.list(scale_path)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not list var/scale/",
|
||||
extra={"user_id": user_id, "error": str(e)},
|
||||
)
|
||||
return 0
|
||||
|
||||
new_imports = 0
|
||||
|
||||
for fname in files:
|
||||
fname = os.path.basename(fname)
|
||||
if not fname.lower().endswith(".csv"):
|
||||
continue
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname)
|
||||
try:
|
||||
webdav_client.download_sync(
|
||||
remote_path=f"{scale_path}/{fname}", local_path=tmp.name
|
||||
)
|
||||
|
||||
new_hash = get_file_md5_hash(tmp.name)
|
||||
|
||||
if koreader_found:
|
||||
last_import = (
|
||||
KoReaderImport.objects.filter(
|
||||
user_id=user_id, processed_finished__isnull=False
|
||||
ScaleCSVImport.objects.filter(
|
||||
user_id=user_id,
|
||||
original_filename=fname,
|
||||
processed_finished__isnull=False,
|
||||
)
|
||||
.order_by("processed_finished")
|
||||
.last()
|
||||
.order_by("-processed_finished")
|
||||
.first()
|
||||
)
|
||||
|
||||
koreader_file_path = fetch_file_from_webdav(1)
|
||||
new_hash = get_file_md5_hash(koreader_file_path)
|
||||
old_hash = None
|
||||
if last_import:
|
||||
old_hash = last_import.file_md5_hash()
|
||||
|
||||
if old_hash and new_hash == old_hash:
|
||||
logger.info(
|
||||
"koreader stats file has not changed",
|
||||
extra={
|
||||
"user_id": user_id,
|
||||
"new_hash": new_hash,
|
||||
"old_hash": old_hash,
|
||||
"last_import_id": last_import.id,
|
||||
},
|
||||
if last_import and last_import.file_hash == new_hash:
|
||||
logger.debug(
|
||||
"Scale CSV %s unchanged (hash match), skipping",
|
||||
fname,
|
||||
extra={"user_id": user_id, "hash": new_hash},
|
||||
)
|
||||
continue
|
||||
|
||||
koreader_import, created = KoReaderImport.objects.get_or_create(
|
||||
user_id=user_id, processed_finished__isnull=True
|
||||
imp = ScaleCSVImport.objects.create(
|
||||
user_id=user_id,
|
||||
original_filename=fname,
|
||||
file_hash=new_hash,
|
||||
)
|
||||
with open(tmp.name, "rb") as f:
|
||||
imp.csv_file.save(fname, f, save=True)
|
||||
process_scale_csv_import.delay(imp.id)
|
||||
new_imports += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to import Scale CSV file {fname}: {e}")
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
|
||||
if not created and not restart:
|
||||
logger.info(
|
||||
f"Not resuming failed KoReader import {koreader_import.id} for user {user_id}, use restart=True to restart"
|
||||
)
|
||||
continue
|
||||
|
||||
koreader_import.save_sqlite_file_to_self(koreader_file_path)
|
||||
|
||||
process_koreader_import.delay(koreader_import.id)
|
||||
koreader_import_count += 1
|
||||
return koreader_import_count
|
||||
return new_imports
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import models
|
||||
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.utils import analyze_scrobble_sentiment
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Backfill VADER sentiment analysis for scrobbles with notes"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Actually update scrobble logs with sentiment data",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--overwrite",
|
||||
action="store_true",
|
||||
help="Re-analyze scrobbles that already have sentiment data",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
commit = options["commit"]
|
||||
overwrite = options["overwrite"]
|
||||
|
||||
qs = Scrobble.objects.filter(
|
||||
~models.Q(log__notes__isnull=True)
|
||||
& ~models.Q(log__notes=[])
|
||||
& ~models.Q(log__notes={})
|
||||
)
|
||||
if not overwrite:
|
||||
qs = qs.filter(log__sentiment__isnull=True)
|
||||
|
||||
total = qs.count()
|
||||
analyzed_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
self.stdout.write(f"Found {total} scrobbles to process")
|
||||
|
||||
for scrobble in qs.iterator():
|
||||
if commit:
|
||||
analyzed = analyze_scrobble_sentiment(scrobble, overwrite=overwrite)
|
||||
else:
|
||||
notes_str = ""
|
||||
if scrobble.logdata:
|
||||
notes_str = scrobble.logdata.notes_as_str()
|
||||
analyzed = bool(notes_str)
|
||||
|
||||
if analyzed:
|
||||
analyzed_count += 1
|
||||
if commit:
|
||||
scores = (scrobble.log or {}).get("sentiment", {})
|
||||
self.stdout.write(
|
||||
f" Updated scrobble {scrobble.id}: compound={scores.get('compound', 'N/A')}"
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
f" [DRY RUN] Would analyze scrobble {scrobble.id}"
|
||||
)
|
||||
else:
|
||||
skipped_count += 1
|
||||
|
||||
self.stdout.write(
|
||||
f"\nAnalyzed {analyzed_count} scrobbles, skipped {skipped_count}"
|
||||
)
|
||||
if not commit:
|
||||
self.stdout.write("Run with --commit to persist changes")
|
||||
@ -0,0 +1,7 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from vrobbler.apps.scrobbles.tasks import backup_database
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
def handle(self, *args, **options):
|
||||
backup_database()
|
||||
@ -1,9 +1,7 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from vrobbler.apps.tasks.utils import (
|
||||
convert_notes_to_dict,
|
||||
convert_old_boardgame_log_to_new,
|
||||
convert_old_orgmode_log_to_new,
|
||||
convert_old_todoist_log_to_new,
|
||||
convert_tasks_notes_list_to_dict,
|
||||
)
|
||||
|
||||
|
||||
@ -21,7 +19,5 @@ class Command(BaseCommand):
|
||||
commit = True
|
||||
else:
|
||||
print("No changes will be saved, use --commit to save")
|
||||
convert_old_orgmode_log_to_new(commit)
|
||||
convert_old_todoist_log_to_new(commit)
|
||||
convert_notes_to_dict(commit)
|
||||
convert_tasks_notes_list_to_dict(commit)
|
||||
convert_old_boardgame_log_to_new(commit)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from vrobbler.apps.scrobbles.importers import webdav
|
||||
|
||||
|
||||
@ -9,10 +10,35 @@ class Command(BaseCommand):
|
||||
action="store_true",
|
||||
help="Restart failed imports",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--update-retroarch-hash",
|
||||
action="store_true",
|
||||
help="Update retroarch files_hash to new ETag-based scheme "
|
||||
"without re-importing (migration helper)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--update-koreader-etag",
|
||||
action="store_true",
|
||||
help="Store current WebDAV ETag on last KoReader import "
|
||||
"without re-importing (migration helper)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-processed",
|
||||
action="store_true",
|
||||
help="Also import files already moved to processed/ subdirectories "
|
||||
"(may produce duplicate scrobbles; use with care on production)",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
restart = False
|
||||
if options["restart"]:
|
||||
restart = True
|
||||
count = webdav.import_from_webdav_for_all_users(restart=restart)
|
||||
print(f"Started {count} WeDAV imports")
|
||||
update_hash = options.get("update_retroarch_hash", False)
|
||||
update_etag = options.get("update_koreader_etag", False)
|
||||
include_processed = options.get("include_processed", False)
|
||||
webdav.import_from_webdav_for_all_users(
|
||||
restart=restart,
|
||||
update_retroarch_hash=update_hash,
|
||||
update_koreader_etag=update_etag,
|
||||
include_processed=include_processed,
|
||||
)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@ -11,11 +11,17 @@ class Command(BaseCommand):
|
||||
type=int,
|
||||
help="User ID to import historical scrobbles for",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--async",
|
||||
action="store_true",
|
||||
dest="async",
|
||||
help="Dispatch the task to Celery instead of running inline",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from scrobbles.models import LastFmImport
|
||||
from scrobbles.importers.lastfm import dispatch_historical_imports
|
||||
|
||||
user_id = options.get("user_id")
|
||||
user_id = options["user_id"]
|
||||
user = User.objects.filter(id=user_id).first()
|
||||
|
||||
if not user:
|
||||
@ -29,17 +35,15 @@ class Command(BaseCommand):
|
||||
)
|
||||
return
|
||||
|
||||
lfm_import = LastFmImport.objects.create(
|
||||
user=user,
|
||||
earliest_timestamp=None,
|
||||
last_batch_imported=None,
|
||||
import_in_progress=False,
|
||||
)
|
||||
|
||||
lfm_import.start_historical_import()
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Started historical LastFM import for {user} (import ID: {lfm_import.id})"
|
||||
if options["async"]:
|
||||
dispatch_historical_imports.delay(user_id)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Dispatched historical import task for user {user}")
|
||||
)
|
||||
else:
|
||||
count = dispatch_historical_imports(user_id)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Dispatched {count} monthly batch(es) for user {user}"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
70
vrobbler/apps/scrobbles/migrations/0077_scalecsvimport.py
Normal file
70
vrobbler/apps/scrobbles/migrations/0077_scalecsvimport.py
Normal file
@ -0,0 +1,70 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-21 12:55
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import scrobbles.models
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("scrobbles", "0076_scrobble_birding_location_alter_scrobble_media_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ScaleCSVImport",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
("uuid", models.UUIDField(default=uuid.uuid4, editable=False)),
|
||||
("processing_started", models.DateTimeField(blank=True, null=True)),
|
||||
("processed_finished", models.DateTimeField(blank=True, null=True)),
|
||||
("process_log", models.TextField(blank=True, null=True)),
|
||||
("process_count", models.IntegerField(blank=True, null=True)),
|
||||
(
|
||||
"csv_file",
|
||||
models.FileField(
|
||||
blank=True,
|
||||
null=True,
|
||||
upload_to=scrobbles.models.ScaleCSVImport.get_path,
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Scale CSV Import",
|
||||
},
|
||||
),
|
||||
]
|
||||
74
vrobbler/apps/scrobbles/migrations/0078_trailgpximport.py
Normal file
74
vrobbler/apps/scrobbles/migrations/0078_trailgpximport.py
Normal file
@ -0,0 +1,74 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-21 14:36
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import scrobbles.models
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("scrobbles", "0077_scalecsvimport"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="TrailGPXImport",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
("uuid", models.UUIDField(default=uuid.uuid4, editable=False)),
|
||||
("processing_started", models.DateTimeField(blank=True, null=True)),
|
||||
("processed_finished", models.DateTimeField(blank=True, null=True)),
|
||||
("process_log", models.TextField(blank=True, null=True)),
|
||||
("process_count", models.IntegerField(blank=True, null=True)),
|
||||
(
|
||||
"gpx_file",
|
||||
models.FileField(
|
||||
blank=True,
|
||||
null=True,
|
||||
upload_to=scrobbles.models.TrailGPXImport.get_path,
|
||||
),
|
||||
),
|
||||
(
|
||||
"original_filename",
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Trail GPX Import",
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,25 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-22 15:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0078_trailgpximport"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="retroarchimport",
|
||||
name="lrtl_file",
|
||||
field=models.FileField(
|
||||
blank=True, null=True, upload_to="scrobbles/lrtl_file/"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="retroarchimport",
|
||||
name="original_filename",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-22 16:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0079_retroarchimport_lrtl_file"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="retroarchimport",
|
||||
name="files_hash",
|
||||
field=models.CharField(blank=True, max_length=64, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,74 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-23 20:54
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import scrobbles.models
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("scrobbles", "0080_retroarchimport_files_hash"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="BGStatsImport",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
("uuid", models.UUIDField(default=uuid.uuid4, editable=False)),
|
||||
("processing_started", models.DateTimeField(blank=True, null=True)),
|
||||
("processed_finished", models.DateTimeField(blank=True, null=True)),
|
||||
("process_log", models.TextField(blank=True, null=True)),
|
||||
("process_count", models.IntegerField(blank=True, null=True)),
|
||||
(
|
||||
"bgsplay_file",
|
||||
models.FileField(
|
||||
blank=True,
|
||||
null=True,
|
||||
upload_to=scrobbles.models.BGStatsImport.get_path,
|
||||
),
|
||||
),
|
||||
(
|
||||
"original_filename",
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "BG Stats Import",
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,75 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-23 21:24
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import scrobbles.models
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("scrobbles", "0081_bgstats_import_model"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="EBirdCSVImport",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
("uuid", models.UUIDField(default=uuid.uuid4, editable=False)),
|
||||
("processing_started", models.DateTimeField(blank=True, null=True)),
|
||||
("processed_finished", models.DateTimeField(blank=True, null=True)),
|
||||
("process_log", models.TextField(blank=True, null=True)),
|
||||
("process_count", models.IntegerField(blank=True, null=True)),
|
||||
(
|
||||
"csv_file",
|
||||
models.FileField(
|
||||
blank=True,
|
||||
null=True,
|
||||
upload_to=scrobbles.models.EBirdCSVImport.get_path,
|
||||
),
|
||||
),
|
||||
(
|
||||
"original_filename",
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="scrobbles_ebirdcsvimport_set",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "eBird CSV Import",
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,36 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-23 21:24
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def copy_birding_data(apps, schema_editor):
|
||||
OldImport = apps.get_model("birds", "BirdingCSVImport")
|
||||
NewImport = apps.get_model("scrobbles", "EBirdCSVImport")
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
for old in OldImport.objects.using(db_alias).iterator():
|
||||
NewImport.objects.using(db_alias).create(
|
||||
id=old.id,
|
||||
created=old.created,
|
||||
modified=old.modified,
|
||||
uuid=old.uuid,
|
||||
user=old.user,
|
||||
processing_started=old.processing_started,
|
||||
processed_finished=old.processed_finished,
|
||||
process_log=old.process_log,
|
||||
process_count=old.process_count,
|
||||
csv_file=old.csv_file,
|
||||
original_filename=(
|
||||
old.csv_file.name.rpartition("/")[-1] if old.csv_file else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("scrobbles", "0082_ebird_csv_import_model"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(copy_birding_data, migrations.RunPython.noop),
|
||||
]
|
||||
@ -0,0 +1,30 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def reset_sequence(apps, schema_editor):
|
||||
from django.db import connection
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
vendor = connection.vendor
|
||||
if vendor == "postgresql":
|
||||
cursor.execute(
|
||||
"SELECT setval('scrobbles_ebirdcsvimport_id_seq', "
|
||||
"COALESCE((SELECT MAX(id) + 1 FROM scrobbles_ebirdcsvimport), 1), "
|
||||
"false)"
|
||||
)
|
||||
elif vendor == "sqlite":
|
||||
cursor.execute(
|
||||
"UPDATE sqlite_sequence SET seq = ("
|
||||
"SELECT COALESCE(MAX(id), 0) FROM scrobbles_ebirdcsvimport"
|
||||
") WHERE name = 'scrobbles_ebirdcsvimport'"
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("scrobbles", "0083_migrate_birding_data"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(reset_sequence, migrations.RunPython.noop),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-24 15:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0084_fix_ebird_sequence"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="scalecsvimport",
|
||||
name="original_filename",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-24 19:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0085_scale_csv_import_original_filename"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="lastfmimport",
|
||||
name="import_in_progress",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-26 13:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0086_add_import_in_progress_to_lastfmimport"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="koreaderimport",
|
||||
name="webdav_etag",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0087_koreaderimport_webdav_etag"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="scalecsvimport",
|
||||
name="file_hash",
|
||||
field=models.CharField(blank=True, max_length=32, null=True),
|
||||
),
|
||||
]
|
||||
287
vrobbler/apps/scrobbles/migrations/0089_favoritemedia.py
Normal file
287
vrobbler/apps/scrobbles/migrations/0089_favoritemedia.py
Normal file
@ -0,0 +1,287 @@
|
||||
# Generated by Django 4.2.30 on 2026-06-05 14:55
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("boardgames", "0015_alter_boardgame_genre"),
|
||||
("trails", "0009_trail_route_waypoint"),
|
||||
("moods", "0008_alter_mood_genre"),
|
||||
("birds", "0002_birdingcsvimport"),
|
||||
("bricksets", "0005_alter_brickset_genre"),
|
||||
("music", "0036_artist_similar_artists"),
|
||||
("puzzles", "0006_alter_puzzle_genre"),
|
||||
("videogames", "0015_alter_videogame_genre"),
|
||||
("lifeevents", "0005_alter_lifeevent_genre"),
|
||||
("beers", "0008_alter_beer_genre"),
|
||||
("foods", "0007_alter_food_genre"),
|
||||
("tasks", "0007_alter_task_genre"),
|
||||
("books", "0036_alter_book_genre_alter_paper_genre"),
|
||||
("videos", "0030_alter_channel_genre_alter_series_genre_and_more"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("podcasts", "0020_alter_podcast_genre_alter_podcastepisode_genre"),
|
||||
("webpages", "0009_alter_webpage_genre"),
|
||||
("sports", "0018_alter_sportevent_genre"),
|
||||
("locations", "0010_clean_start"),
|
||||
("scrobbles", "0088_scalecsvimport_file_hash"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="FavoriteMedia",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"media_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("Video", "Video"),
|
||||
("Track", "Track"),
|
||||
("PodcastEpisode", "Podcast episode"),
|
||||
("SportEvent", "Sport event"),
|
||||
("Book", "Book"),
|
||||
("Paper", "Paper"),
|
||||
("VideoGame", "Video game"),
|
||||
("BoardGame", "Board game"),
|
||||
("GeoLocation", "GeoLocation"),
|
||||
("Trail", "Trail"),
|
||||
("Beer", "Beer"),
|
||||
("Puzzle", "Puzzle"),
|
||||
("Food", "Food"),
|
||||
("Task", "Task"),
|
||||
("WebPage", "Web Page"),
|
||||
("LifeEvent", "Life event"),
|
||||
("Mood", "Mood"),
|
||||
("BrickSet", "Brick set"),
|
||||
("Channel", "Channel"),
|
||||
("BirdingLocation", "Birding location"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("sent_to_mopidy", models.BooleanField(default=False)),
|
||||
(
|
||||
"beer",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="beers.beer",
|
||||
),
|
||||
),
|
||||
(
|
||||
"birding_location",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="birds.birdinglocation",
|
||||
),
|
||||
),
|
||||
(
|
||||
"board_game",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="boardgames.boardgame",
|
||||
),
|
||||
),
|
||||
(
|
||||
"book",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="books.book",
|
||||
),
|
||||
),
|
||||
(
|
||||
"brick_set",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="bricksets.brickset",
|
||||
),
|
||||
),
|
||||
(
|
||||
"channel",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="videos.channel",
|
||||
),
|
||||
),
|
||||
(
|
||||
"food",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="foods.food",
|
||||
),
|
||||
),
|
||||
(
|
||||
"geo_location",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="locations.geolocation",
|
||||
),
|
||||
),
|
||||
(
|
||||
"life_event",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="lifeevents.lifeevent",
|
||||
),
|
||||
),
|
||||
(
|
||||
"mood",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="moods.mood",
|
||||
),
|
||||
),
|
||||
(
|
||||
"paper",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="books.paper",
|
||||
),
|
||||
),
|
||||
(
|
||||
"podcast_episode",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="podcasts.podcastepisode",
|
||||
),
|
||||
),
|
||||
(
|
||||
"puzzle",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="puzzles.puzzle",
|
||||
),
|
||||
),
|
||||
(
|
||||
"sport_event",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="sports.sportevent",
|
||||
),
|
||||
),
|
||||
(
|
||||
"task",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="tasks.task",
|
||||
),
|
||||
),
|
||||
(
|
||||
"track",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="music.track",
|
||||
),
|
||||
),
|
||||
(
|
||||
"trail",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="trails.trail",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"video",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="videos.video",
|
||||
),
|
||||
),
|
||||
(
|
||||
"video_game",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="videogames.videogame",
|
||||
),
|
||||
),
|
||||
(
|
||||
"web_page",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="webpages.webpage",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -10,9 +10,9 @@ from zoneinfo import ZoneInfo
|
||||
import pendulum
|
||||
import pytz
|
||||
from beers.models import Beer
|
||||
from birds.models import BirdingLocation
|
||||
from boardgames.models import BoardGame
|
||||
from books.koreader import process_koreader_sqlite_file
|
||||
from birds.models import BirdingLocation
|
||||
from books.models import Book, BookLogData, BookPageLogData, Paper
|
||||
from bricksets.models import BrickSet
|
||||
from charts.utils import build_charts
|
||||
@ -188,6 +188,7 @@ class KoReaderImport(BaseFileImportMixin):
|
||||
return path
|
||||
|
||||
sqlite_file = models.FileField(upload_to=get_path, **BNULL)
|
||||
webdav_etag = models.CharField(max_length=255, **BNULL)
|
||||
|
||||
def save_sqlite_file_to_self(self, file_path):
|
||||
with open(file_path, "rb") as f:
|
||||
@ -260,7 +261,92 @@ class AudioScrobblerTSVImport(BaseFileImportMixin):
|
||||
self.mark_finished()
|
||||
|
||||
|
||||
class ScaleCSVImport(BaseFileImportMixin):
|
||||
class Meta:
|
||||
verbose_name = "Scale CSV Import"
|
||||
|
||||
@property
|
||||
def import_type(self) -> str:
|
||||
return "Scale"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("scrobbles:scale-csv-import-detail", kwargs={"slug": self.uuid})
|
||||
|
||||
def get_path(instance, filename):
|
||||
extension = filename.split(".")[-1]
|
||||
uuid = instance.uuid
|
||||
return f"scale-csv-uploads/{uuid}.{extension}"
|
||||
|
||||
@property
|
||||
def upload_file_path(self):
|
||||
if getattr(settings, "USE_S3_STORAGE"):
|
||||
path = self.csv_file.url
|
||||
else:
|
||||
path = self.csv_file.path
|
||||
return path
|
||||
|
||||
csv_file = models.FileField(upload_to=get_path, **BNULL)
|
||||
original_filename = models.CharField(max_length=255, **BNULL)
|
||||
file_hash = models.CharField(max_length=32, **BNULL)
|
||||
|
||||
def process(self, force=False):
|
||||
from scrobbles.importers.scale import import_scale_csv
|
||||
|
||||
if self.processed_finished and not force:
|
||||
logger.info(f"{self} already processed on {self.processed_finished}")
|
||||
return
|
||||
|
||||
self.mark_started()
|
||||
scrobbles = import_scale_csv(self.upload_file_path, self.user.id)
|
||||
self.record_log(scrobbles)
|
||||
self.mark_finished()
|
||||
|
||||
|
||||
class TrailGPXImport(BaseFileImportMixin):
|
||||
class Meta:
|
||||
verbose_name = "Trail GPX Import"
|
||||
|
||||
@property
|
||||
def import_type(self) -> str:
|
||||
return "Trail GPX"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("scrobbles:trail-gpx-import-detail", kwargs={"slug": self.uuid})
|
||||
|
||||
def get_path(instance, filename):
|
||||
extension = filename.split(".")[-1]
|
||||
uuid = instance.uuid
|
||||
return f"trail-gpx-uploads/{uuid}.{extension}"
|
||||
|
||||
@property
|
||||
def upload_file_path(self):
|
||||
if getattr(settings, "USE_S3_STORAGE"):
|
||||
path = self.gpx_file.url
|
||||
else:
|
||||
path = self.gpx_file.path
|
||||
return path
|
||||
|
||||
gpx_file = models.FileField(upload_to=get_path, **BNULL)
|
||||
original_filename = models.CharField(max_length=255, **BNULL)
|
||||
|
||||
def process(self, force=False):
|
||||
from scrobbles.importers.trail_gpx import import_trail_gpx
|
||||
|
||||
if self.processed_finished and not force:
|
||||
logger.info(f"{self} already processed on {self.processed_finished}")
|
||||
return
|
||||
|
||||
self.mark_started()
|
||||
scrobbles = import_trail_gpx(
|
||||
self.upload_file_path, self.user.id, self.original_filename
|
||||
)
|
||||
self.record_log(scrobbles)
|
||||
self.mark_finished()
|
||||
|
||||
|
||||
class LastFmImport(BaseFileImportMixin):
|
||||
import_in_progress = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Last.FM Import"
|
||||
|
||||
@ -271,7 +357,7 @@ class LastFmImport(BaseFileImportMixin):
|
||||
def get_absolute_url(self):
|
||||
return reverse("scrobbles:lastfm-import-detail", kwargs={"slug": self.uuid})
|
||||
|
||||
def process(self, import_all=False):
|
||||
def process(self, import_all=False, time_from=None, time_to=None):
|
||||
"""Import scrobbles found on LastFM"""
|
||||
|
||||
if self.user.id == 1:
|
||||
@ -281,27 +367,32 @@ class LastFmImport(BaseFileImportMixin):
|
||||
logger.info(f"{self} already processed on {self.processed_finished}")
|
||||
return
|
||||
|
||||
last_import = None
|
||||
if not import_all:
|
||||
try:
|
||||
last_import = LastFmImport.objects.exclude(id=self.id).last()
|
||||
except:
|
||||
pass
|
||||
|
||||
if not import_all and not last_import:
|
||||
logger.warn(
|
||||
"No previous import, to import all Last.fm scrobbles, pass import_all=True"
|
||||
)
|
||||
return
|
||||
|
||||
lastfm = LastFM(self.user)
|
||||
last_processed = None
|
||||
if last_import:
|
||||
last_processed = last_import.processed_finished
|
||||
|
||||
if time_from is not None or time_to is not None:
|
||||
last_processed = time_from
|
||||
else:
|
||||
last_import = None
|
||||
if not import_all:
|
||||
try:
|
||||
last_import = LastFmImport.objects.exclude(id=self.id).last()
|
||||
except:
|
||||
pass
|
||||
|
||||
if not import_all and not last_import:
|
||||
logger.warn(
|
||||
"No previous import, to import all Last.fm scrobbles, "
|
||||
"pass import_all=True"
|
||||
)
|
||||
return
|
||||
|
||||
if last_import:
|
||||
last_processed = last_import.processed_finished
|
||||
|
||||
self.mark_started()
|
||||
|
||||
scrobbles = lastfm.import_from_lastfm(last_processed)
|
||||
scrobbles = lastfm.import_from_lastfm(last_processed, time_to=time_to)
|
||||
|
||||
self.record_log(scrobbles)
|
||||
self.mark_finished()
|
||||
@ -318,6 +409,10 @@ class RetroarchImport(BaseFileImportMixin):
|
||||
def get_absolute_url(self):
|
||||
return reverse("scrobbles:retroarch-import-detail", kwargs={"slug": self.uuid})
|
||||
|
||||
original_filename = models.CharField(max_length=255, **BNULL)
|
||||
lrtl_file = models.FileField(upload_to="scrobbles/lrtl_file/", **BNULL)
|
||||
files_hash = models.CharField(max_length=64, **BNULL)
|
||||
|
||||
def process(self, import_all=False, force=False):
|
||||
"""Import scrobbles found on Retroarch"""
|
||||
if self.user.id == 1:
|
||||
@ -330,18 +425,136 @@ class RetroarchImport(BaseFileImportMixin):
|
||||
if force:
|
||||
logger.info(f"You told me to force import from Retroarch")
|
||||
|
||||
if not self.user.profile.retroarch_path:
|
||||
logger.info(
|
||||
"Tying to import Retroarch logs, but user has no retroarch_path configured"
|
||||
self.mark_started()
|
||||
|
||||
if self.lrtl_file:
|
||||
import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
|
||||
tmpdir = tempfile.mkdtemp()
|
||||
try:
|
||||
zip_path = os.path.join(tmpdir, "archive.zip")
|
||||
with open(zip_path, "wb") as f:
|
||||
f.write(self.lrtl_file.read())
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
zf.extractall(tmpdir)
|
||||
os.unlink(zip_path)
|
||||
scrobbles = retroarch.import_retroarch_lrtl_files(
|
||||
tmpdir + "/",
|
||||
self.user.id,
|
||||
)
|
||||
finally:
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
else:
|
||||
if not self.user.profile.retroarch_path:
|
||||
logger.info(
|
||||
"Tying to import Retroarch logs, but user has no retroarch_path configured"
|
||||
)
|
||||
self.mark_finished()
|
||||
return
|
||||
|
||||
scrobbles = retroarch.import_retroarch_lrtl_files(
|
||||
self.user.profile.retroarch_path,
|
||||
self.user.id,
|
||||
)
|
||||
|
||||
self.record_log(scrobbles)
|
||||
self.mark_finished()
|
||||
|
||||
|
||||
class BGStatsImport(BaseFileImportMixin):
|
||||
class Meta:
|
||||
verbose_name = "BG Stats Import"
|
||||
|
||||
@property
|
||||
def import_type(self) -> str:
|
||||
return "BG Stats"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("scrobbles:bgstats-import-detail", kwargs={"slug": self.uuid})
|
||||
|
||||
def get_path(instance, filename):
|
||||
extension = filename.split(".")[-1]
|
||||
uuid = instance.uuid
|
||||
return f"bgstats-uploads/{uuid}.{extension}"
|
||||
|
||||
@property
|
||||
def upload_file_path(self):
|
||||
if getattr(settings, "USE_S3_STORAGE"):
|
||||
path = self.bgsplay_file.url
|
||||
else:
|
||||
path = self.bgsplay_file.path
|
||||
return path
|
||||
|
||||
bgsplay_file = models.FileField(upload_to=get_path, **BNULL)
|
||||
original_filename = models.CharField(max_length=255, **BNULL)
|
||||
|
||||
def process(self, force=False):
|
||||
"""Import scrobbles from a single BG Stats bgsplay file"""
|
||||
if self.processed_finished and not force:
|
||||
logger.info(f"{self} already processed on {self.processed_finished}")
|
||||
return
|
||||
|
||||
self.mark_started()
|
||||
|
||||
scrobbles = retroarch.import_retroarch_lrtl_files(
|
||||
self.user.profile.retroarch_path,
|
||||
self.user.id,
|
||||
)
|
||||
import json
|
||||
|
||||
from scrobbles.scrobblers import email_scrobble_board_game
|
||||
|
||||
with open(self.upload_file_path, "r", encoding="utf-8") as f:
|
||||
parsed_json = json.load(f)
|
||||
scrobbles = email_scrobble_board_game(parsed_json, self.user_id)
|
||||
|
||||
self.record_log(scrobbles)
|
||||
self.mark_finished()
|
||||
|
||||
|
||||
class EBirdCSVImport(BaseFileImportMixin):
|
||||
class Meta:
|
||||
verbose_name = "eBird CSV Import"
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.DO_NOTHING,
|
||||
**BNULL,
|
||||
related_name="scrobbles_ebirdcsvimport_set",
|
||||
)
|
||||
|
||||
@property
|
||||
def import_type(self) -> str:
|
||||
return "eBird"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("scrobbles:ebird-csv-import-detail", kwargs={"slug": self.uuid})
|
||||
|
||||
def get_path(instance, filename):
|
||||
extension = filename.split(".")[-1]
|
||||
uuid = instance.uuid
|
||||
return f"birding-csv-uploads/{uuid}.{extension}"
|
||||
|
||||
@property
|
||||
def upload_file_path(self):
|
||||
if getattr(settings, "USE_S3_STORAGE"):
|
||||
path = self.csv_file.url
|
||||
else:
|
||||
path = self.csv_file.path
|
||||
return path
|
||||
|
||||
csv_file = models.FileField(upload_to=get_path, **BNULL)
|
||||
original_filename = models.CharField(max_length=255, **BNULL)
|
||||
|
||||
def process(self, force=False):
|
||||
from birds.importer import import_birding_csv
|
||||
|
||||
if self.processed_finished and not force:
|
||||
logger.info(f"{self} already processed on {self.processed_finished}")
|
||||
return
|
||||
|
||||
self.mark_started()
|
||||
scrobbles = import_birding_csv(self.upload_file_path, self.user_id)
|
||||
self.record_log(scrobbles)
|
||||
self.mark_finished()
|
||||
|
||||
@ -351,7 +564,7 @@ class ScrobbleQuerySet(models.QuerySet):
|
||||
return self.select_related("user").prefetch_related(
|
||||
"video",
|
||||
"track",
|
||||
"track__artist",
|
||||
"track__artist_fk",
|
||||
"podcast_episode",
|
||||
"podcast_episode__podcast",
|
||||
"sport_event",
|
||||
@ -571,6 +784,8 @@ class Scrobble(TimeStampedModel):
|
||||
return reverse("scrobbles:finish", kwargs={"uuid": self.uuid})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
class_name = self.media_obj.__class__.__name__
|
||||
|
||||
if not self.uuid:
|
||||
self.uuid = uuid4()
|
||||
|
||||
@ -593,12 +808,24 @@ class Scrobble(TimeStampedModel):
|
||||
self.stop_timestamp - self.timestamp
|
||||
).seconds
|
||||
|
||||
if class_name == "Book":
|
||||
self.calculate_reading_stats(commit=False)
|
||||
|
||||
if (
|
||||
self.log
|
||||
and isinstance(self.log, dict)
|
||||
and self.log.get("page_start") is not None
|
||||
and self.log.get("page_end") is not None
|
||||
and "pages_read" not in self.log
|
||||
):
|
||||
self.log["pages_read"] = self.log["page_end"] - self.log["page_start"] + 1
|
||||
|
||||
return super(Scrobble, self).save(*args, **kwargs)
|
||||
|
||||
def get_absolute_url(self):
|
||||
if not self.uuid:
|
||||
self.uuid = uuid4()
|
||||
self.save()
|
||||
self.save(update_fields=["uuid"])
|
||||
return reverse("scrobbles:detail", kwargs={"uuid": self.uuid})
|
||||
|
||||
def push_to_archivebox(self):
|
||||
@ -655,8 +882,14 @@ class Scrobble(TimeStampedModel):
|
||||
logger.warning("Log data could not be loaded", e)
|
||||
return logdata_cls()
|
||||
|
||||
# Strip log-only keys (stored in JSONField but not part of LogData dataclass)
|
||||
logdata_kwargs = {
|
||||
k: v for k, v in log_dict.items()
|
||||
if k in logdata_cls.__dataclass_fields__
|
||||
}
|
||||
|
||||
try:
|
||||
return logdata_cls(**log_dict)
|
||||
return logdata_cls(**logdata_kwargs)
|
||||
except ParseError as e:
|
||||
logger.warning(
|
||||
"Could not parse log data",
|
||||
@ -1302,7 +1535,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:
|
||||
@ -1452,38 +1690,166 @@ class Scrobble(TimeStampedModel):
|
||||
return False
|
||||
|
||||
def calculate_reading_stats(self, commit=True):
|
||||
# --- Sort safely by numeric page_number ---
|
||||
def safe_page_number(entry):
|
||||
try:
|
||||
return int(getattr("page_number", entry), 0)
|
||||
except (ValueError, TypeError):
|
||||
return float("inf") # push invalid entries to the end
|
||||
|
||||
page_data = self.log.get("page_data")
|
||||
|
||||
if not page_data:
|
||||
logger.warning("No page data found to calculate")
|
||||
return
|
||||
if page_data:
|
||||
# --- Sort safely by numeric page_number ---
|
||||
def safe_page_number(entry):
|
||||
try:
|
||||
return int(getattr("page_number", entry), 0)
|
||||
except (ValueError, TypeError):
|
||||
return float("inf")
|
||||
|
||||
if isinstance(page_data, dict):
|
||||
logger.warning("Page data is dict, migrate koreader data")
|
||||
return
|
||||
if isinstance(page_data, dict):
|
||||
logger.warning("Page data is dict, migrate koreader data")
|
||||
return
|
||||
|
||||
page_data.sort(key=safe_page_number)
|
||||
page_data.sort(key=safe_page_number)
|
||||
|
||||
# --- Extract valid numeric page numbers ---
|
||||
valid_pages = []
|
||||
for page in page_data:
|
||||
try:
|
||||
valid_pages.append(int(page["page_number"]))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
valid_pages = []
|
||||
for page in page_data:
|
||||
try:
|
||||
valid_pages.append(int(page["page_number"]))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
# --- Compute stats ---
|
||||
if valid_pages:
|
||||
self.log["page_start"] = min(valid_pages)
|
||||
self.log["page_end"] = max(valid_pages)
|
||||
self.log["pages_read"] = len(set(valid_pages))
|
||||
if valid_pages:
|
||||
self.log["page_start"] = min(valid_pages)
|
||||
self.log["page_end"] = max(valid_pages)
|
||||
self.log["pages_read"] = len(set(valid_pages))
|
||||
else:
|
||||
page_start = self.log.get("page_start")
|
||||
page_end = self.log.get("page_end")
|
||||
if page_start is not None and page_end is not None:
|
||||
self.log["pages_read"] = page_end - page_start + 1
|
||||
|
||||
if commit:
|
||||
self.save(update_fields=["log"])
|
||||
if commit and "pages_read" in self.log:
|
||||
self.save(update_fields=["log"])
|
||||
|
||||
|
||||
class FavoriteMedia(TimeStampedModel):
|
||||
|
||||
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
|
||||
video = models.ForeignKey(Video, on_delete=models.CASCADE, **BNULL)
|
||||
channel = models.ForeignKey("videos.Channel", on_delete=models.CASCADE, **BNULL)
|
||||
track = models.ForeignKey(Track, on_delete=models.CASCADE, **BNULL)
|
||||
podcast_episode = models.ForeignKey(
|
||||
PodcastEpisode, on_delete=models.CASCADE, **BNULL
|
||||
)
|
||||
sport_event = models.ForeignKey(SportEvent, on_delete=models.CASCADE, **BNULL)
|
||||
book = models.ForeignKey(Book, on_delete=models.CASCADE, **BNULL)
|
||||
paper = models.ForeignKey(Paper, on_delete=models.CASCADE, **BNULL)
|
||||
video_game = models.ForeignKey(VideoGame, on_delete=models.CASCADE, **BNULL)
|
||||
board_game = models.ForeignKey(BoardGame, on_delete=models.CASCADE, **BNULL)
|
||||
geo_location = models.ForeignKey(GeoLocation, on_delete=models.CASCADE, **BNULL)
|
||||
beer = models.ForeignKey(Beer, on_delete=models.CASCADE, **BNULL)
|
||||
puzzle = models.ForeignKey(Puzzle, on_delete=models.CASCADE, **BNULL)
|
||||
food = models.ForeignKey(Food, on_delete=models.CASCADE, **BNULL)
|
||||
trail = models.ForeignKey(Trail, on_delete=models.CASCADE, **BNULL)
|
||||
task = models.ForeignKey(Task, on_delete=models.CASCADE, **BNULL)
|
||||
web_page = models.ForeignKey(WebPage, on_delete=models.CASCADE, **BNULL)
|
||||
life_event = models.ForeignKey(LifeEvent, on_delete=models.CASCADE, **BNULL)
|
||||
mood = models.ForeignKey(Mood, on_delete=models.CASCADE, **BNULL)
|
||||
brick_set = models.ForeignKey(BrickSet, on_delete=models.CASCADE, **BNULL)
|
||||
birding_location = models.ForeignKey(
|
||||
BirdingLocation, on_delete=models.CASCADE, **BNULL
|
||||
)
|
||||
media_type = models.CharField(
|
||||
max_length=20, choices=Scrobble.MediaType.choices
|
||||
)
|
||||
sent_to_mopidy = models.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} favorites {self.media_obj}"
|
||||
|
||||
@property
|
||||
def media_obj(self):
|
||||
media_obj = None
|
||||
if self.video:
|
||||
media_obj = self.video
|
||||
if self.track:
|
||||
media_obj = self.track
|
||||
if self.podcast_episode:
|
||||
media_obj = self.podcast_episode
|
||||
if self.sport_event:
|
||||
media_obj = self.sport_event
|
||||
if self.book:
|
||||
media_obj = self.book
|
||||
if self.video_game:
|
||||
media_obj = self.video_game
|
||||
if self.board_game:
|
||||
media_obj = self.board_game
|
||||
if self.geo_location:
|
||||
media_obj = self.geo_location
|
||||
if self.web_page:
|
||||
media_obj = self.web_page
|
||||
if self.life_event:
|
||||
media_obj = self.life_event
|
||||
if self.mood:
|
||||
media_obj = self.mood
|
||||
if self.brick_set:
|
||||
media_obj = self.brick_set
|
||||
if self.trail:
|
||||
media_obj = self.trail
|
||||
if self.beer:
|
||||
media_obj = self.beer
|
||||
if self.puzzle:
|
||||
media_obj = self.puzzle
|
||||
if self.task:
|
||||
media_obj = self.task
|
||||
if self.food:
|
||||
media_obj = self.food
|
||||
if self.channel:
|
||||
media_obj = self.channel
|
||||
if self.birding_location:
|
||||
media_obj = self.birding_location
|
||||
return media_obj
|
||||
|
||||
@classmethod
|
||||
def toggle(cls, media_obj, user):
|
||||
media_type = media_obj.__class__.__name__
|
||||
if media_type not in Scrobble.MediaType.list():
|
||||
raise ValueError(f"Unknown media type: {media_type}")
|
||||
|
||||
fk_map = {
|
||||
"Video": "video",
|
||||
"Channel": "channel",
|
||||
"Track": "track",
|
||||
"PodcastEpisode": "podcast_episode",
|
||||
"SportEvent": "sport_event",
|
||||
"Book": "book",
|
||||
"Paper": "paper",
|
||||
"VideoGame": "video_game",
|
||||
"BoardGame": "board_game",
|
||||
"GeoLocation": "geo_location",
|
||||
"Beer": "beer",
|
||||
"Puzzle": "puzzle",
|
||||
"Food": "food",
|
||||
"Trail": "trail",
|
||||
"Task": "task",
|
||||
"WebPage": "web_page",
|
||||
"LifeEvent": "life_event",
|
||||
"Mood": "mood",
|
||||
"BrickSet": "brick_set",
|
||||
"BirdingLocation": "birding_location",
|
||||
}
|
||||
|
||||
fk = fk_map.get(media_type)
|
||||
if not fk:
|
||||
raise ValueError(f"No FK mapping for media type: {media_type}")
|
||||
|
||||
existing = cls.objects.filter(user=user, **{fk: media_obj}).first()
|
||||
if existing:
|
||||
existing.delete()
|
||||
return None
|
||||
|
||||
return cls.objects.create(
|
||||
user=user,
|
||||
media_type=media_type,
|
||||
**{fk: media_obj},
|
||||
)
|
||||
|
||||
@ -83,7 +83,7 @@ class MoodNtfyNotification(BasicNtfyNotification):
|
||||
def __init__(self, profile, **kwargs):
|
||||
super().__init__(profile)
|
||||
self.ntfy_str: str = "Would you like to check in about your mood?"
|
||||
self.click_url = self.url_tmpl.format(path=reverse("moods:mood_list"))
|
||||
self.click_url = self.url_tmpl.format(path=reverse("moods:checkin"))
|
||||
self.title = "Mood Check-in!"
|
||||
|
||||
def send(self):
|
||||
|
||||
@ -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,
|
||||
)
|
||||
@ -450,11 +451,11 @@ def email_scrobble_board_game(
|
||||
locations[location_dict.get("id")] = location
|
||||
|
||||
scrobbles_created = []
|
||||
second = 0
|
||||
for play_dict in bgstat_data.get("plays", []):
|
||||
hour = None
|
||||
minute = None
|
||||
second = None
|
||||
comments = None
|
||||
if "comments" in play_dict.keys():
|
||||
for line in play_dict.get("comments", "").split("\n"):
|
||||
if "Learning to play" in line:
|
||||
@ -469,7 +470,7 @@ def email_scrobble_board_game(
|
||||
except IndexError:
|
||||
second = 0
|
||||
|
||||
log_data["notes"] = [play_dict.get("comments")]
|
||||
comments = play_dict.get("comments")
|
||||
log_data["expansion_ids"] = []
|
||||
try:
|
||||
base_game = base_games[play_dict.get("gameRefId")]
|
||||
@ -527,6 +528,9 @@ def email_scrobble_board_game(
|
||||
duration_seconds = base_game.run_time_seconds
|
||||
stop_timestamp = timestamp + timedelta(seconds=duration_seconds)
|
||||
|
||||
if 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
|
||||
scrobble_dict = {
|
||||
@ -539,9 +543,7 @@ def email_scrobble_board_game(
|
||||
|
||||
scrobble = None
|
||||
if timestamp.year > 2023:
|
||||
logger.info(
|
||||
"Scrobbles older than 2024 likely have no time associated just create it"
|
||||
)
|
||||
# Scrobbles older than 2023 have play times, so try looking it up
|
||||
scrobble = Scrobble.objects.filter(
|
||||
board_game=base_game, user_id=user_id, timestamp=timestamp
|
||||
).first()
|
||||
@ -560,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
|
||||
|
||||
@ -687,9 +688,10 @@ def todoist_scrobble_update_task(
|
||||
)
|
||||
return
|
||||
|
||||
timestamp = todoist_note.get("posted_at") or make_note_timestamp()
|
||||
if not scrobble.log.get("notes"):
|
||||
scrobble.log["notes"] = []
|
||||
scrobble.log["notes"].append(todoist_note.get("notes"))
|
||||
scrobble.log["notes"] = {}
|
||||
scrobble.log["notes"][timestamp] = todoist_note.get("notes")
|
||||
scrobble.save(update_fields=["log"])
|
||||
logger.info(
|
||||
"[todoist_scrobble_update_task] todoist note added",
|
||||
@ -759,7 +761,10 @@ def todoist_scrobble_task(
|
||||
|
||||
todoist_task["title"] = todoist_task.pop("description")
|
||||
todoist_task["description"] = todoist_task.pop("details")
|
||||
todoist_task["labels"] = todoist_task.pop("todoist_label_list", [])
|
||||
labels = todoist_task.pop("todoist_label_list", [])
|
||||
todoist_task["labels"] = [
|
||||
l for l in labels if l.lower() != "inprogress"
|
||||
]
|
||||
todoist_task.pop("todoist_type")
|
||||
todoist_task.pop("todoist_event")
|
||||
|
||||
@ -784,9 +789,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: dict, user_id: int
|
||||
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,
|
||||
@ -805,27 +838,35 @@ def emacs_scrobble_update_task(
|
||||
)
|
||||
return
|
||||
|
||||
notes_updated = False
|
||||
log_updated = False
|
||||
|
||||
if not scrobble.log.get("notes"):
|
||||
scrobble.log["notes"] = {}
|
||||
|
||||
for note in emacs_notes:
|
||||
try:
|
||||
existing_note_ts = [
|
||||
n.get("timestamp") for n in scrobble.log.get("notes", [])
|
||||
]
|
||||
except AttributeError:
|
||||
existing_note_ts = []
|
||||
if not scrobble.log.get('notes"'):
|
||||
scrobble.log["notes"] = []
|
||||
if note.get("timestamp") not in existing_note_ts:
|
||||
scrobble.log["notes"].append({note.get("timestamp"): note.get("content")})
|
||||
notes_updated = True
|
||||
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:
|
||||
scrobble.log["notes"][timestamp] = content
|
||||
log_updated = True
|
||||
|
||||
if notes_updated:
|
||||
if description is not None and scrobble.log.get("description") != description:
|
||||
scrobble.log["description"] = description
|
||||
log_updated = True
|
||||
|
||||
if log_updated:
|
||||
scrobble.save(update_fields=["log"])
|
||||
|
||||
logger.info(
|
||||
"[emacs_scrobble_update_task] emacs note added",
|
||||
"[emacs_scrobble_update_task] emacs scrobble updated",
|
||||
extra={
|
||||
"emacs_note": emacs_notes,
|
||||
"emacs_id": emacs_id,
|
||||
"user_id": user_id,
|
||||
"media_type": Scrobble.MediaType.TASK,
|
||||
},
|
||||
@ -867,7 +908,7 @@ def emacs_scrobble_task(
|
||||
logger.info(
|
||||
"[emacs_scrobble_task] cannot start already started task",
|
||||
extra={
|
||||
"ormode_id": orgmode_id,
|
||||
"orgmode_id": orgmode_id,
|
||||
},
|
||||
)
|
||||
return in_progress_scrobble
|
||||
@ -886,11 +927,9 @@ def emacs_scrobble_task(
|
||||
if in_progress_scrobble:
|
||||
return in_progress_scrobble
|
||||
|
||||
notes = task_data.pop("notes")
|
||||
if notes:
|
||||
task_data["notes"] = [note.get("content") for note in notes]
|
||||
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")
|
||||
|
||||
@ -4,8 +4,14 @@ from django.db.models.signals import post_delete, post_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils import timezone
|
||||
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.tasks import MEDIA_TYPES, update_charts_for_timestamp
|
||||
from scrobbles.models import FavoriteMedia, Scrobble
|
||||
from scrobbles.tasks import (
|
||||
add_favorite_to_mopidy_playlist,
|
||||
CHARTABLE_MEDIA_TYPES,
|
||||
remove_favorite_from_mopidy_playlist,
|
||||
SCROBBLES_WITHOUT_CHARTS,
|
||||
update_charts_for_timestamp,
|
||||
)
|
||||
from scrobbles.utils import tokenize_title_to_tags
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -19,7 +25,10 @@ def update_charts_on_scrobble_complete(sender, instance, **kwargs):
|
||||
if instance.timestamp is None:
|
||||
return
|
||||
|
||||
if instance.media_type.lower() not in MEDIA_TYPES:
|
||||
if instance.media_type.lower() in SCROBBLES_WITHOUT_CHARTS:
|
||||
return
|
||||
|
||||
if instance.media_type.lower() not in CHARTABLE_MEDIA_TYPES:
|
||||
return
|
||||
|
||||
_update_charts_for_timestamp(instance.user, instance.timestamp)
|
||||
@ -30,7 +39,10 @@ def update_charts_on_scrobble_delete(sender, instance, **kwargs):
|
||||
if instance.timestamp is None:
|
||||
return
|
||||
|
||||
if instance.media_type.lower() not in MEDIA_TYPES:
|
||||
if instance.media_type.lower() in SCROBBLES_WITHOUT_CHARTS:
|
||||
return
|
||||
|
||||
if instance.media_type.lower() not in CHARTABLE_MEDIA_TYPES:
|
||||
return
|
||||
|
||||
_update_charts_for_timestamp(instance.user, instance.timestamp)
|
||||
@ -66,3 +78,28 @@ def add_tags_from_task_title(sender, instance, **kwargs):
|
||||
for tag in new_tags:
|
||||
if tag not in existing_tags:
|
||||
instance.tags.add(tag)
|
||||
|
||||
|
||||
@receiver(post_save, sender=FavoriteMedia)
|
||||
def add_to_mopidy_playlist_on_favorite(sender, instance, created, **kwargs):
|
||||
if not created:
|
||||
return
|
||||
if instance.media_type != Scrobble.MediaType.TRACK:
|
||||
return
|
||||
if not instance.track:
|
||||
return
|
||||
|
||||
add_favorite_to_mopidy_playlist.delay(instance.id)
|
||||
|
||||
|
||||
@receiver(post_delete, sender=FavoriteMedia)
|
||||
def remove_from_mopidy_playlist_on_unfavorite(sender, instance, **kwargs):
|
||||
if instance.media_type != Scrobble.MediaType.TRACK:
|
||||
return
|
||||
if not instance.track_id:
|
||||
return
|
||||
|
||||
remove_favorite_from_mopidy_playlist.delay(
|
||||
user_id=instance.user_id,
|
||||
track_id=instance.track_id,
|
||||
)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from celery import shared_task
|
||||
from charts.utils import (
|
||||
@ -10,13 +10,15 @@ from charts.utils import (
|
||||
build_yearly_charts,
|
||||
)
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
|
||||
MEDIA_TYPES = [
|
||||
CHARTABLE_MEDIA_TYPES = [
|
||||
"artist",
|
||||
"album",
|
||||
"track",
|
||||
@ -29,6 +31,10 @@ MEDIA_TYPES = [
|
||||
"book",
|
||||
]
|
||||
|
||||
SCROBBLES_WITHOUT_CHARTS = [
|
||||
"geolocation",
|
||||
]
|
||||
|
||||
|
||||
@shared_task
|
||||
def check_twitch_channels_for_vods():
|
||||
@ -103,13 +109,24 @@ def process_retroarch_import(import_id):
|
||||
|
||||
|
||||
@shared_task
|
||||
def process_lastfm_import(import_id):
|
||||
def process_bgstats_import(import_id):
|
||||
BGStatsImport = apps.get_model("scrobbles", "BGStatsImport")
|
||||
bgstats_import = BGStatsImport.objects.filter(id=import_id).first()
|
||||
if not bgstats_import:
|
||||
logger.warn(f"BGStatsImport not found with id {import_id}")
|
||||
return
|
||||
bgstats_import.process()
|
||||
|
||||
|
||||
@shared_task
|
||||
def process_lastfm_import(import_id, time_from=None, time_to=None):
|
||||
LastFmImport = apps.get_model("scrobbles", "LastFMImport")
|
||||
lastfm_import = LastFmImport.objects.filter(id=import_id).first()
|
||||
if not lastfm_import:
|
||||
logger.warn(f"LastFmImport not found with id {import_id}")
|
||||
return
|
||||
|
||||
lastfm_import.process()
|
||||
lastfm_import.process(time_from=time_from, time_to=time_to)
|
||||
|
||||
|
||||
@shared_task
|
||||
@ -133,6 +150,36 @@ def process_koreader_import(import_id):
|
||||
koreader_import.process()
|
||||
|
||||
|
||||
@shared_task
|
||||
def process_trail_gpx_import(import_id):
|
||||
TrailGPXImport = apps.get_model("scrobbles", "TrailGPXImport")
|
||||
trail_gpx_import = TrailGPXImport.objects.filter(id=import_id).first()
|
||||
if not trail_gpx_import:
|
||||
logger.warn(f"TrailGPXImport not found with id {import_id}")
|
||||
return
|
||||
trail_gpx_import.process()
|
||||
|
||||
|
||||
@shared_task
|
||||
def process_ebird_csv_import(import_id):
|
||||
EBirdCSVImport = apps.get_model("scrobbles", "EBirdCSVImport")
|
||||
birding_import = EBirdCSVImport.objects.filter(id=import_id).first()
|
||||
if not birding_import:
|
||||
logger.warn(f"EBirdCSVImport not found with id {import_id}")
|
||||
return
|
||||
birding_import.process()
|
||||
|
||||
|
||||
@shared_task
|
||||
def process_scale_csv_import(import_id):
|
||||
ScaleCSVImport = apps.get_model("scrobbles", "ScaleCSVImport")
|
||||
scale_import = ScaleCSVImport.objects.filter(id=import_id).first()
|
||||
if not scale_import:
|
||||
logger.warn(f"ScaleCSVImport not found with id {import_id}")
|
||||
return
|
||||
scale_import.process()
|
||||
|
||||
|
||||
@shared_task
|
||||
def create_yesterdays_charts():
|
||||
"""Build/update charts for all users starting from last known record."""
|
||||
@ -157,7 +204,7 @@ def rebuild_weekly_charts():
|
||||
now = timezone.now()
|
||||
year, week, _ = now.isocalendar()
|
||||
for user in User.objects.all():
|
||||
build_weekly_charts(user, year, week, MEDIA_TYPES)
|
||||
build_weekly_charts(user, year, week, CHARTABLE_MEDIA_TYPES)
|
||||
logger.info(f"Rebuilt weekly charts for week {week}, {year}")
|
||||
|
||||
|
||||
@ -172,7 +219,7 @@ def rebuild_monthly_charts():
|
||||
else:
|
||||
month -= 1
|
||||
for user in User.objects.all():
|
||||
build_monthly_charts(user, year, month, MEDIA_TYPES)
|
||||
build_monthly_charts(user, year, month, CHARTABLE_MEDIA_TYPES)
|
||||
logger.info(f"Rebuilt monthly charts for {month}/{year}")
|
||||
|
||||
|
||||
@ -182,7 +229,7 @@ def rebuild_yearly_charts():
|
||||
now = timezone.now()
|
||||
year = now.year - 1
|
||||
for user in User.objects.all():
|
||||
build_yearly_charts(user, year, MEDIA_TYPES)
|
||||
build_yearly_charts(user, year, CHARTABLE_MEDIA_TYPES)
|
||||
logger.info(f"Rebuilt yearly charts for {year}")
|
||||
|
||||
|
||||
@ -195,11 +242,450 @@ def update_charts_for_timestamp(user_id, year, month, day, week):
|
||||
return
|
||||
|
||||
try:
|
||||
build_daily_charts(user, year, month, day, MEDIA_TYPES)
|
||||
build_weekly_charts(user, year, week, MEDIA_TYPES)
|
||||
build_monthly_charts(user, year, month, MEDIA_TYPES)
|
||||
build_yearly_charts(user, year, MEDIA_TYPES)
|
||||
build_daily_charts(user, year, month, day, CHARTABLE_MEDIA_TYPES)
|
||||
build_weekly_charts(user, year, week, CHARTABLE_MEDIA_TYPES)
|
||||
build_monthly_charts(user, year, month, CHARTABLE_MEDIA_TYPES)
|
||||
build_yearly_charts(user, year, CHARTABLE_MEDIA_TYPES)
|
||||
date_str = f"{year}-{month:02d}-{day:02d}"
|
||||
logger.info(f"[charts] Updated charts for {user} on {date_str}")
|
||||
except Exception as e:
|
||||
logger.error(f"[charts] Failed to update charts: {e}")
|
||||
|
||||
|
||||
# ── Crontab replacements ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
BACKUP_RETENTION_DAYS = 7
|
||||
BACKUP_RETENTION_MONTHS = 12
|
||||
|
||||
|
||||
def _cleanup_failed_backup(backup_path):
|
||||
"""Remove a failed/incomplete backup file if it exists."""
|
||||
from pathlib import Path
|
||||
|
||||
p = Path(backup_path)
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
logger.warning("backup_database: removed incomplete backup %s", backup_path)
|
||||
|
||||
|
||||
def _retention_files_to_delete(remote_files, now):
|
||||
"""Return list of filenames to delete under retention policy.
|
||||
|
||||
Policy: keep all files from the last *BACKUP_RETENTION_DAYS* days,
|
||||
plus the single most-recent backup from each of the last
|
||||
*BACKUP_RETENTION_MONTHS* calendar months (not already covered by
|
||||
the day-based window).
|
||||
"""
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from datetime import date, timedelta
|
||||
|
||||
pattern = re.compile(r"vrobbler-backup-(\d{4})_(\d{2})_(\d{2})\.sql\.gz")
|
||||
parsed = []
|
||||
for fname in remote_files:
|
||||
m = pattern.match(fname)
|
||||
if not m:
|
||||
continue
|
||||
y, mo, d = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
||||
parsed.append((date(y, mo, d), fname))
|
||||
|
||||
cutoff = now.date() - timedelta(days=BACKUP_RETENTION_DAYS)
|
||||
|
||||
keep = set()
|
||||
for d, fname in parsed:
|
||||
if d >= cutoff:
|
||||
keep.add(fname)
|
||||
|
||||
remaining = [(d, fname) for d, fname in parsed if fname not in keep]
|
||||
by_month = defaultdict(list)
|
||||
for d, fname in remaining:
|
||||
by_month[(d.year, d.month)].append((d, fname))
|
||||
|
||||
for files in by_month.values():
|
||||
files.sort(key=lambda x: x[0], reverse=True)
|
||||
keep.add(files[0][1])
|
||||
|
||||
return [fname for _, fname in parsed if fname not in keep]
|
||||
|
||||
|
||||
def _run_remote_cleanup(ssh_key, ssh_host, remote_path):
|
||||
"""SSH to remote host, list backup files, delete those outside retention.
|
||||
|
||||
Returns a summary string (e.g. "pruned 3 old backup(s)") or None.
|
||||
"""
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run(
|
||||
["ssh", "-i", ssh_key, ssh_host, "ls", "-1", remote_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning(
|
||||
"backup_database: could not list remote files (%s)", result.stderr.strip()
|
||||
)
|
||||
return None
|
||||
|
||||
files = [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
||||
if not files:
|
||||
return None
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
now = datetime.now()
|
||||
to_delete = _retention_files_to_delete(files, now)
|
||||
if not to_delete:
|
||||
logger.info("backup_database: no remote files to prune")
|
||||
return None
|
||||
|
||||
# Delete in batches to avoid absurdly long command lines
|
||||
batch_size = 50
|
||||
for i in range(0, len(to_delete), batch_size):
|
||||
batch = to_delete[i : i + batch_size]
|
||||
rm_cmd = ["ssh", "-i", ssh_key, ssh_host, "rm", "-f"]
|
||||
rm_cmd.extend(f"{remote_path}/{f}" for f in batch)
|
||||
subprocess.run(rm_cmd, check=True, timeout=30)
|
||||
|
||||
logger.info("backup_database: pruned %d remote backup(s)", len(to_delete))
|
||||
return f"pruned {len(to_delete)} old remote backup(s)"
|
||||
|
||||
|
||||
@shared_task
|
||||
def backup_database():
|
||||
"""pg_dump + gzip, scp to remote, retention cleanup, ntfy notification."""
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import requests as req
|
||||
|
||||
db = settings.DATABASES["default"]
|
||||
engine = db["ENGINE"]
|
||||
if "postgresql" not in engine and "postgis" not in engine:
|
||||
logger.warning("backup_database skipped — not PostgreSQL")
|
||||
return
|
||||
|
||||
backup_dir = Path(settings.DB_BACKUP_LOCAL_DIR)
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
date_str = datetime.now().strftime("%Y_%m_%d")
|
||||
backup_path = backup_dir / f"vrobbler-backup-{date_str}.sql.gz"
|
||||
|
||||
env = os.environ.copy()
|
||||
if db.get("PASSWORD"):
|
||||
env["PGPASSWORD"] = db["PASSWORD"]
|
||||
|
||||
pg_dump_cmd = [
|
||||
"pg_dump",
|
||||
"--no-blobs",
|
||||
"-h",
|
||||
db.get("HOST", "localhost"),
|
||||
"-U",
|
||||
db.get("USER", "postgres"),
|
||||
"-d",
|
||||
db["NAME"],
|
||||
]
|
||||
|
||||
logger.info("backup_database: dumping %s to %s", db["NAME"], backup_path)
|
||||
|
||||
try:
|
||||
with open(backup_path, "wb") as f:
|
||||
dump_proc = subprocess.Popen(pg_dump_cmd, stdout=subprocess.PIPE, env=env)
|
||||
gzip_proc = subprocess.Popen(["gzip"], stdin=dump_proc.stdout, stdout=f)
|
||||
dump_proc.stdout.close()
|
||||
gzip_proc.communicate()
|
||||
|
||||
if gzip_proc.returncode != 0:
|
||||
logger.error("backup_database: pg_dump / gzip failed")
|
||||
_cleanup_failed_backup(backup_path)
|
||||
return
|
||||
|
||||
dump_size = backup_path.stat().st_size
|
||||
logger.info("backup_database: dump complete (%.1f MB)", dump_size / 1_000_000)
|
||||
|
||||
size_mb = dump_size / 1_000_000
|
||||
locations = [str(backup_path)]
|
||||
|
||||
ssh_key = getattr(settings, "DB_BACKUP_SSH_KEY", "")
|
||||
ssh_dest = getattr(settings, "DB_BACKUP_SSH_DEST", "")
|
||||
cleanup_summary = None
|
||||
if ssh_key and ssh_dest:
|
||||
logger.info("backup_database: copying to %s", ssh_dest)
|
||||
try:
|
||||
subprocess.run(
|
||||
["scp", "-i", ssh_key, str(backup_path), ssh_dest],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
logger.error(
|
||||
"backup_database: scp failed (stderr: %s)", exc.stderr.strip()
|
||||
)
|
||||
_cleanup_failed_backup(backup_path)
|
||||
return
|
||||
locations.append(ssh_dest)
|
||||
logger.info("backup_database: copied to %s", ssh_dest)
|
||||
|
||||
# Parse user@host and path from dest
|
||||
m = re.match(r"(\S+)@(\S+):(.+)", ssh_dest)
|
||||
if m:
|
||||
ssh_host = f"{m.group(1)}@{m.group(2)}"
|
||||
remote_path = m.group(3)
|
||||
logger.info("backup_database: pruning old remote backups")
|
||||
cleanup_summary = _run_remote_cleanup(ssh_key, ssh_host, remote_path)
|
||||
else:
|
||||
logger.warning(
|
||||
"backup_database: DB_BACKUP_SSH_KEY and DB_BACKUP_SSH_DEST not set — "
|
||||
"backup saved locally at %s",
|
||||
backup_path,
|
||||
)
|
||||
|
||||
msg = (
|
||||
f"✅ Vrobbler backup complete — {size_mb:.1f} MB\n"
|
||||
f"Stored at: {', '.join(locations)}"
|
||||
)
|
||||
if cleanup_summary:
|
||||
msg += f"\nRemote: {cleanup_summary}"
|
||||
|
||||
ntfy_url = getattr(
|
||||
settings, "DB_BACKUP_NTFY_URL", "https://ntfy.unbl.ink/backups"
|
||||
)
|
||||
req.post(ntfy_url, data=msg.encode())
|
||||
logger.info("backup_database: completed %s", backup_path)
|
||||
except Exception as e:
|
||||
logger.error("backup_database failed: %s", e)
|
||||
_cleanup_failed_backup(backup_path)
|
||||
ntfy_url = getattr(
|
||||
settings, "DB_BACKUP_NTFY_URL", "https://ntfy.unbl.ink/backups"
|
||||
)
|
||||
req.post(ntfy_url, data=f"❌ Vrobbler backup FAILED: {e}".encode())
|
||||
|
||||
|
||||
@shared_task
|
||||
def import_from_lastfm_all_users():
|
||||
"""Import Last.fm scrobbles for all users (replaces */30 cron)."""
|
||||
from vrobbler.apps.scrobbles.utils import import_lastfm_for_all_users
|
||||
|
||||
import_lastfm_for_all_users()
|
||||
|
||||
|
||||
@shared_task
|
||||
def import_from_retroarch_all_users():
|
||||
"""Import RetroArch scrobbles for all users (replaces @daily cron).
|
||||
|
||||
Deprecated: retroarch .lrtl files are now picked up by the WebDAV
|
||||
importer (scan_webdav_for_retroarch). This task remains for manual use.
|
||||
"""
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"import_from_retroarch_all_users is deprecated. "
|
||||
"Upload .lrtl files to WebDAV var/retroarch/ instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
from vrobbler.apps.scrobbles.utils import import_retroarch_for_all_users
|
||||
|
||||
import_retroarch_for_all_users()
|
||||
|
||||
|
||||
@shared_task
|
||||
def import_from_webdav_all_users():
|
||||
"""Import from WebDAV for all users (replaces */2 cron)."""
|
||||
from vrobbler.apps.scrobbles.importers.webdav import (
|
||||
import_from_webdav_for_all_users,
|
||||
)
|
||||
|
||||
import_from_webdav_for_all_users()
|
||||
|
||||
|
||||
@shared_task
|
||||
def import_from_imap_all_users():
|
||||
"""Deprecated — BG Stats files now picked up from WebDAV var/bgstats/."""
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"IMAP import is deprecated. Upload .bgsplay files to WebDAV var/bgstats/ instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
logger.warning("Skipping deprecated IMAP import (use WebDAV var/bgstats/ instead)")
|
||||
|
||||
|
||||
@shared_task
|
||||
def import_from_lichess_all_users():
|
||||
"""Import chess games from Lichess for all users (replaces */20 cron)."""
|
||||
from vrobbler.apps.boardgames.sources.lichess import (
|
||||
import_chess_games_for_all_users,
|
||||
)
|
||||
|
||||
import_chess_games_for_all_users()
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_notification_for_in_progress():
|
||||
"""Send ntfy stop-notifications for in-progress scrobbles (replaces */3 cron)."""
|
||||
from vrobbler.apps.scrobbles.utils import (
|
||||
send_stop_notifications_for_in_progress_scrobbles,
|
||||
)
|
||||
|
||||
send_stop_notifications_for_in_progress_scrobbles()
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_mood_checkin():
|
||||
"""Send mood check-in reminders (replaces @hourly cron)."""
|
||||
from vrobbler.apps.scrobbles.utils import send_mood_checkin_reminders
|
||||
|
||||
send_mood_checkin_reminders()
|
||||
|
||||
|
||||
@shared_task
|
||||
def backfill_scrobble_sentiment():
|
||||
"""Backfill VADER sentiment for scrobbles with notes (replaces @hourly cron)."""
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.utils import analyze_scrobble_sentiment
|
||||
|
||||
qs = Scrobble.objects.filter(
|
||||
models.Q(log__notes__isnull=False)
|
||||
& ~models.Q(log__notes=[])
|
||||
& ~models.Q(log__notes={})
|
||||
& models.Q(log__sentiment__isnull=True)
|
||||
)
|
||||
|
||||
count = 0
|
||||
for scrobble in qs.iterator():
|
||||
if analyze_scrobble_sentiment(scrobble):
|
||||
count += 1
|
||||
|
||||
logger.info(
|
||||
"Backfilled sentiment for %d scrobbles",
|
||||
count,
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def add_favorite_to_mopidy_playlist(favorite_id):
|
||||
from scrobbles.models import FavoriteMedia
|
||||
from scrobbles.utils import add_track_to_mopidy_favorites_playlist
|
||||
|
||||
favorite = FavoriteMedia.objects.filter(id=favorite_id).first()
|
||||
if not favorite:
|
||||
return
|
||||
add_track_to_mopidy_favorites_playlist(favorite)
|
||||
|
||||
|
||||
@shared_task
|
||||
def remove_favorite_from_mopidy_playlist(user_id, track_id):
|
||||
from music.models import Track
|
||||
from scrobbles.utils import remove_track_from_mopidy_favorites_playlist
|
||||
|
||||
User = get_user_model()
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
track = Track.objects.get(id=track_id)
|
||||
except (User.DoesNotExist, Track.DoesNotExist):
|
||||
return
|
||||
|
||||
import types
|
||||
|
||||
proxy = types.SimpleNamespace(
|
||||
media_type="Track",
|
||||
user=user,
|
||||
user_id=user.id,
|
||||
track=track,
|
||||
)
|
||||
remove_track_from_mopidy_favorites_playlist(proxy)
|
||||
|
||||
|
||||
@shared_task
|
||||
def add_scrobble_to_mopidy_queue(scrobble_id):
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
scrobble = Scrobble.objects.filter(id=scrobble_id).first()
|
||||
if not scrobble:
|
||||
return
|
||||
|
||||
profile = scrobble.user.profile
|
||||
mopidy_url = profile.mopidy_api_url
|
||||
if not mopidy_url:
|
||||
return
|
||||
|
||||
mopidy_uri = (scrobble.log or {}).get("raw_data", {}).get("mopidy_uri")
|
||||
track = scrobble.track if scrobble.media_type == "Track" else None
|
||||
if not mopidy_uri and track:
|
||||
sibling = (
|
||||
Scrobble.objects.filter(track=track, user=scrobble.user)
|
||||
.order_by("-timestamp")
|
||||
.iterator()
|
||||
)
|
||||
for s in sibling:
|
||||
uri = (s.log or {}).get("raw_data", {}).get("mopidy_uri")
|
||||
if uri:
|
||||
mopidy_uri = uri
|
||||
break
|
||||
|
||||
if not mopidy_uri:
|
||||
logger.warning(
|
||||
"No Mopidy URI found for scrobble",
|
||||
extra={"scrobble_id": scrobble_id, "user_id": scrobble.user_id},
|
||||
)
|
||||
return
|
||||
|
||||
import requests
|
||||
|
||||
rpc_url = mopidy_url.rstrip("/") + "/rpc"
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "core.tracklist.add",
|
||||
"params": {"uris": [mopidy_uri]},
|
||||
}
|
||||
try:
|
||||
resp = requests.post(rpc_url, json=payload, timeout=10)
|
||||
resp.raise_for_status()
|
||||
rpc_result = resp.json()
|
||||
if rpc_result.get("error"):
|
||||
logger.error(
|
||||
"Mopidy error adding to queue",
|
||||
extra={"error": rpc_result["error"], "scrobble_id": scrobble_id},
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Added track to Mopidy queue",
|
||||
extra={"scrobble_id": scrobble_id, "mopidy_uri": mopidy_uri},
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
logger.error(
|
||||
"Failed to add track to Mopidy queue",
|
||||
extra={"scrobble_id": scrobble_id, "error": str(e)},
|
||||
)
|
||||
|
||||
|
||||
@shared_task
|
||||
def add_scrobble_to_mopidy_monthly_playlist(scrobble_id):
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.utils import add_track_to_mopidy_monthly_playlist
|
||||
|
||||
scrobble = Scrobble.objects.filter(id=scrobble_id).first()
|
||||
if not scrobble:
|
||||
return
|
||||
|
||||
track = scrobble.track if scrobble.media_type == "Track" else None
|
||||
if track:
|
||||
sibling = (
|
||||
Scrobble.objects.filter(track=track, user=scrobble.user)
|
||||
.order_by("-timestamp")
|
||||
.iterator()
|
||||
)
|
||||
for s in sibling:
|
||||
if (s.log or {}).get("raw_data", {}).get("mopidy_uri"):
|
||||
scrobble = s
|
||||
break
|
||||
|
||||
add_track_to_mopidy_monthly_playlist(scrobble)
|
||||
|
||||
@ -5,6 +5,7 @@ from tasks.webhooks import EmacsWebhookView, TodoistWebhookView
|
||||
app_name = "scrobbles"
|
||||
|
||||
urlpatterns = [
|
||||
path("calendar/", views.ScrobbleCalendarView.as_view(), name="calendar"),
|
||||
path("search/", views.ScrobbleSearchView.as_view(), name="search"),
|
||||
path("status/", views.ScrobbleStatusView.as_view(), name="status"),
|
||||
path(
|
||||
@ -57,6 +58,16 @@ urlpatterns = [
|
||||
views.KoReaderImportCreateView.as_view(),
|
||||
name="koreader-file-upload",
|
||||
),
|
||||
path(
|
||||
"upload/scale/",
|
||||
views.ScaleCSVImportCreateView.as_view(),
|
||||
name="scale-csv-upload",
|
||||
),
|
||||
path(
|
||||
"upload/trail-gpx/",
|
||||
views.TrailGPXImportCreateView.as_view(),
|
||||
name="trail-gpx-upload",
|
||||
),
|
||||
path(
|
||||
"lastfm-import/",
|
||||
views.lastfm_import,
|
||||
@ -116,6 +127,26 @@ urlpatterns = [
|
||||
views.ScrobbleRetroarchImportDetailView.as_view(),
|
||||
name="retroarch-import-detail",
|
||||
),
|
||||
path(
|
||||
"imports/bgstats/<slug:slug>/",
|
||||
views.ScrobbleBGStatsImportDetailView.as_view(),
|
||||
name="bgstats-import-detail",
|
||||
),
|
||||
path(
|
||||
"imports/scale/<slug:slug>/",
|
||||
views.ScrobbleScaleCSVImportDetailView.as_view(),
|
||||
name="scale-csv-import-detail",
|
||||
),
|
||||
path(
|
||||
"imports/trail-gpx/<slug:slug>/",
|
||||
views.ScrobbleTrailGPXImportDetailView.as_view(),
|
||||
name="trail-gpx-import-detail",
|
||||
),
|
||||
path(
|
||||
"imports/ebird-csv/<slug:slug>/",
|
||||
views.ScrobbleBirdingCSVImportDetailView.as_view(),
|
||||
name="ebird-csv-import-detail",
|
||||
),
|
||||
path(
|
||||
"long-plays/",
|
||||
views.ScrobbleLongPlaysView.as_view(),
|
||||
@ -127,7 +158,22 @@ urlpatterns = [
|
||||
views.ScrobbleDetailView.as_view(),
|
||||
name="detail",
|
||||
),
|
||||
path(
|
||||
"scrobbles/<slug:uuid>/add-to-mopidy-queue/",
|
||||
views.add_to_mopidy_queue,
|
||||
name="add-to-mopidy-queue",
|
||||
),
|
||||
path(
|
||||
"scrobbles/<slug:uuid>/add-to-mopidy-monthly-playlist/",
|
||||
views.add_to_mopidy_monthly_playlist,
|
||||
name="add-to-mopidy-monthly-playlist",
|
||||
),
|
||||
path("scrobbles/<slug:uuid>/start/", views.scrobble_start, name="start"),
|
||||
path("scrobbles/<slug:uuid>/finish/", views.scrobble_finish, name="finish"),
|
||||
path("scrobbles/<slug:uuid>/cancel/", views.scrobble_cancel, name="cancel"),
|
||||
path(
|
||||
"favorite/<str:media_type>/<int:object_id>/toggle/",
|
||||
views.toggle_favorite,
|
||||
name="toggle-favorite",
|
||||
),
|
||||
]
|
||||
|
||||
@ -15,6 +15,7 @@ from django.db import models
|
||||
from django.db.models.fields.json import KeyTextTransform
|
||||
from django.db.models.functions import Cast, TruncDate
|
||||
from django.utils import timezone
|
||||
from django.utils.dateformat import DateFormat
|
||||
from profiles.models import UserProfile
|
||||
from profiles.utils import now_user_timezone
|
||||
from scrobbles.constants import LONG_PLAY_MEDIA
|
||||
@ -32,6 +33,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()
|
||||
|
||||
@ -77,8 +88,7 @@ def get_scrobbles_for_media(media_obj, user: User) -> models.QuerySet:
|
||||
return Scrobble.objects.filter(media_query, user=user)
|
||||
|
||||
|
||||
def get_recently_played_board_games(user: User) -> dict:
|
||||
...
|
||||
def get_recently_played_board_games(user: User) -> dict: ...
|
||||
|
||||
|
||||
def get_long_plays_in_progress(user: User) -> dict:
|
||||
@ -327,15 +337,13 @@ def send_stop_notifications_for_in_progress_scrobbles() -> int:
|
||||
|
||||
|
||||
def send_mood_checkin_reminders() -> int:
|
||||
"""Get all profiles with mood check-ins enabled and checkin!"""
|
||||
"""Send mood check-in to every user with the setting enabled."""
|
||||
from profiles.models import UserProfile
|
||||
|
||||
now = timezone.now()
|
||||
notifications_sent = 0
|
||||
for profile in UserProfile.objects.filter(mood_checkin_enabled=True):
|
||||
if profile.mood_checkin_frequency == "hourly" and now.minute == 0:
|
||||
MoodNtfyNotification(profile).send()
|
||||
notifications_sent += 1
|
||||
MoodNtfyNotification(profile).send()
|
||||
notifications_sent += 1
|
||||
|
||||
return notifications_sent
|
||||
|
||||
@ -415,6 +423,288 @@ def get_daily_calorie_dict_for_user(user_id: int) -> dict[date, int]:
|
||||
return {entry["day"]: entry["total_calories"] for entry in qs}
|
||||
|
||||
|
||||
def _mopidy_rpc(profile, method, params=None):
|
||||
rpc_url = profile.mopidy_api_url.rstrip("/") + "/rpc"
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": method,
|
||||
}
|
||||
if params:
|
||||
payload["params"] = params
|
||||
resp = requests.post(rpc_url, json=payload, timeout=10)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
if result.get("error"):
|
||||
raise RuntimeError(f'Mopidy error: {result["error"]}')
|
||||
return result.get("result")
|
||||
|
||||
|
||||
def _ensure_mopidy_playlist(profile):
|
||||
playlist_name = profile.favorites_mopidy_playlist
|
||||
# Strip any m3u: prefix and .m3u8 suffix the user may have included
|
||||
playlist_name = playlist_name.removeprefix("m3u:").removesuffix(".m3u8")
|
||||
|
||||
try:
|
||||
playlists = _mopidy_rpc(profile, "core.playlists.as_list") or []
|
||||
for pl in playlists:
|
||||
if pl.get("name") == playlist_name:
|
||||
existing = _mopidy_rpc(
|
||||
profile, "core.playlists.lookup", {"uri": pl["uri"]}
|
||||
)
|
||||
if existing:
|
||||
return existing
|
||||
except (requests.RequestException, RuntimeError):
|
||||
pass
|
||||
|
||||
result = _mopidy_rpc(
|
||||
profile, "core.playlists.create",
|
||||
{"name": playlist_name, "uri_scheme": "m3u"},
|
||||
)
|
||||
logger.info(
|
||||
"Created Mopidy favorites playlist",
|
||||
extra={"uri": result.get("uri") if result else playlist_name},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _scrobble_with_mopidy_uri(track, user):
|
||||
"""Find a scrobble for this track+user that has a mopidy_uri in its log."""
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
for scrobble in (
|
||||
Scrobble.objects.filter(track=track, user=user)
|
||||
.order_by("-timestamp")
|
||||
.iterator()
|
||||
):
|
||||
raw_data = scrobble.log.get("raw_data") or {}
|
||||
if raw_data.get("mopidy_uri"):
|
||||
return scrobble
|
||||
return None
|
||||
|
||||
|
||||
def add_track_to_mopidy_favorites_playlist(favorite):
|
||||
if favorite.media_type != "Track" or not favorite.track:
|
||||
return
|
||||
|
||||
profile = favorite.user.profile
|
||||
if not profile.favorites_mopidy_playlist or not profile.mopidy_api_url:
|
||||
return
|
||||
|
||||
track = favorite.track
|
||||
scrobble = _scrobble_with_mopidy_uri(track, favorite.user)
|
||||
|
||||
if not scrobble:
|
||||
logger.warning(
|
||||
"No Mopidy URI found for track",
|
||||
extra={"track_id": track.id, "user_id": favorite.user_id},
|
||||
)
|
||||
return
|
||||
|
||||
mopidy_uri = (scrobble.log or {}).get("raw_data", {}).get("mopidy_uri")
|
||||
|
||||
try:
|
||||
playlist = _ensure_mopidy_playlist(profile)
|
||||
if playlist and playlist.get("uri"):
|
||||
existing_tracks = playlist.get("tracks") or []
|
||||
track_uris = [t["uri"] for t in existing_tracks if isinstance(t, dict)]
|
||||
if mopidy_uri in track_uris:
|
||||
logger.info(
|
||||
"Track already in Mopidy favorites playlist",
|
||||
extra={"track_id": track.id, "mopidy_uri": mopidy_uri},
|
||||
)
|
||||
favorite.sent_to_mopidy = True
|
||||
favorite.save(update_fields=["sent_to_mopidy"])
|
||||
return
|
||||
|
||||
new_track = {"__model__": "Track", "uri": mopidy_uri}
|
||||
existing_tracks.append(new_track)
|
||||
_mopidy_rpc(
|
||||
profile,
|
||||
"core.playlists.save",
|
||||
{
|
||||
"playlist": {
|
||||
"__model__": "Playlist",
|
||||
"uri": playlist["uri"],
|
||||
"name": playlist.get("name", "Favorites"),
|
||||
"tracks": existing_tracks,
|
||||
"last_modified": playlist.get("last_modified"),
|
||||
},
|
||||
},
|
||||
)
|
||||
else:
|
||||
_mopidy_rpc(profile, "core.tracklist.add", {"uris": [mopidy_uri]})
|
||||
|
||||
favorite.sent_to_mopidy = True
|
||||
favorite.save(update_fields=["sent_to_mopidy"])
|
||||
logger.info(
|
||||
"Added track to Mopidy favorites playlist",
|
||||
extra={"track_id": track.id, "user_id": favorite.user_id},
|
||||
)
|
||||
except (requests.RequestException, RuntimeError) as e:
|
||||
logger.debug(e)
|
||||
logger.error(
|
||||
"Failed to add track to Mopidy favorites playlist",
|
||||
extra={"track_id": track.id, "user_id": favorite.user_id, "error": str(e)},
|
||||
)
|
||||
|
||||
|
||||
def resubmit_favorites_to_mopidy(user):
|
||||
from scrobbles.models import FavoriteMedia
|
||||
|
||||
favorites = FavoriteMedia.objects.filter(
|
||||
user=user,
|
||||
media_type="Track",
|
||||
track__isnull=False,
|
||||
)
|
||||
for favorite in favorites:
|
||||
add_track_to_mopidy_favorites_playlist(favorite)
|
||||
|
||||
|
||||
def remove_track_from_mopidy_favorites_playlist(favorite):
|
||||
if favorite.media_type != "Track" or not favorite.track:
|
||||
return
|
||||
|
||||
profile = favorite.user.profile
|
||||
if not profile.favorites_mopidy_playlist or not profile.mopidy_api_url:
|
||||
return
|
||||
|
||||
track = favorite.track
|
||||
scrobble = _scrobble_with_mopidy_uri(track, favorite.user)
|
||||
|
||||
if not scrobble:
|
||||
logger.warning(
|
||||
"No Mopidy URI found for track",
|
||||
extra={"track_id": track.id, "user_id": favorite.user_id},
|
||||
)
|
||||
return
|
||||
|
||||
mopidy_uri = (scrobble.log or {}).get("raw_data", {}).get("mopidy_uri")
|
||||
|
||||
try:
|
||||
playlist = _ensure_mopidy_playlist(profile)
|
||||
if playlist and playlist.get("uri"):
|
||||
existing_tracks = playlist.get("tracks") or []
|
||||
filtered = [
|
||||
t for t in existing_tracks
|
||||
if not (isinstance(t, dict) and t.get("uri") == mopidy_uri)
|
||||
]
|
||||
if len(filtered) == len(existing_tracks):
|
||||
logger.info(
|
||||
"Track not found in Mopidy favorites playlist",
|
||||
extra={"track_id": track.id, "mopidy_uri": mopidy_uri},
|
||||
)
|
||||
return
|
||||
|
||||
_mopidy_rpc(
|
||||
profile,
|
||||
"core.playlists.save",
|
||||
{
|
||||
"playlist": {
|
||||
"__model__": "Playlist",
|
||||
"uri": playlist["uri"],
|
||||
"name": playlist.get("name", "Favorites"),
|
||||
"tracks": filtered,
|
||||
"last_modified": playlist.get("last_modified"),
|
||||
},
|
||||
},
|
||||
)
|
||||
logger.info(
|
||||
"Removed track from Mopidy favorites playlist",
|
||||
extra={"track_id": track.id, "user_id": favorite.user_id},
|
||||
)
|
||||
except (requests.RequestException, RuntimeError) as e:
|
||||
logger.debug(e)
|
||||
logger.error(
|
||||
"Failed to remove track from Mopidy favorites playlist",
|
||||
extra={"track_id": track.id, "user_id": favorite.user_id, "error": str(e)},
|
||||
)
|
||||
|
||||
|
||||
def _ensure_mopidy_playlist_by_name(profile, playlist_name):
|
||||
"""Find or create a Mopidy playlist by name (without m3u: prefix handling)."""
|
||||
playlist_name = playlist_name.removeprefix("m3u:").removesuffix(".m3u8")
|
||||
try:
|
||||
playlists = _mopidy_rpc(profile, "core.playlists.as_list") or []
|
||||
for pl in playlists:
|
||||
if pl.get("name") == playlist_name:
|
||||
existing = _mopidy_rpc(
|
||||
profile, "core.playlists.lookup", {"uri": pl["uri"]}
|
||||
)
|
||||
if existing:
|
||||
return existing
|
||||
except (requests.RequestException, RuntimeError):
|
||||
pass
|
||||
|
||||
result = _mopidy_rpc(
|
||||
profile, "core.playlists.create",
|
||||
{"name": playlist_name, "uri_scheme": "m3u"},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def add_track_to_mopidy_monthly_playlist(scrobble):
|
||||
"""Add a scrobbled track to a monthly Mopidy playlist based on the user's pattern."""
|
||||
profile = scrobble.user.profile
|
||||
pattern = profile.monthly_mopidy_playlist_pattern
|
||||
if not pattern or not profile.mopidy_api_url:
|
||||
return
|
||||
|
||||
mopidy_uri = (scrobble.log or {}).get("raw_data", {}).get("mopidy_uri")
|
||||
if not mopidy_uri:
|
||||
return
|
||||
|
||||
now = now_user_timezone(profile)
|
||||
playlist_name = DateFormat(now).format(pattern)
|
||||
if not playlist_name:
|
||||
return
|
||||
|
||||
try:
|
||||
playlist = _ensure_mopidy_playlist_by_name(profile, playlist_name)
|
||||
if playlist and playlist.get("uri"):
|
||||
existing_tracks = playlist.get("tracks") or []
|
||||
track_uris = [t["uri"] for t in existing_tracks if isinstance(t, dict)]
|
||||
if mopidy_uri in track_uris:
|
||||
logger.info(
|
||||
"Track already in monthly Mopidy playlist",
|
||||
extra={"playlist": playlist_name, "mopidy_uri": mopidy_uri},
|
||||
)
|
||||
return
|
||||
|
||||
new_track = {"__model__": "Track", "uri": mopidy_uri}
|
||||
existing_tracks.append(new_track)
|
||||
_mopidy_rpc(
|
||||
profile,
|
||||
"core.playlists.save",
|
||||
{
|
||||
"playlist": {
|
||||
"__model__": "Playlist",
|
||||
"uri": playlist["uri"],
|
||||
"name": playlist.get("name", playlist_name),
|
||||
"tracks": existing_tracks,
|
||||
"last_modified": playlist.get("last_modified"),
|
||||
},
|
||||
},
|
||||
)
|
||||
else:
|
||||
_mopidy_rpc(profile, "core.tracklist.add", {"uris": [mopidy_uri]})
|
||||
|
||||
logger.info(
|
||||
"Added track to monthly Mopidy playlist",
|
||||
extra={
|
||||
"playlist": playlist_name,
|
||||
"track_id": scrobble.media_obj.id,
|
||||
"user_id": scrobble.user_id,
|
||||
},
|
||||
)
|
||||
except (requests.RequestException, RuntimeError) as e:
|
||||
logger.debug(e)
|
||||
logger.error(
|
||||
"Failed to add track to monthly Mopidy playlist",
|
||||
extra={"playlist": playlist_name, "error": str(e)},
|
||||
)
|
||||
|
||||
|
||||
def remove_last_part(url: str) -> str:
|
||||
url = url.rstrip("/")
|
||||
if "/" not in url:
|
||||
@ -509,8 +799,35 @@ def tokenize_title_to_tags(title: str) -> list[str]:
|
||||
cleaned = re.sub(r"[^\w\s]", "", cleaned)
|
||||
|
||||
words = [
|
||||
w.lower()
|
||||
for w in cleaned.split()
|
||||
if w.lower() not in STOPWORDS and len(w) > 2
|
||||
w.lower() for w in cleaned.split() if w.lower() not in STOPWORDS and len(w) > 2
|
||||
]
|
||||
return words
|
||||
|
||||
|
||||
def analyze_scrobble_sentiment(scrobble, overwrite=False) -> bool:
|
||||
"""Run VADER sentiment analysis on a scrobble's notes.
|
||||
|
||||
Stores result in log["sentiment"] as a dict with keys:
|
||||
neg, neu, pos, compound.
|
||||
|
||||
Returns True if analyzed, False if skipped (no notes or already done).
|
||||
"""
|
||||
from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer
|
||||
|
||||
log = scrobble.log or {}
|
||||
if not overwrite and log.get("sentiment") is not None:
|
||||
return False
|
||||
|
||||
notes_str = ""
|
||||
if scrobble.logdata:
|
||||
notes_str = scrobble.logdata.notes_as_str()
|
||||
if not notes_str:
|
||||
return False
|
||||
|
||||
analyzer = SentimentIntensityAnalyzer()
|
||||
scores = analyzer.polarity_scores(notes_str)
|
||||
|
||||
log["sentiment"] = scores
|
||||
scrobble.log = log
|
||||
scrobble.save(update_fields=["log"])
|
||||
return True
|
||||
|
||||
@ -2,6 +2,9 @@ import calendar
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
|
||||
import pendulum
|
||||
import pytz
|
||||
@ -11,7 +14,7 @@ from django.contrib import messages
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
|
||||
from django.db.models import Count, Max, Q
|
||||
from django.db.models import Count, Max, Q, Sum
|
||||
from django.db.models.query import QuerySet
|
||||
from rest_framework.authentication import TokenAuthentication
|
||||
from rest_framework.authtoken.models import Token
|
||||
@ -29,10 +32,12 @@ from django.http import (
|
||||
HttpResponseRedirect,
|
||||
JsonResponse,
|
||||
)
|
||||
from django.shortcuts import redirect
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.dateformat import DateFormat
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.views.generic import DetailView, FormView, TemplateView
|
||||
from django.views.generic.edit import CreateView
|
||||
from django.views.generic.list import ListView
|
||||
@ -69,15 +74,22 @@ from scrobbles.export import export_scrobbles
|
||||
from scrobbles.forms import ExportScrobbleForm, ScrobbleForm
|
||||
from scrobbles.models import (
|
||||
AudioScrobblerTSVImport,
|
||||
BGStatsImport,
|
||||
EBirdCSVImport,
|
||||
FavoriteMedia,
|
||||
KoReaderImport,
|
||||
LastFmImport,
|
||||
RetroarchImport,
|
||||
ScaleCSVImport,
|
||||
Scrobble,
|
||||
ScrobbleQuerySet,
|
||||
TrailGPXImport,
|
||||
)
|
||||
from scrobbles.scrobblers import *
|
||||
from scrobbles.tasks import (
|
||||
process_koreader_import,
|
||||
process_lastfm_import,
|
||||
process_trail_gpx_import,
|
||||
process_tsv_import,
|
||||
)
|
||||
from scrobbles.utils import (
|
||||
@ -106,8 +118,6 @@ class ScrobbleableListView(ListView):
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
if self.model == Mood:
|
||||
return queryset
|
||||
|
||||
user_filter = Q()
|
||||
if not self.request.user.is_anonymous:
|
||||
@ -135,9 +145,9 @@ class ChartContextMixin:
|
||||
return context
|
||||
|
||||
media_type_map = {
|
||||
"music.Artist": "artist",
|
||||
"music.Album": "album",
|
||||
"music.Track": "track",
|
||||
"music.artist": "artist",
|
||||
"music.album": "album",
|
||||
"music.track": "track",
|
||||
"videos.video": "video",
|
||||
"videos.series": "tv_series",
|
||||
"podcasts.podcast": "podcast",
|
||||
@ -151,10 +161,17 @@ class ChartContextMixin:
|
||||
media_type = media_type_map.get(model_label)
|
||||
|
||||
if media_type:
|
||||
context["charts"] = ChartRecord.objects.filter(
|
||||
charts_qs = ChartRecord.objects.filter(
|
||||
**{media_type: obj}, rank__in=[1, 2, 3]
|
||||
).exclude(day__isnull=False)
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
grouped = OrderedDict()
|
||||
for chart in charts_qs:
|
||||
grouped.setdefault(chart.period_type, []).append(chart)
|
||||
context["charts"] = grouped
|
||||
|
||||
return context
|
||||
|
||||
|
||||
@ -214,7 +231,7 @@ class RecentScrobbleList(ListView):
|
||||
|
||||
# Get user's home scrobble limit (default 20)
|
||||
home_limit = 20
|
||||
if hasattr(user, 'profile') and user.profile.home_scrobble_limit:
|
||||
if hasattr(user, "profile") and user.profile.home_scrobble_limit:
|
||||
home_limit = user.profile.home_scrobble_limit
|
||||
|
||||
today = timezone.localtime(timezone.now())
|
||||
@ -352,6 +369,7 @@ class ScrobbleListView(LoginRequiredMixin, ListView):
|
||||
else:
|
||||
tag_list = []
|
||||
self.tag_list = tag_list
|
||||
self._full_queryset = qs
|
||||
return qs
|
||||
|
||||
def _compute_overlap_groups(self, scrobbles):
|
||||
@ -418,6 +436,13 @@ class ScrobbleListView(LoginRequiredMixin, ListView):
|
||||
ctx["tag_list"] = getattr(self, "tag_list", [])
|
||||
scrobbles = list(ctx.get("object_list", []))
|
||||
ctx["overlap_map"] = self._compute_overlap_groups(scrobbles)
|
||||
full_qs = getattr(self, "_full_queryset", None)
|
||||
if full_qs is not None and getattr(self, "tag_list", []):
|
||||
total = (
|
||||
full_qs.aggregate(total=Sum("playback_position_seconds"))["total"]
|
||||
or 0
|
||||
)
|
||||
ctx["total_time_seconds"] = total
|
||||
return ctx
|
||||
|
||||
|
||||
@ -451,9 +476,16 @@ class ScrobbleImportListView(TemplateView):
|
||||
context_data["retroarch_imports"] = RetroarchImport.objects.filter(
|
||||
user=self.request.user,
|
||||
).order_by("-processing_started")[:10]
|
||||
context_data["birding_csv_imports"] = apps.get_model(
|
||||
"birds", "BirdingCSVImport"
|
||||
).objects.filter(
|
||||
context_data["bgstats_imports"] = BGStatsImport.objects.filter(
|
||||
user=self.request.user,
|
||||
).order_by("-processing_started")[:10]
|
||||
context_data["birding_csv_imports"] = EBirdCSVImport.objects.filter(
|
||||
user=self.request.user,
|
||||
).order_by("-processing_started")[:10]
|
||||
context_data["scale_csv_imports"] = ScaleCSVImport.objects.filter(
|
||||
user=self.request.user,
|
||||
).order_by("-processing_started")[:10]
|
||||
context_data["trail_gpx_imports"] = TrailGPXImport.objects.filter(
|
||||
user=self.request.user,
|
||||
).order_by("-processing_started")[:10]
|
||||
return context_data
|
||||
@ -477,6 +509,14 @@ class BaseScrobbleImportDetailView(DetailView):
|
||||
title = "LastFM Import"
|
||||
if self.model == RetroarchImport:
|
||||
title = "Retroarch Import"
|
||||
if self.model == BGStatsImport:
|
||||
title = "BG Stats Import"
|
||||
if self.model == EBirdCSVImport:
|
||||
title = "eBird CSV Import"
|
||||
if self.model == ScaleCSVImport:
|
||||
title = "Scale CSV Import"
|
||||
if self.model == TrailGPXImport:
|
||||
title = "Trail GPX Import"
|
||||
context_data["title"] = title
|
||||
return context_data
|
||||
|
||||
@ -497,6 +537,22 @@ class ScrobbleRetroarchImportDetailView(BaseScrobbleImportDetailView):
|
||||
model = RetroarchImport
|
||||
|
||||
|
||||
class ScrobbleBGStatsImportDetailView(BaseScrobbleImportDetailView):
|
||||
model = BGStatsImport
|
||||
|
||||
|
||||
class ScrobbleScaleCSVImportDetailView(BaseScrobbleImportDetailView):
|
||||
model = ScaleCSVImport
|
||||
|
||||
|
||||
class ScrobbleTrailGPXImportDetailView(BaseScrobbleImportDetailView):
|
||||
model = TrailGPXImport
|
||||
|
||||
|
||||
class ScrobbleBirdingCSVImportDetailView(BaseScrobbleImportDetailView):
|
||||
model = EBirdCSVImport
|
||||
|
||||
|
||||
class ManualScrobbleView(FormView):
|
||||
form_class = ScrobbleForm
|
||||
template_name = "scrobbles/manual_form.html"
|
||||
@ -569,6 +625,36 @@ class KoReaderImportCreateView(LoginRequiredMixin, JsonableResponseMixin, Create
|
||||
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER"))
|
||||
|
||||
|
||||
class ScaleCSVImportCreateView(LoginRequiredMixin, JsonableResponseMixin, CreateView):
|
||||
model = ScaleCSVImport
|
||||
fields = ["csv_file"]
|
||||
template_name = "scrobbles/upload_form.html"
|
||||
success_url = reverse_lazy("vrobbler-home")
|
||||
|
||||
def form_valid(self, form):
|
||||
self.object = form.save(commit=False)
|
||||
self.object.user = self.request.user
|
||||
self.object.original_filename = form.cleaned_data["csv_file"].name
|
||||
self.object.save()
|
||||
self.object.process()
|
||||
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER"))
|
||||
|
||||
|
||||
class TrailGPXImportCreateView(LoginRequiredMixin, JsonableResponseMixin, CreateView):
|
||||
model = TrailGPXImport
|
||||
fields = ["gpx_file"]
|
||||
template_name = "scrobbles/upload_form.html"
|
||||
success_url = reverse_lazy("vrobbler-home")
|
||||
|
||||
def form_valid(self, form):
|
||||
self.object = form.save(commit=False)
|
||||
self.object.user = self.request.user
|
||||
self.object.original_filename = form.cleaned_data["gpx_file"].name
|
||||
self.object.save()
|
||||
process_trail_gpx_import.delay(self.object.id)
|
||||
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER"))
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def lastfm_import(request):
|
||||
@ -763,7 +849,27 @@ def scrobble_start(request, uuid):
|
||||
scrobble = None
|
||||
user_id = request.user.id
|
||||
if media_obj:
|
||||
media_obj.scrobble_for_user(user_id)
|
||||
log_data = None
|
||||
if (
|
||||
request.GET.get("resume")
|
||||
and media_obj.__class__.__name__ == Scrobble.MediaType.BOOK
|
||||
):
|
||||
last_scrobble = (
|
||||
Scrobble.objects.filter(
|
||||
book=media_obj,
|
||||
user_id=user_id,
|
||||
)
|
||||
.filter(
|
||||
Q(long_play_complete=False) | Q(long_play_complete__isnull=True)
|
||||
)
|
||||
.filter(log__page_end__isnull=False)
|
||||
.order_by("-timestamp")
|
||||
.first()
|
||||
)
|
||||
if last_scrobble and last_scrobble.logdata:
|
||||
next_page = last_scrobble.logdata.page_end + 1
|
||||
log_data = {"page_start": next_page}
|
||||
media_obj.scrobble_for_user(user_id, log=log_data)
|
||||
|
||||
if scrobble:
|
||||
messages.add_message(
|
||||
@ -870,6 +976,113 @@ def scrobble_cancel(request, uuid):
|
||||
return HttpResponseRedirect(request.META.get("HTTP_REFERER"))
|
||||
|
||||
|
||||
@require_POST
|
||||
def add_to_mopidy_queue(request, uuid):
|
||||
if not request.user.is_authenticated:
|
||||
return redirect("scrobbles:detail", uuid=uuid)
|
||||
|
||||
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
|
||||
mopidy_url = request.user.profile.mopidy_api_url
|
||||
|
||||
if not mopidy_url:
|
||||
messages.add_message(
|
||||
request,
|
||||
messages.ERROR,
|
||||
"Mopidy API URL not configured in your profile settings.",
|
||||
)
|
||||
return redirect("scrobbles:detail", uuid=uuid)
|
||||
|
||||
from scrobbles.tasks import add_scrobble_to_mopidy_queue as task
|
||||
|
||||
task.delay(scrobble.id)
|
||||
msg = f'Adding "{scrobble.media_obj}" to Mopidy queue.'
|
||||
messages.add_message(request, messages.SUCCESS, msg)
|
||||
return redirect("scrobbles:detail", uuid=uuid)
|
||||
|
||||
|
||||
@require_POST
|
||||
def add_to_mopidy_monthly_playlist(request, uuid):
|
||||
if not request.user.is_authenticated:
|
||||
return redirect("scrobbles:detail", uuid=uuid)
|
||||
|
||||
scrobble = get_object_or_404(Scrobble, uuid=uuid, user=request.user)
|
||||
profile = request.user.profile
|
||||
pattern = profile.monthly_mopidy_playlist_pattern
|
||||
|
||||
if not pattern or not profile.mopidy_api_url:
|
||||
messages.add_message(
|
||||
request,
|
||||
messages.ERROR,
|
||||
"Monthly playlist pattern or Mopidy API URL not configured in your profile.",
|
||||
)
|
||||
return redirect("scrobbles:detail", uuid=uuid)
|
||||
|
||||
now = now_user_timezone(profile)
|
||||
playlist_name = DateFormat(now).format(pattern)
|
||||
|
||||
from scrobbles.tasks import add_scrobble_to_mopidy_monthly_playlist as task
|
||||
|
||||
task.delay(scrobble.id)
|
||||
messages.add_message(
|
||||
request,
|
||||
messages.SUCCESS,
|
||||
f'Adding "{scrobble.media_obj}" to monthly playlist "{playlist_name}".',
|
||||
)
|
||||
return redirect("scrobbles:detail", uuid=uuid)
|
||||
|
||||
|
||||
@require_POST
|
||||
def toggle_favorite(request, media_type, object_id):
|
||||
if not request.user.is_authenticated:
|
||||
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))
|
||||
|
||||
app_model_map = {
|
||||
"Video": ("videos", "Video"),
|
||||
"Channel": ("videos", "Channel"),
|
||||
"Track": ("music", "Track"),
|
||||
"PodcastEpisode": ("podcasts", "PodcastEpisode"),
|
||||
"SportEvent": ("sports", "SportEvent"),
|
||||
"Book": ("books", "Book"),
|
||||
"Paper": ("books", "Paper"),
|
||||
"VideoGame": ("videogames", "VideoGame"),
|
||||
"BoardGame": ("boardgames", "BoardGame"),
|
||||
"GeoLocation": ("locations", "GeoLocation"),
|
||||
"Beer": ("beers", "Beer"),
|
||||
"Puzzle": ("puzzles", "Puzzle"),
|
||||
"Food": ("foods", "Food"),
|
||||
"Trail": ("trails", "Trail"),
|
||||
"Task": ("tasks", "Task"),
|
||||
"WebPage": ("webpages", "WebPage"),
|
||||
"LifeEvent": ("lifeevents", "LifeEvent"),
|
||||
"Mood": ("moods", "Mood"),
|
||||
"BrickSet": ("bricksets", "BrickSet"),
|
||||
"BirdingLocation": ("birds", "BirdingLocation"),
|
||||
}
|
||||
|
||||
app_label, model_name = app_model_map.get(media_type, (None, None))
|
||||
if not app_label:
|
||||
messages.add_message(
|
||||
request, messages.ERROR, f"Unknown media type: {media_type}"
|
||||
)
|
||||
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))
|
||||
|
||||
model = apps.get_model(app_label, model_name)
|
||||
media_obj = get_object_or_404(model, id=object_id)
|
||||
result = FavoriteMedia.toggle(media_obj, request.user)
|
||||
|
||||
is_favorited = result is not None
|
||||
if not is_favorited:
|
||||
msg = f'Removed "{media_obj}" from favorites.'
|
||||
else:
|
||||
msg = f'Added "{media_obj}" to favorites.'
|
||||
|
||||
if request.headers.get("x-requested-with") == "XMLHttpRequest":
|
||||
return JsonResponse({"is_favorited": is_favorited, "message": msg})
|
||||
|
||||
messages.add_message(request, messages.SUCCESS, msg)
|
||||
return HttpResponseRedirect(request.META.get("HTTP_REFERER", "/"))
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
def export(request):
|
||||
@ -931,7 +1144,11 @@ class ScrobbleDetailView(DetailView):
|
||||
FormClass = self.get_form_class()
|
||||
|
||||
log = self.object.log or {}
|
||||
log["notes"] = self.object.logdata.notes_as_str(separator="\n")
|
||||
notes = log.get("notes")
|
||||
if isinstance(notes, dict):
|
||||
log["notes"] = notes
|
||||
else:
|
||||
log["notes"] = self.object.logdata.notes_as_str(separator="\n")
|
||||
|
||||
return FormClass(initial=log)
|
||||
|
||||
@ -951,6 +1168,9 @@ class ScrobbleDetailView(DetailView):
|
||||
if data.get("with_people_ids") is not None:
|
||||
data["with_people_ids"] = [p.id for p in data["with_people_ids"]]
|
||||
|
||||
if data.get("mood_reason_ids") is not None:
|
||||
data["mood_reason_ids"] = [r.id for r in data["mood_reason_ids"]]
|
||||
|
||||
if data.get("platform_id", False):
|
||||
data["platform_id"] = data["platform_id"].id
|
||||
|
||||
@ -1013,6 +1233,24 @@ class ScrobbleDetailView(DetailView):
|
||||
except EmptyPage:
|
||||
context["related_scrobbles"] = paginator.page(paginator.num_pages)
|
||||
|
||||
if self.request.user.is_authenticated:
|
||||
fk_field = self.MEDIA_FK_MAP.get(media_type)
|
||||
if fk_field and media_obj:
|
||||
context["is_favorited"] = FavoriteMedia.objects.filter(
|
||||
user=self.request.user, **{fk_field: media_obj}
|
||||
).exists()
|
||||
|
||||
if media_type == "Track" and media_obj:
|
||||
scrobbles = Scrobble.objects.filter(
|
||||
track=media_obj, user=self.object.user
|
||||
).order_by("-timestamp")[:20]
|
||||
context["has_mopidy_uri"] = any(
|
||||
(s.log or {}).get("raw_data", {}).get("mopidy_uri")
|
||||
for s in scrobbles
|
||||
)
|
||||
else:
|
||||
context["has_mopidy_uri"] = False
|
||||
|
||||
return context
|
||||
|
||||
|
||||
@ -1182,12 +1420,197 @@ class EmbeddableTopArtistsWidget(TemplateView):
|
||||
return context
|
||||
|
||||
|
||||
class ScrobbleCalendarView(LoginRequiredMixin, TemplateView):
|
||||
template_name = "scrobbles/calendar.html"
|
||||
|
||||
CALENDAR_MEDIA_TYPES = [
|
||||
"Task",
|
||||
"BirdingLocation",
|
||||
"Food",
|
||||
"Trail",
|
||||
"VideoGame",
|
||||
"Book",
|
||||
"Mood",
|
||||
"Video",
|
||||
"BoardGame",
|
||||
]
|
||||
|
||||
MEDIA_EMOJI = {
|
||||
"Task": "✅",
|
||||
"BirdingLocation": "🐦",
|
||||
"Food": "🍽️",
|
||||
"Trail": "🥾",
|
||||
"VideoGame": "🎮",
|
||||
"Book": "📚",
|
||||
"Mood": "🥰",
|
||||
"Video": "🎬",
|
||||
"BoardGame": "🎲",
|
||||
}
|
||||
|
||||
DEFAULT_EXCLUDE = ["Task", "Mood", "Video", "Food", "BoardGame"]
|
||||
|
||||
MONTH_COLORS = [
|
||||
"#db7a7a", # Jan
|
||||
"#db847a", # Feb
|
||||
"#b0db7a", # Mar
|
||||
"#7adb82", # Apr
|
||||
"#7adbb3", # May
|
||||
"#7ab6db", # Jun
|
||||
"#7a8edb", # Jul
|
||||
"#977adb", # Aug
|
||||
"#c47adb", # Sep
|
||||
"#db7ac5", # Oct
|
||||
"#db7a90", # Nov
|
||||
"#db7a7a", # Dec
|
||||
]
|
||||
|
||||
def _day_color(self, month_index, day_num, total_days):
|
||||
import colorsys
|
||||
|
||||
hue = (month_index - 1) * 30 / 360
|
||||
lightness = 0.80 + (day_num / total_days) * 0.15
|
||||
r, g, b = colorsys.hls_to_rgb(hue, lightness, 0.5)
|
||||
return f"#{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
|
||||
today = timezone.localdate()
|
||||
date_param = self.request.GET.get("date", today.isoformat()[:7])
|
||||
try:
|
||||
year, month = [int(x) for x in date_param.split("-")]
|
||||
except (ValueError, IndexError):
|
||||
year, month = today.year, today.month
|
||||
|
||||
_, total_days = calendar.monthrange(year, month)
|
||||
month_start = datetime(year, month, 1).date()
|
||||
month_end = datetime(year, month, total_days).date()
|
||||
first_weekday = month_start.weekday()
|
||||
|
||||
prev_month = month_start - timedelta(days=1)
|
||||
next_month = month_end + timedelta(days=1)
|
||||
|
||||
media_type_filter = self.request.GET.get("media_type")
|
||||
if media_type_filter and media_type_filter in self.CALENDAR_MEDIA_TYPES:
|
||||
active_types = [media_type_filter]
|
||||
else:
|
||||
active_types = [
|
||||
t for t in self.CALENDAR_MEDIA_TYPES if t not in self.DEFAULT_EXCLUDE
|
||||
]
|
||||
|
||||
scrobbles = (
|
||||
Scrobble.objects.filter(
|
||||
user=self.request.user,
|
||||
timestamp__date__gte=month_start,
|
||||
timestamp__date__lte=month_end,
|
||||
media_type__in=active_types,
|
||||
)
|
||||
.select_related(
|
||||
"task",
|
||||
"birding_location",
|
||||
"food",
|
||||
"trail",
|
||||
"video_game",
|
||||
"book",
|
||||
"mood",
|
||||
"video",
|
||||
"board_game",
|
||||
)
|
||||
.order_by("timestamp")
|
||||
)
|
||||
|
||||
from django.db.models import Count, Q
|
||||
from django.db.models.functions import TruncDate
|
||||
|
||||
total_by_day = dict(
|
||||
Scrobble.objects.filter(
|
||||
user=self.request.user,
|
||||
timestamp__date__gte=month_start,
|
||||
timestamp__date__lte=month_end,
|
||||
)
|
||||
.exclude(Q(media_type="GeoLocation") & Q(geo_location__title__isnull=True))
|
||||
.annotate(
|
||||
local_date=TruncDate(
|
||||
"timestamp", tzinfo=timezone.get_current_timezone()
|
||||
)
|
||||
)
|
||||
.values("local_date")
|
||||
.annotate(count=Count("id"))
|
||||
.values_list("local_date", "count")
|
||||
)
|
||||
|
||||
day_map = {d: [] for d in range(1, total_days + 1)}
|
||||
for scrobble in scrobbles:
|
||||
local_ts = timezone.localtime(scrobble.timestamp)
|
||||
day_map[local_ts.day].append(scrobble)
|
||||
|
||||
missing_uuids = [s for s in scrobbles if not s.uuid]
|
||||
if missing_uuids:
|
||||
for scrobble in missing_uuids:
|
||||
scrobble.uuid = uuid4()
|
||||
Scrobble.objects.bulk_update(missing_uuids, ["uuid"])
|
||||
|
||||
calendar_days = []
|
||||
month_color = self.MONTH_COLORS[(month - 1) % 12]
|
||||
for day_num in range(1, total_days + 1):
|
||||
day_scrobbles = []
|
||||
for scrobble in day_map[day_num]:
|
||||
day_scrobbles.append(
|
||||
{
|
||||
"uuid": scrobble.uuid,
|
||||
"emoji": self.MEDIA_EMOJI.get(scrobble.media_type, "📌"),
|
||||
"title": (
|
||||
str(scrobble.media_obj)
|
||||
if scrobble.media_obj
|
||||
else scrobble.media_type
|
||||
),
|
||||
"media_type": scrobble.media_type,
|
||||
}
|
||||
)
|
||||
calendar_days.append(
|
||||
{
|
||||
"day": day_num,
|
||||
"scrobbles": day_scrobbles,
|
||||
"total_count": total_by_day.get(
|
||||
datetime(year, month, day_num).date(), 0
|
||||
),
|
||||
"is_today": year == today.year
|
||||
and month == today.month
|
||||
and day_num == today.day,
|
||||
"color": self._day_color(month, day_num, total_days),
|
||||
}
|
||||
)
|
||||
|
||||
ctx.update(
|
||||
{
|
||||
"year": year,
|
||||
"month": month,
|
||||
"month_name": month_start.strftime("%B"),
|
||||
"total_days": total_days,
|
||||
"first_weekday": first_weekday,
|
||||
"calendar_days": calendar_days,
|
||||
"prev_month": prev_month.isoformat()[:7],
|
||||
"next_month": next_month.isoformat()[:7],
|
||||
"today": today,
|
||||
"current_month": today.isoformat()[:7],
|
||||
"day_names": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
|
||||
"month_color": month_color,
|
||||
"active_filter": media_type_filter or "",
|
||||
"media_types": [
|
||||
{"name": mt, "emoji": self.MEDIA_EMOJI.get(mt, "📌")}
|
||||
for mt in self.CALENDAR_MEDIA_TYPES
|
||||
],
|
||||
}
|
||||
)
|
||||
return ctx
|
||||
|
||||
|
||||
class ScrobbleSearchView(LoginRequiredMixin, TemplateView):
|
||||
template_name = "scrobbles/scrobble_search.html"
|
||||
|
||||
MEDIA_FIELDS = {
|
||||
"Video": ["video__title", "video__overview"],
|
||||
"Track": ["track__title", "track__artist__name", "track__album__name"],
|
||||
"Track": ["track__title", "track__artists__name", "track__album__name"],
|
||||
"PodcastEpisode": ["podcast_episode__title", None],
|
||||
"Book": ["book__title", "book__summary"],
|
||||
"Paper": ["paper__title", None],
|
||||
|
||||
47
vrobbler/apps/tasks/forms.py
Normal file
47
vrobbler/apps/tasks/forms.py
Normal file
@ -0,0 +1,47 @@
|
||||
import json
|
||||
|
||||
from django import forms
|
||||
|
||||
|
||||
class TaskNotesWidget(forms.Widget):
|
||||
template_name = "tasks/task_notes_widget.html"
|
||||
|
||||
class Media:
|
||||
js = ("tasks/task_notes.js",)
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
timestamps = data.getlist(f"{name}_timestamps")
|
||||
contents = data.getlist(f"{name}_contents")
|
||||
return {
|
||||
"timestamps": timestamps,
|
||||
"contents": contents,
|
||||
}
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
context = super().get_context(name, value, attrs)
|
||||
notes = {}
|
||||
if value:
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
notes = json.loads(value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
notes = {}
|
||||
elif isinstance(value, dict):
|
||||
notes = value
|
||||
context["widget"]["notes"] = notes
|
||||
return context
|
||||
|
||||
|
||||
class TaskNotesField(forms.Field):
|
||||
widget = TaskNotesWidget
|
||||
|
||||
def clean(self, value):
|
||||
if not value:
|
||||
return {}
|
||||
result = {}
|
||||
timestamps = value.get("timestamps", [])
|
||||
contents = value.get("contents", [])
|
||||
for i, ts in enumerate(timestamps):
|
||||
if i < len(contents) and ts and contents[i].strip():
|
||||
result[ts] = contents[i].strip()
|
||||
return result if result else {}
|
||||
@ -38,6 +38,18 @@ class TaskLogData(BaseLogData):
|
||||
"todoist_project_id",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def override_fields(cls) -> dict:
|
||||
from scrobbles.forms import NotesDictField
|
||||
|
||||
fields = {}
|
||||
for base in cls.mro()[1:]:
|
||||
if hasattr(base, "override_fields"):
|
||||
base_fields = base.override_fields()
|
||||
fields.update(base_fields)
|
||||
fields["notes"] = NotesDictField(required=False)
|
||||
return fields
|
||||
|
||||
def notes_as_str(self, separator: str = " | ") -> str:
|
||||
"""Return formatted notes with line breaks and no keys"""
|
||||
labels_str = ""
|
||||
@ -47,13 +59,14 @@ class TaskLogData(BaseLogData):
|
||||
lines = []
|
||||
if self.notes:
|
||||
notes = self.notes
|
||||
if isinstance(notes, dict):
|
||||
notes = [{k: v} for k, v in notes.items()]
|
||||
if isinstance(notes, str):
|
||||
notes = [notes]
|
||||
|
||||
for note in notes:
|
||||
if isinstance(note, dict):
|
||||
timestamp, note_text = next(iter(note.items()))
|
||||
# Flatten newlines and clean whitespace
|
||||
note_text = " ".join(note_text.strip().split())
|
||||
lines.append(f"{timestamp}: {note_text}")
|
||||
if isinstance(note, list):
|
||||
@ -63,31 +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):
|
||||
|
||||
36
vrobbler/apps/tasks/static/tasks/task_notes.js
Normal file
36
vrobbler/apps/tasks/static/tasks/task_notes.js
Normal file
@ -0,0 +1,36 @@
|
||||
(function() {
|
||||
function addNoteRow(container, widgetName, timestamp, content) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'task-note-row row mb-2 align-items-start';
|
||||
row.innerHTML =
|
||||
'<div class="col-md-3">' +
|
||||
'<input type="hidden" name="' + widgetName + '_timestamps" value="' + timestamp + '">' +
|
||||
'</div>' +
|
||||
'<div class="col-md-7">' +
|
||||
'<textarea name="' + widgetName + '_contents" class="form-control" rows="2">' + (content || '') + '</textarea>' +
|
||||
'</div>' +
|
||||
'<div class="col-md-2">' +
|
||||
'<button type="button" class="btn btn-sm btn-outline-danger remove-note">×</button>' +
|
||||
'</div>';
|
||||
container.appendChild(row);
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
var addBtn = e.target.closest('.add-note');
|
||||
if (addBtn) {
|
||||
e.preventDefault();
|
||||
var container = document.getElementById('task-notes-container');
|
||||
var now = Math.floor(Date.now() / 1000);
|
||||
var widgetName = addBtn.getAttribute('data-widget-name');
|
||||
addNoteRow(container, widgetName, String(now), '');
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
var removeBtn = e.target.closest('.remove-note');
|
||||
if (removeBtn) {
|
||||
e.preventDefault();
|
||||
removeBtn.closest('.task-note-row').remove();
|
||||
}
|
||||
});
|
||||
})();
|
||||
17
vrobbler/apps/tasks/templates/tasks/task_notes_widget.html
Normal file
17
vrobbler/apps/tasks/templates/tasks/task_notes_widget.html
Normal file
@ -0,0 +1,17 @@
|
||||
{% load static %}
|
||||
<div id="task-notes-container" class="task-notes-widget">
|
||||
{% for timestamp, content in widget.notes.items %}
|
||||
<div class="task-note-row row mb-2 align-items-start">
|
||||
<div class="col-md-3">
|
||||
<input type="hidden" name="{{widget.name}}_timestamps" value="{{timestamp}}">
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<textarea name="{{widget.name}}_contents" class="form-control" rows="2">{{content}}</textarea>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger remove-note">×</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary add-note" data-widget-name="{{widget.name}}">+ Add Note</button>
|
||||
@ -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}
|
||||
@ -83,18 +85,54 @@ def convert_notes_to_dict(commit=False):
|
||||
scrobble.log["notes"] = [
|
||||
value for d in note_list for value in d.values()
|
||||
]
|
||||
else:
|
||||
scrobble.log["notes"] = note_list
|
||||
count += 1
|
||||
if commit:
|
||||
scrobble.save(update_fields=["log"])
|
||||
print(f"Updated {count} todoist tasks scrobbles")
|
||||
|
||||
|
||||
def convert_old_boardgame_log_to_new(commit=False):
|
||||
scrobbles = Scrobble.objects.filter(board_game__isnull=False, log__has_key="notes")
|
||||
for scrobble in scrobbles:
|
||||
if isinstance(scrobble.log.get("notes"), str):
|
||||
scrobble.log["notes"] = [scrobble.log.pop("notes")]
|
||||
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 = make_note_timestamp(scrobble.timestamp + timedelta(seconds=10))
|
||||
parts = []
|
||||
for note in notes:
|
||||
if isinstance(note, dict):
|
||||
parts.append(next(iter(note.values()), ""))
|
||||
elif isinstance(note, list):
|
||||
parts.extend(str(x) for x in note)
|
||||
else:
|
||||
parts.append(str(note))
|
||||
scrobble.log["notes"] = {key: "\n".join(parts)}
|
||||
count += 1
|
||||
if commit:
|
||||
scrobble.save(update_fields=["log"])
|
||||
print(f"Updated {count} task scrobbles notes from list to dict")
|
||||
|
||||
|
||||
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:
|
||||
notes = scrobble.log.get("notes")
|
||||
if isinstance(notes, str):
|
||||
scrobble.log["notes"] = [notes]
|
||||
notes = [notes]
|
||||
if isinstance(notes, list):
|
||||
key_ts = scrobble.stop_timestamp or scrobble.timestamp
|
||||
scrobble.log["notes"] = {make_note_timestamp(key_ts): "\n".join(
|
||||
str(n) for n in notes
|
||||
)}
|
||||
count += 1
|
||||
if commit:
|
||||
scrobble.save(update_fields=["log"])
|
||||
print(f"Updated {scrobbles.count()} board game scrobbles")
|
||||
print(f"Updated {count} board game scrobbles")
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import logging
|
||||
|
||||
import pendulum
|
||||
from django.contrib import messages
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse_lazy
|
||||
@ -8,6 +9,7 @@ from rest_framework import status
|
||||
from rest_framework.decorators import api_view, permission_classes
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView
|
||||
from tasks.models import Task
|
||||
|
||||
@ -23,6 +25,72 @@ class TaskListView(ScrobbleableListView):
|
||||
class TaskDetailView(ScrobbleableDetailView):
|
||||
model = Task
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
if self.object.title != "Weigh-in":
|
||||
return ctx
|
||||
|
||||
scrobbles = list(
|
||||
Scrobble.objects.filter(
|
||||
user=self.request.user,
|
||||
task=self.object,
|
||||
log__weight__isnull=False,
|
||||
).order_by("timestamp")
|
||||
)
|
||||
if not scrobbles:
|
||||
return ctx
|
||||
|
||||
labels = []
|
||||
weight_data = []
|
||||
body_fat_data = []
|
||||
bmi_data = []
|
||||
for s in scrobbles:
|
||||
ts = s.timestamp
|
||||
if isinstance(ts, str):
|
||||
ts = pendulum.parse(ts)
|
||||
labels.append(ts.strftime("%Y-%m-%d"))
|
||||
log = s.log if isinstance(s.log, dict) else {}
|
||||
raw_weight = log.get("weight")
|
||||
weight_data.append(
|
||||
float(raw_weight) if raw_weight is not None else None
|
||||
)
|
||||
raw_bf = log.get("body_fat")
|
||||
body_fat_data.append(
|
||||
float(raw_bf) if raw_bf is not None else None
|
||||
)
|
||||
raw_bmi = log.get("bmi")
|
||||
bmi_data.append(
|
||||
float(raw_bmi) if raw_bmi is not None else None
|
||||
)
|
||||
|
||||
ctx["weighin_chart"] = {
|
||||
"labels": labels,
|
||||
"datasets": [
|
||||
{
|
||||
"label": "Weight",
|
||||
"data": weight_data,
|
||||
"borderColor": "#4bc0c0",
|
||||
"fill": False,
|
||||
"yAxisID": "y",
|
||||
},
|
||||
{
|
||||
"label": "Body Fat %",
|
||||
"data": body_fat_data,
|
||||
"borderColor": "#ff6384",
|
||||
"fill": False,
|
||||
"yAxisID": "y1",
|
||||
},
|
||||
{
|
||||
"label": "BMI",
|
||||
"data": bmi_data,
|
||||
"borderColor": "#36a2eb",
|
||||
"fill": False,
|
||||
"yAxisID": "y2",
|
||||
},
|
||||
],
|
||||
}
|
||||
return ctx
|
||||
|
||||
|
||||
@api_view(["GET"])
|
||||
@permission_classes([IsAuthenticated])
|
||||
|
||||
@ -136,6 +136,7 @@ class TodoistWebhookView(APIView):
|
||||
"todoist_type": todoist_type,
|
||||
"todoist_event": todoist_event,
|
||||
"updated_at": task_data.get("updated_at"),
|
||||
"posted_at": event_data.get("posted_at"),
|
||||
"details": task_data.get("description"),
|
||||
"notes": event_data.get("content"),
|
||||
"is_deleted": (
|
||||
@ -231,11 +232,12 @@ class EmacsWebhookView(APIView):
|
||||
status=status.HTTP_304_NOT_MODIFIED,
|
||||
)
|
||||
|
||||
if task_in_progress and post_data.get("notes"):
|
||||
if scrobble and scrobble.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"),
|
||||
)
|
||||
|
||||
self.logger.info(
|
||||
|
||||
23
vrobbler/apps/trails/migrations/0009_trail_route_waypoint.py
Normal file
23
vrobbler/apps/trails/migrations/0009_trail_route_waypoint.py
Normal file
@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-21 19:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("trails", "0008_alter_trail_genre"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="trail",
|
||||
name="route_lat",
|
||||
field=models.FloatField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="trail",
|
||||
name="route_lon",
|
||||
field=models.FloatField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user