Compare commits

...

120 Commits

Author SHA1 Message Date
a6ef34623e [release] Bump to version 40.1
Some checks failed
deploy / test (push) Successful in 2m5s
build / test (push) Successful in 2m6s
deploy / build-and-deploy (push) Failing after 28s
- Releases are still broken
- Fix bug on chart pages where trail titles missing
2026-06-03 16:13:54 -04:00
7cb48d20f6 [tooling] Try to fix deploy issues
Some checks failed
build / test (push) Has been cancelled
2026-06-03 16:13:16 -04:00
445103a878 [trails] Fix display of trails in charts
All checks were successful
build / test (push) Successful in 2m7s
2026-06-03 15:47:56 -04:00
579da8c44e [release] Bump to version 40.0
Some checks failed
build / test (push) Successful in 1m59s
deploy / test (push) Successful in 2m3s
deploy / build-and-deploy (push) Failing after 19s
- Fix error in org-mode task sync
- Adjust how similar artists are shown
2026-06-01 11:12:31 -04:00
daabd2f37f [tasks] Fix bug in syncing orgmode tasks without notes
All checks were successful
build / test (push) Successful in 1m50s
2026-06-01 11:02:56 -04:00
039c58cf89 [tooling] Try fixing deploys again
All checks were successful
build / test (push) Successful in 1m53s
2026-06-01 10:50:38 -04:00
410c033f12 [music] Fix slow loading artist page 2026-06-01 10:50:22 -04:00
ce302e4d45 [release] Bump to version 39.3
Some checks failed
build / test (push) Successful in 1m53s
deploy / test (push) Successful in 1m55s
deploy / build-and-deploy (push) Failing after 19s
- Issue found when doing a release
- Fix deploy actions running twice
2026-06-01 10:35:51 -04:00
19589c9463 [tooling] Fix failing deploys 2026-06-01 10:35:10 -04:00
3d9506b14e [tooling] Actulaly fix the release problems
All checks were successful
build / test (push) Successful in 2m1s
Turns out we need build and deploy in separate files to trigger at
different times. Now we build on all pushes, but only deploy on tag pushes.
2026-06-01 10:21:56 -04:00
23b87278b2 [tooling] Stop running release task twice 2026-06-01 10:12:00 -04:00
0b8e027c30 [release] Bump to version 39.2
Some checks failed
build & deploy / test (push) Successful in 1m59s
build & deploy / build-and-deploy (push) Failing after 24s
- Releases do not pin commit to the repo for display
- Fix the way timestamps are stored for notes on tasks
2026-06-01 10:11:02 -04:00
1bd9f0d942 [tooling] Fix commit stamping on release
All checks were successful
build & deploy / test (push) Successful in 1m56s
build & deploy / build-and-deploy (push) Has been skipped
2026-06-01 09:58:59 -04:00
fa7890cb21 [tasks] Fix timestamps for notes and syncing from org-mode (32973bb3)
All checks were successful
build & deploy / test (push) Successful in 1m54s
build & deploy / build-and-deploy (push) Has been skipped
2026-06-01 09:37:41 -04:00
957c32e3a7 [release] Bump to version 39.1
All checks were successful
build & deploy / test (push) Successful in 1m56s
build & deploy / build-and-deploy (push) Has been skipped
- Fix bug in tests for notes saving
2026-05-31 23:25:34 -04:00
8d069df9d1 [scrobbles] Fix log saving tests 2026-05-31 23:24:22 -04:00
96d1d7ac6b [release] Bump to version 39.0
Some checks failed
build & deploy / test (push) Failing after 1m43s
build & deploy / build-and-deploy (push) Has been skipped
- Clean up org-mode tasks metadata
- Actually push branches up and add a just command to do it
- Try to fix deploy failing with bad release plan
2026-05-31 23:17:52 -04:00
009b2ba243 [scrobbles] Big update for notes in tasks and boardgames
Some checks failed
build & deploy / build-and-deploy (push) Has been cancelled
build & deploy / test (push) Has been cancelled
2026-05-31 23:17:16 -04:00
4f051ae250 [tooling] Add push command to just
All checks were successful
build & deploy / test (push) Successful in 2m7s
build & deploy / build-and-deploy (push) Has been skipped
2026-05-31 13:56:57 -04:00
7e9fbb1bf6 [tooling] Try to fix CI script
All checks were successful
build & deploy / test (push) Successful in 1m58s
build & deploy / build-and-deploy (push) Has been skipped
2026-05-31 13:10:31 -04:00
ec1a54f623 [release] Bump to version 38.0
All checks were successful
build & deploy / test (push) Successful in 2m41s
build & deploy / build-and-deploy (push) Successful in 21s
- Fix release flow to be easier to trigger
- Move imported retroarch lrtl files to processed/ directory on WebDAV
- Add listenbrainz support for similar tracks
- Consolidate albums in the same musicbrainz_releasegroup_id
- Clean up metadata on music tracks
- Make artists_m2m field source of artist truth for albums
- Fix various artist album problem with Superwolves (track with multiple artists)
- Move imported eBird CSV files to processed/ directory on WebDAV
- Move imported Board Game CSV files to processed/ directory on WebDAV
- Move imported Scale CSV files to processed/ directory on WebDAV
- Allow special parameter to re-import already processed GPX files
- Move imported GPX files to processed/ directory on WebDAV
- Add CSS Grid calendar view for scrobbles
- Come up with a possible flow using WebDAV and super-productivity for tasks
- Fix PuzzleLogData has no attribute form
- Add PuzzleLogData class with with_people and completed
- Add weather lookup to the mood check-in flow
- Add importing of openScale CSV files to Tasks
- Add ability to track Birding sessions via BirdingLocation scrobbles
- List only the last 20 scrobbles per category on the home page
- Fix display of notes so they look like stickies
- Add searching to scrobbles
- Fix uniqueness of imdb_id messing up youtube videos
- Fix genearting chart records
- Save raw scrobble request data to every scrobble log
- Clean up follow up notifications for if you're still scrobbling
- Fix lookup of music tracks from Musicbrainz
- Check opencode about a way to present stats like movies per month
- Fix bug in Jellyfin audio track playback
- Auto calc duration if no playback time seconds present
- Fix bug in video find_or_create
- Update admin page to be easier to use
- Fix migrations and update repo
- Add recipe parsing for food lookups
- Videos are scrobbling duplicates again
- Fix board games not saving BGG id on lookup
- Fix board game lookup with name like Unmatched Game System
- Fix raw text webpage title not truncating to 254 chars
2026-05-31 12:58:31 -04:00
b502667ca6 [importers] Retroarch webdav imports oldest first 2026-05-31 12:35:39 -04:00
263874288a [importers] WebDAV for retroarch should stash in procesesd now
All checks were successful
build & deploy / test (push) Successful in 2m18s
build & deploy / build-and-deploy (push) Successful in 49s
2026-05-31 12:09:56 -04:00
bca90c97ae [music] Add listenbrainz support 2026-05-31 11:21:11 -04:00
12f49a6cee [project] Update tasks
All checks were successful
build & deploy / test (push) Successful in 2m0s
build & deploy / build-and-deploy (push) Successful in 29s
2026-05-29 19:47:52 -04:00
034c7cb413 [music] Add better track fetching 2026-05-29 19:47:09 -04:00
e737870733 [music] Add metadata cleanup command
All checks were successful
build & deploy / test (push) Successful in 2m2s
build & deploy / build-and-deploy (push) Successful in 31s
2026-05-29 17:58:23 -04:00
95757650f6 [project] Add metadata clean up tasks 2026-05-29 08:37:10 -04:00
1928acf8a6 [music] Make artists_m2m field source of artist truth (05a24455)
All checks were successful
build & deploy / test (push) Successful in 2m0s
build & deploy / build-and-deploy (push) Successful in 32s
2026-05-29 08:26:30 -04:00
22956c7c7f [music] Tracks can have multiple artists
All checks were successful
build & deploy / test (push) Successful in 1m54s
build & deploy / build-and-deploy (push) Successful in 33s
2026-05-28 23:37:46 -04:00
17aed1191d [videos] Remove unused source
All checks were successful
build & deploy / test (push) Successful in 2m2s
build & deploy / build-and-deploy (push) Successful in 30s
2026-05-28 23:26:49 -04:00
0b1fb8667c [deps] Pin lxml version and upgrade python
Some checks failed
build & deploy / test (push) Successful in 1m52s
build & deploy / build-and-deploy (push) Failing after 2m20s
2026-05-28 23:16:27 -04:00
13dd5b67d0 [importers] Add processed stashing to eBird imports
All checks were successful
build & deploy / test (push) Successful in 1m55s
build & deploy / build-and-deploy (push) Successful in 28s
2026-05-28 17:40:19 -04:00
20c7874466 [importers] Add processed directory flow to board games
All checks were successful
build & deploy / test (push) Successful in 1m56s
build & deploy / build-and-deploy (push) Successful in 30s
2026-05-28 17:22:56 -04:00
62d8ffd794 [importers] Add flag to reimport already processed files
All checks were successful
build & deploy / test (push) Successful in 2m1s
build & deploy / build-and-deploy (push) Successful in 29s
2026-05-28 17:19:08 -04:00
bea2b2d187 [importers] Fix scale file checker 2026-05-28 17:05:48 -04:00
034cb99c77 [charts] Add timemachine links
All checks were successful
build & deploy / test (push) Successful in 2m4s
build & deploy / build-and-deploy (push) Successful in 35s
2026-05-28 16:57:57 -04:00
37187f33dd [settings] WebDAV is fast again, back to 3 minutes
All checks were successful
build & deploy / test (push) Successful in 1m58s
build & deploy / build-and-deploy (push) Successful in 1m3s
2026-05-28 10:00:11 -04:00
d7a23c3832 [charts] Clean up some chart views
All checks were successful
build & deploy / test (push) Successful in 1m58s
build & deploy / build-and-deploy (push) Successful in 29s
2026-05-27 22:08:58 -04:00
6d45571e75 [templates] Have some fun with CSS
All checks were successful
build & deploy / test (push) Successful in 2m14s
build & deploy / build-and-deploy (push) Successful in 32s
2026-05-27 21:53:03 -04:00
88fd0ed7f8 [moods] Checkin notification should use correct link 2026-05-27 21:52:40 -04:00
2100cedc1a [calendar] Fix no cal scroll
All checks were successful
build & deploy / test (push) Successful in 1m52s
build & deploy / build-and-deploy (push) Successful in 30s
2026-05-27 14:58:10 -04:00
2b17a92c6c [calendar] Fix bug in query
All checks were successful
build & deploy / test (push) Successful in 1m57s
build & deploy / build-and-deploy (push) Successful in 27s
2026-05-27 14:46:04 -04:00
72fd1ab90e [calendar] Kidding, only locations without a title
All checks were successful
build & deploy / test (push) Successful in 1m53s
build & deploy / build-and-deploy (push) Successful in 30s
2026-05-27 14:27:37 -04:00
301440909b [calendar] Exclude locations from count 2026-05-27 14:26:44 -04:00
389641002d [calendar] Make it responsive 2026-05-27 14:26:33 -04:00
43d514cf5b [calendar] Fix top bar
All checks were successful
build & deploy / test (push) Successful in 1m57s
build & deploy / build-and-deploy (push) Successful in 29s
2026-05-27 14:12:58 -04:00
25baeca2b0 [calendar] Add fun to calendar day links
Some checks failed
build & deploy / test (push) Successful in 1m52s
build & deploy / build-and-deploy (push) Has been cancelled
2026-05-27 14:10:37 -04:00
b4e15c73c1 [calendar] Add boardgames to pills 2026-05-27 14:00:42 -04:00
90f3d38687 [calendar] Exclude food by default too
All checks were successful
build & deploy / test (push) Successful in 1m55s
build & deploy / build-and-deploy (push) Successful in 26s
2026-05-27 13:53:36 -04:00
8afb227267 [calendar] Keep query params when navigating
Some checks failed
build & deploy / test (push) Successful in 1m52s
build & deploy / build-and-deploy (push) Has been cancelled
2026-05-27 13:51:36 -04:00
425abebc9a [calendar] Remove mood and video by default
All checks were successful
build & deploy / test (push) Successful in 1m53s
build & deploy / build-and-deploy (push) Successful in 29s
2026-05-27 13:37:44 -04:00
afb61f6622 [calendar] Fix timezone bug
All checks were successful
build & deploy / test (push) Successful in 1m50s
build & deploy / build-and-deploy (push) Successful in 32s
2026-05-27 13:28:40 -04:00
65815fc198 [templates] Good old fashioned hand edited CSS
Some checks failed
build & deploy / test (push) Successful in 1m53s
build & deploy / build-and-deploy (push) Has been cancelled
2026-05-27 13:26:23 -04:00
75479d91a3 [templates] Add pill buttons to calendar
All checks were successful
build & deploy / test (push) Successful in 1m49s
build & deploy / build-and-deploy (push) Successful in 28s
2026-05-27 13:22:52 -04:00
99789e5477 [templates] Add calendar view
All checks were successful
build & deploy / test (push) Successful in 1m52s
build & deploy / build-and-deploy (push) Successful in 35s
2026-05-27 13:10:12 -04:00
fd95f1e686 [importers] Add gpx files to processed dir on import
All checks were successful
build & deploy / test (push) Successful in 2m0s
build & deploy / build-and-deploy (push) Successful in 33s
Fix bug with koreader import too
2026-05-27 00:25:03 -04:00
e133c4149b [books] Add etag update option to webdav imports
All checks were successful
build & deploy / test (push) Successful in 1m50s
build & deploy / build-and-deploy (push) Successful in 29s
2026-05-26 09:44:01 -04:00
7e75828012 [settings] Reactivate webdav and set time to 5 min
All checks were successful
build & deploy / test (push) Successful in 1m48s
build & deploy / build-and-deploy (push) Successful in 27s
2026-05-26 09:33:43 -04:00
664148e702 [importers] Try speeding up koreader imports
All checks were successful
build & deploy / test (push) Successful in 1m55s
build & deploy / build-and-deploy (push) Successful in 30s
2026-05-26 09:26:34 -04:00
768819b664 [importers] Also speed up koreader imports 2026-05-26 09:15:13 -04:00
760d453165 [settings] Temp disable webdav imports
All checks were successful
build & deploy / test (push) Successful in 1m54s
build & deploy / build-and-deploy (push) Successful in 32s
2026-05-26 09:05:16 -04:00
2fac5815b1 [videogames] Fix how we scan for new RA playlogs 2026-05-26 09:01:27 -04:00
f4b30ade70 [settings] Oops, backup should run after chart batch jobs 2026-05-25 00:19:28 -04:00
1cbb84c29f [importers] Better webdav info logging
All checks were successful
build & deploy / test (push) Successful in 1m54s
build & deploy / build-and-deploy (push) Successful in 30s
2026-05-25 00:17:38 -04:00
b622b151d4 [settings] Never run db dumps at midnight
All checks were successful
build & deploy / test (push) Successful in 1m58s
build & deploy / build-and-deploy (push) Successful in 28s
2026-05-24 22:58:41 -04:00
4e1c3ffbf0 [music] Fix historical LFM imports
All checks were successful
build & deploy / test (push) Successful in 2m0s
build & deploy / build-and-deploy (push) Successful in 45s
2026-05-24 22:11:00 -04:00
8a419c7bbc [tasks] Add more robust ntfy messages 2026-05-24 12:55:50 -04:00
0639033aa9 [tasks] Fix backup locations
All checks were successful
build & deploy / test (push) Successful in 1m57s
build & deploy / build-and-deploy (push) Successful in 30s
2026-05-24 12:47:12 -04:00
6927729284 [tasks] Add more visibility to backups
All checks were successful
build & deploy / test (push) Successful in 2m3s
build & deploy / build-and-deploy (push) Successful in 32s
2026-05-24 12:39:51 -04:00
766f9db17c [tasks] Use no-blobs for backups
All checks were successful
build & deploy / test (push) Successful in 1m59s
build & deploy / build-and-deploy (push) Successful in 31s
2026-05-24 12:04:08 -04:00
25cbd88071 [tasks] Add auto importing of scale csv from webdav
All checks were successful
build & deploy / test (push) Successful in 1m48s
build & deploy / build-and-deploy (push) Successful in 30s
2026-05-24 11:15:50 -04:00
972568bebc [videogamse] Guard rails around tz info
All checks were successful
build & deploy / test (push) Successful in 1m57s
build & deploy / build-and-deploy (push) Successful in 29s
2026-05-24 10:58:31 -04:00
939c89d368 [birds] Fix reset sequence for ebird imports
All checks were successful
build & deploy / test (push) Successful in 2m1s
build & deploy / build-and-deploy (push) Successful in 41s
2026-05-24 10:39:47 -04:00
83e7061b92 [birds] Fix tests
All checks were successful
build & deploy / test (push) Successful in 1m54s
build & deploy / build-and-deploy (push) Successful in 27s
2026-05-23 18:29:16 -04:00
a171517f92 [birds] Move importer to scrobbles and webdav
Some checks failed
build & deploy / test (push) Failing after 1m32s
build & deploy / build-and-deploy (push) Has been skipped
2026-05-23 17:30:25 -04:00
a4030e89ec [importers] Add bgstats import class
All checks were successful
build & deploy / test (push) Successful in 1m57s
build & deploy / build-and-deploy (push) Successful in 29s
2026-05-23 17:03:17 -04:00
dce31ed840 [imports] Point retroarch imports at correct dir
All checks were successful
build & deploy / test (push) Successful in 1m55s
build & deploy / build-and-deploy (push) Successful in 30s
2026-05-23 16:34:35 -04:00
645e81299b [imports] Clean up logs and fix missing tz for birding
All checks were successful
build & deploy / test (push) Successful in 1m59s
build & deploy / build-and-deploy (push) Successful in 38s
2026-05-23 16:22:52 -04:00
72fa41977e [workflows] Make sure celery and beat are restarted
All checks were successful
build & deploy / test (push) Successful in 2m5s
build & deploy / build-and-deploy (push) Successful in 32s
2026-05-22 12:56:15 -04:00
56745b33f4 [boardgames] Fix bug in bgstats importer
All checks were successful
build & deploy / test (push) Successful in 2m8s
build & deploy / build-and-deploy (push) Successful in 33s
2026-05-22 12:51:06 -04:00
dd2f44e72f [boardgames] Move importer from IMAP to WebDAV
All checks were successful
build & deploy / test (push) Successful in 2m12s
build & deploy / build-and-deploy (push) Successful in 31s
2026-05-22 12:40:48 -04:00
41890d14d9 [celery] Fix deprecation warning on start
All checks were successful
build & deploy / test (push) Successful in 2m17s
build & deploy / build-and-deploy (push) Successful in 33s
2026-05-22 12:35:59 -04:00
c5f1ee2d64 [boardgames] Hash lrtl files and only import if changed
All checks were successful
build & deploy / test (push) Successful in 2m3s
build & deploy / build-and-deploy (push) Successful in 33s
2026-05-22 12:25:42 -04:00
26176ccd73 [boardgames] Fix lichess error
All checks were successful
build & deploy / test (push) Successful in 2m17s
build & deploy / build-and-deploy (push) Successful in 33s
2026-05-22 12:22:14 -04:00
9b7fa0d4f8 [tasks] Fix path names
All checks were successful
build & deploy / test (push) Successful in 2m28s
build & deploy / build-and-deploy (push) Successful in 34s
2026-05-22 12:18:12 -04:00
aeb460d677 [mood] Fix mood checkin sending
All checks were successful
build & deploy / test (push) Successful in 2m9s
build & deploy / build-and-deploy (push) Successful in 31s
2026-05-22 12:13:09 -04:00
4e059683b0 [videogames] Save lrtl files in zip file on import
All checks were successful
build & deploy / test (push) Successful in 2m0s
build & deploy / build-and-deploy (push) Successful in 31s
2026-05-22 12:09:15 -04:00
d3146433f2 [tasks] Move retroarch importing to webdav
All checks were successful
build & deploy / test (push) Successful in 1m48s
build & deploy / build-and-deploy (push) Successful in 26s
2026-05-22 11:58:09 -04:00
7b487f8494 [charts] Bird charts didn't use date filters
All checks were successful
build & deploy / test (push) Successful in 1m55s
build & deploy / build-and-deploy (push) Successful in 26s
2026-05-22 11:43:31 -04:00
f7675b8a02 [charts] Fix missing title for trails 2026-05-22 11:41:18 -04:00
d036dbe4fd [trails] Use latest scrobble for trail map
All checks were successful
build & deploy / test (push) Successful in 1m57s
build & deploy / build-and-deploy (push) Successful in 31s
2026-05-22 11:40:10 -04:00
86c495f22f [tasks] Move tasks out of cron and into celerybeat
Some checks failed
build & deploy / build-and-deploy (push) Has been cancelled
build & deploy / test (push) Has been cancelled
2026-05-22 11:38:22 -04:00
b9324d6443 [scrobbles] GeoLocations dont need to rebuild charts 2026-05-21 19:07:12 -04:00
c11858810c [trails] Fix name 2026-05-21 18:49:39 -04:00
9bafe45951 [trails] Use half way waypoints
All checks were successful
build & deploy / test (push) Successful in 1m51s
build & deploy / build-and-deploy (push) Successful in 29s
2026-05-21 17:13:04 -04:00
4d8e925f8c [trails] Don't trust titles
All checks were successful
build & deploy / test (push) Successful in 1m53s
build & deploy / build-and-deploy (push) Successful in 25s
2026-05-21 14:40:24 -04:00
e7fff25543 [templates] Fix leaflet issues and add to trails 2026-05-21 14:13:59 -04:00
62c200ab05 [trails] Fix webdav importer
All checks were successful
build & deploy / test (push) Successful in 1m54s
build & deploy / build-and-deploy (push) Successful in 26s
2026-05-21 13:54:28 -04:00
7c33095d87 [trails] Pull data out of FIT/GPX files
All checks were successful
build & deploy / test (push) Successful in 1m50s
build & deploy / build-and-deploy (push) Successful in 27s
2026-05-21 11:17:13 -04:00
cb50de13c0 [trails] Add auto GPX and FIT file importing
All checks were successful
build & deploy / test (push) Successful in 1m51s
build & deploy / build-and-deploy (push) Successful in 32s
2026-05-21 10:52:23 -04:00
08152de086 [project] Update task status 2026-05-21 09:46:09 -04:00
0d6b2c4afc [tasks] Add unit handling to weigh-ins
All checks were successful
build & deploy / test (push) Successful in 1m44s
build & deploy / build-and-deploy (push) Successful in 26s
2026-05-21 09:42:40 -04:00
9d3f7f434f [tasks] Add weigh-in task importer
All checks were successful
build & deploy / test (push) Successful in 1m48s
build & deploy / build-and-deploy (push) Successful in 33s
2026-05-21 09:09:41 -04:00
2b88f89794 [locations] Update view to show all locations
All checks were successful
build & deploy / test (push) Successful in 1m42s
build & deploy / build-and-deploy (push) Successful in 27s
2026-05-20 13:29:09 -04:00
3014a30616 [locations] Add weather fetching to birds and moods
All checks were successful
build & deploy / test (push) Successful in 1m49s
build & deploy / build-and-deploy (push) Successful in 30s
2026-05-20 13:19:54 -04:00
c7850878fe [moods] Add weather lookup to moods 2026-05-20 12:47:27 -04:00
db5b673cd5 [books] Calculate page data on scrobble save
All checks were successful
build & deploy / test (push) Successful in 1m45s
build & deploy / build-and-deploy (push) Successful in 28s
2026-05-19 22:54:06 -04:00
217e2443e2 [books] Fix resume reading link and page calc
All checks were successful
build & deploy / test (push) Successful in 1m44s
build & deploy / build-and-deploy (push) Successful in 33s
2026-05-19 21:03:42 -04:00
af8b1d4f8a [scrobbles] Remove special case for mood queryest
All checks were successful
build & deploy / test (push) Successful in 1m42s
build & deploy / build-and-deploy (push) Successful in 27s
2026-05-19 17:34:26 -04:00
d69bc6c235 [scrobbles] fixing saving mood with logdata
All checks were successful
build & deploy / test (push) Successful in 1m51s
build & deploy / build-and-deploy (push) Successful in 47s
2026-05-19 17:27:45 -04:00
cc0f7db453 [templates] Add source to scrobbles
All checks were successful
build & deploy / test (push) Successful in 1m42s
build & deploy / build-and-deploy (push) Successful in 30s
2026-05-18 09:12:07 -04:00
11bde2a306 [birds] Properly import from tz
All checks were successful
build & deploy / test (push) Successful in 1m52s
build & deploy / build-and-deploy (push) Successful in 33s
2026-05-17 10:07:13 -04:00
cd4f6ff71d [books] Calc run time from pages on save
All checks were successful
build & deploy / test (push) Successful in 1m52s
build & deploy / build-and-deploy (push) Successful in 27s
2026-05-15 21:50:43 -04:00
bf7e5677e6 [charts] Clean up how they're displayed
All checks were successful
build & deploy / test (push) Successful in 1m51s
build & deploy / build-and-deploy (push) Successful in 30s
2026-05-15 16:03:53 -04:00
2b04a17d77 [birds] Add charts for birds
All checks were successful
build & deploy / test (push) Successful in 1m55s
build & deploy / build-and-deploy (push) Successful in 29s
2026-05-15 15:05:46 -04:00
97f35f62b8 [vrobbler] Remove bgstats from settings
All checks were successful
build & deploy / test (push) Successful in 1m50s
build & deploy / build-and-deploy (push) Successful in 38s
2026-05-15 12:59:33 -04:00
dfd51c1343 [birds] Add small js file for form
Some checks failed
build & deploy / test (push) Failing after 1m7s
build & deploy / build-and-deploy (push) Has been skipped
2026-05-15 12:50:32 -04:00
e47328e572 [scrobbles] Fix circular dep problem in saving log data 2026-05-15 12:38:59 -04:00
e85d6ec779 [birds] Fix importer not finishing, and add formjs 2026-05-15 12:20:27 -04:00
120 changed files with 10474 additions and 613 deletions

View File

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

View File

@ -1,10 +1,8 @@
name: build & deploy
name: deploy
on:
push:
branches: ["**"]
tags: ["*"]
pull_request:
jobs:
test:
@ -22,7 +20,6 @@ jobs:
with:
python-version: "3.11"
# Cache pip + Poetry caches (rough equivalent to your mounted pip_cache)
- name: Cache pip/poetry
uses: actions/cache@v4
with:
@ -47,7 +44,6 @@ jobs:
run: |
poetry run pytest -n 5 --cov-report term:skip-covered --cov=vrobbler tests
# Notifications (success/failure) for the test job
- name: Notify success (ntfy)
if: success()
run: |
@ -71,8 +67,6 @@ jobs:
https://ntfy.unbl.ink/drone
build-and-deploy:
# Only deploy on tags (equivalent to Drone when: ref: refs/tags/*)
# if: startsWith(gitea.ref, 'refs/tags/')
needs: [test]
runs-on: ubuntu-latest
@ -97,10 +91,8 @@ 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: Copy wheel to server and deploy
@ -120,13 +112,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
python -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()

View File

@ -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,15 @@ 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/15] :vrobbler:project:personal:
** TODO [#C] Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment:
:PROPERTIES:
:ID: 37781d6a-f3b0-48b2-bf98-33c2c791cf85
:END:
** 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 +397,76 @@ 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:
: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 importer class for IMAP imports :vrobbler:feature:imap:importers:project:personal:
:PROPERTIES:
:ID: b1426d92-2feb-4d15-9738-d5b7b0594f96
:END:
** 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 :vrobbler:feature:books:personal:project:
:PROPERTIES:
:ID: 79f867c3-1288-4143-b6bf-2a452983ee9f
:END:
** TODO [#B] Fix koreader scrobble imports to use DST properly :vrobbler:personal:bug:books:imports:
:PROPERTIES:
:ID: 79758cba-a440-48b6-a637-efb88827acf2
:END:
- Note taken on [2025-09-25 Thu 10:37] \\
@ -429,12 +480,408 @@ 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 [#A] Orgmode tasks are not updated if in progress :tasks:orgmode:bug:
:PROPERTIES:
:ID: 7dcebb2c-7c4c-4ac5-bee6-c2e36c3811f9
:END:
*** Description
Currently if you POST to the orgmode webhook with a task that's already in progress, the request just stops there.
We should add logic where if the task is in-progress, instead of doing nothing,
it checks the webhook payload against the in-progress tasks and updates the
description of the scrobble.log with the incoming task description if it's
different. And the same for comments. If a comment (by timestamp key) is
different in the webhook than what's in the scrobble.log, update the comment in
the scrobble.log
** TODO [#C] Fix bug where Weigh-in imports do not set title :bug:tasks:scale:
:PROPERTIES:
:ID: 622e354a-8e66-4ecd-9e1c-a53f0a2ec362
:END:
*** Description
Currently when we import a scale CSV row and create data, the title is left
blank which makes it look funny in a list view. Let's save the weight as the
title of the Weigh-in task.
* Version 40.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 +1086,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:

View File

@ -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

Binary file not shown.

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
View 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,,
1 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
2 2026-05-20 11:56:58.076 31.09 1706.74 29.084072 3.438837 33.07067 2645.46 54.445187 192.68378

View File

@ -14,3 +14,10 @@ celery:
celery-beat:
poetry run celery -A vrobbler beat -l info
release kind="minor":
poetry run python scripts/release.py {{kind}}
push:
git push && git push gitea
git push --tags && git push --tags gitea

298
poetry.lock generated
View File

@ -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"
@ -5980,5 +6004,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 = "bd3f14a9cfce403db426af98774f1e3c41b97283aa43f4bd80f84594ee0dd726"

View File

@ -1,11 +1,11 @@
[tool.poetry]
name = "vrobbler"
version = "0.16.1"
version = "40.1"
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,9 @@ 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"
[tool.poetry.group.test]
optional = true

217
scripts/release.py Executable file
View 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(0)
# ---------------------------------------------------------------
# 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()

View File

@ -1,3 +1,5 @@
from datetime import timedelta
import pytest
from birds.importer import (
import_birding_csv,
@ -111,6 +113,13 @@ class TestImportBirdingCSV:
goose = next(b for b in birds if b["quantity"] == 6)
assert goose is not None
def test_import_sets_stop_timestamp(self, user, birding_csv_file):
import_birding_csv(birding_csv_file, user.id)
scrobble = Scrobble.objects.filter(source="Birding CSV Import").first()
assert scrobble.stop_timestamp is not None
expected = scrobble.timestamp + timedelta(minutes=9)
assert scrobble.stop_timestamp == expected
class TestBirdingCSVImportModel:
def test_create_import_model(self, db, user):

View File

@ -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

View File

@ -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")

View File

View 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

View File

@ -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,

View File

@ -2,8 +2,7 @@ import csv
import logging
import re
from collections import defaultdict
from datetime import datetime
from zoneinfo import ZoneInfo
from datetime import datetime, timedelta
from django.contrib.auth import get_user_model
@ -36,11 +35,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 +86,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()
)
@ -122,9 +123,10 @@ def import_birding_csv(file_path, user_id):
)
birds_data.append(entry.asdict)
duration_minutes = parse_duration(first_row.get("Duration", ""))
logdata = BirdSightingLogData(
birds=birds_data,
duration_minutes=parse_duration(first_row.get("Duration", "")),
duration_minutes=duration_minutes,
observation_type=first_row.get("Observation Type", "").strip() or None,
distance=first_row.get("Distance", "").strip() or None,
area=first_row.get("Area", "").strip() or None,
@ -134,9 +136,34 @@ 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,
log=log_dict,

View File

@ -0,0 +1,27 @@
document.addEventListener("DOMContentLoaded", function () {
var widget = document.querySelector(".bird-sightings-widget");
if (!widget) return;
var list = widget.querySelector(".bird-sightings-list");
widget.addEventListener("click", function (e) {
if (e.target.classList.contains("add-sighting-row")) {
var rows = list.querySelectorAll(".bird-sighting-row");
var template = rows[rows.length - 1];
if (!template) return;
var clone = template.cloneNode(true);
clone.querySelectorAll("select, input").forEach(function (el) {
el.value = "";
});
clone.querySelector('input[name$="_quantity"]').value = "1";
list.appendChild(clone);
}
if (e.target.classList.contains("remove-sighting")) {
var rows = list.querySelectorAll(".bird-sighting-row");
if (rows.length > 1) {
e.target.closest(".bird-sighting-row").remove();
}
}
});
});

View File

@ -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

View File

@ -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)

View File

@ -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:

View File

@ -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):

View File

@ -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:

View File

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

View File

@ -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"),
]

View File

@ -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": {

View File

@ -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

View File

@ -16,6 +16,7 @@ class GeoLocationAdmin(admin.ModelAdmin):
"altitude",
)
ordering = ("-created",)
search_fields = ("title",)
inlines = [
ScrobbleInline,
]

View File

@ -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)),

View File

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

View File

@ -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 (

View File

@ -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:

View File

@ -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,

View File

@ -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,

View File

@ -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

View File

@ -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",
]

View 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 []

View 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}"
)

View File

@ -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}"
)

View File

@ -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}"
)

View File

@ -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"),
),
]

View File

@ -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,
),
]

View File

@ -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,
),
]

View File

@ -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),
),
]

View File

@ -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),
),
]

View File

@ -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): ...

View File

@ -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")

View File

@ -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}")

View File

@ -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

View File

@ -35,6 +35,7 @@ class UserProfileForm(forms.ModelForm):
"enable_public_widgets",
"widget_custom_css",
"home_scrobble_limit",
"weigh_in_units",
]
widgets = {
"lastfm_password": forms.PasswordInput(render_value=True),

View File

@ -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,
),
),
]

View File

@ -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(
@ -66,6 +71,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}"

View File

@ -2,10 +2,14 @@ from django.contrib import admin
from scrobbles.models import (
AudioScrobblerTSVImport,
BGStatsImport,
EBirdCSVImport,
KoReaderImport,
LastFmImport,
RetroarchImport,
ScaleCSVImport,
Scrobble,
TrailGPXImport,
)
from scrobbles.mixins import Genre
@ -77,6 +81,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 = (

View File

@ -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

View File

@ -57,14 +57,28 @@ 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 ""
@ -76,18 +90,35 @@ class BaseLogData(JSONDataclass):
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
html_notes = []
for note in notes_list:
for line in note.split("\n"):
if isinstance(self.notes, dict):
for ts, text in self.notes.items():
note_text = " ".join(text.strip().split())
html_notes.append(
f'<div class="sticky-note">{ts}: {note_text}</div>'
)
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>')
elif isinstance(self.notes, list):
for note in self.notes:
if isinstance(note, dict):
timestamp, note_text = next(iter(note.items()))
note_text = " ".join(note_text.strip().split())
html_notes.append(
f'<div class="sticky-note">{timestamp}: {note_text}</div>'
)
elif isinstance(note, str):
for line in note.split("\n"):
if line.strip():
escaped_line = html.escape(line)
html_notes.append(
f'<div class="sticky-note">{escaped_line}</div>'
)
return mark_safe("".join(html_notes))

View File

@ -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,

View File

@ -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 {}

View File

@ -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

View File

@ -0,0 +1,113 @@
import csv
import logging
from datetime import datetime, timedelta
from django.contrib.auth import get_user_model
from scrobbles.models import Scrobble
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
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")
return created

View File

@ -0,0 +1,331 @@
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 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")
return created

View File

@ -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

View File

@ -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()

View File

@ -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)

View File

@ -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,
)

View File

@ -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}"
)
)
)

View 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",
},
),
]

View 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",
},
),
]

View File

@ -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),
),
]

View File

@ -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),
),
]

View File

@ -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",
},
),
]

View File

@ -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",
},
),
]

View File

@ -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),
]

View File

@ -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),
]

View File

@ -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),
),
]

View File

@ -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),
),
]

View File

@ -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),
),
]

View File

@ -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),
),
]

View File

@ -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):
@ -1452,38 +1679,38 @@ 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"])

View File

@ -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):

View File

@ -35,6 +35,7 @@ from scrobbles.notifications import ScrobbleNtfyNotification
from scrobbles.utils import (
convert_to_seconds,
extract_domain,
make_note_timestamp,
next_url_if_exists,
remove_last_part,
)
@ -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()
@ -687,9 +689,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",
@ -784,9 +787,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 +836,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 +906,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 +925,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")

View File

@ -5,7 +5,7 @@ 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.tasks import CHARTABLE_MEDIA_TYPES, SCROBBLES_WITHOUT_CHARTS, update_charts_for_timestamp
from scrobbles.utils import tokenize_title_to_tags
logger = logging.getLogger(__name__)
@ -19,7 +19,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 +33,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)

View File

@ -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,14 @@ 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.utils import timezone
logger = logging.getLogger(__name__)
User = get_user_model()
MEDIA_TYPES = [
CHARTABLE_MEDIA_TYPES = [
"artist",
"album",
"track",
@ -29,6 +30,10 @@ MEDIA_TYPES = [
"book",
]
SCROBBLES_WITHOUT_CHARTS = [
"geolocation",
]
@shared_task
def check_twitch_channels_for_vods():
@ -103,13 +108,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 +149,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 +203,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 +218,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 +228,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 +241,304 @@ 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()

View File

@ -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(),

View File

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

View File

@ -2,6 +2,7 @@ import calendar
import json
import logging
from datetime import datetime, timedelta
from uuid import uuid4
import pendulum
import pytz
@ -69,15 +70,21 @@ from scrobbles.export import export_scrobbles
from scrobbles.forms import ExportScrobbleForm, ScrobbleForm
from scrobbles.models import (
AudioScrobblerTSVImport,
BGStatsImport,
EBirdCSVImport,
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 +113,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 +140,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 +156,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 +226,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())
@ -451,9 +463,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 +496,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 +524,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 +612,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 +836,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(
@ -931,7 +1024,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)
@ -948,9 +1045,12 @@ class ScrobbleDetailView(DetailView):
original_value = (self.object.log or {}).get(field_name)
data[field_name] = original_value
if data.get("with_people_ids", False):
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
@ -1182,12 +1282,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],

View 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 {}

View File

@ -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):
@ -67,6 +80,8 @@ class TaskLogData(BaseLogData):
return ""
notes = self.notes
if isinstance(notes, dict):
notes = [{k: v} for k, v in notes.items()]
if isinstance(notes, str):
notes = [notes]

View 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">&times;</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();
}
});
})();

View 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">&times;</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>

View File

@ -64,6 +64,8 @@ def convert_old_todoist_log_to_new(commit=False):
def convert_notes_to_dict(commit=False):
from scrobbles.utils import make_note_timestamp
scrobbles = Scrobble.objects.filter(log__notes__isnull=False)
count = 0
for scrobble in scrobbles:
@ -71,7 +73,7 @@ def convert_notes_to_dict(commit=False):
print(f"Converting {scrobble} string note to dict")
if scrobble.log.get("notes") == "":
scrobble.log.pop("notes")
key = str(int(scrobble.timestamp.timestamp()))
key = make_note_timestamp(scrobble.timestamp)
notes = scrobble.log.pop("notes")
scrobble.log = {}
scrobble.log["notes"] = {key: notes}
@ -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")

View File

@ -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 task_in_progress:
emacs_scrobble_update_task(
post_data.get("source_id"),
post_data.get("notes"),
post_data.get("notes") or [],
user_id,
description=post_data.get("body"),
)
self.logger.info(

View 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),
),
]

View File

@ -1,4 +1,5 @@
from dataclasses import dataclass
from math import asin, cos, radians, sin, sqrt
from typing import Optional
from django.apps import apps
@ -12,10 +13,26 @@ from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
BNULL = {"blank": True, "null": True}
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))
@dataclass
class TrailLogData(BaseLogData, WithPeopleLogData):
effort: Optional[str] = None
difficulty: Optional[str] = None
distance_km: Optional[float] = None
elevation_gain_m: Optional[float] = None
moving_time_seconds: Optional[int] = None
activity_type: Optional[str] = None
avg_speed_kmh: Optional[float] = None
avg_heartrate: Optional[int] = None
max_heartrate: Optional[int] = None
calories: Optional[int] = None
class Trail(ScrobblableMixin):
@ -44,6 +61,8 @@ class Trail(ScrobblableMixin):
on_delete=models.DO_NOTHING,
**BNULL,
)
route_lat = models.FloatField(**BNULL)
route_lon = models.FloatField(**BNULL)
strava_id = models.CharField(max_length=255, **BNULL)
trailforks_id = models.CharField(max_length=255, **BNULL)
alltrails_id = models.CharField(max_length=255, **BNULL)
@ -55,6 +74,9 @@ class Trail(ScrobblableMixin):
max_length=10, choices=ActivityType.choices, **BNULL
)
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse("trails:trail_detail", kwargs={"slug": self.uuid})
@ -68,7 +90,30 @@ class Trail(ScrobblableMixin):
@classmethod
def find_or_create(cls, title: str) -> "Trail":
return cls.objects.filter(title=title).first()
trail = cls.objects.filter(title__iexact=title).first()
if not trail:
trail = cls.objects.create(title=title)
return trail
@classmethod
def find_by_trailhead(cls, lat, lon, tolerance_m=100, route_lat=None, route_lon=None):
candidates = cls.objects.filter(
trailhead_location__isnull=False,
).select_related("trailhead_location")
best = None
best_dist = float("inf")
for trail in candidates:
loc = trail.trailhead_location
d = haversine(lat, lon, loc.lat, loc.lon)
if d >= best_dist or d > tolerance_m:
continue
if route_lat is not None and trail.route_lat is not None:
route_dist = haversine(route_lat, route_lon, trail.route_lat, trail.route_lon)
if route_dist > tolerance_m:
continue
best = trail
best_dist = d
return best
def scrobbles(self, user_id):
Scrobble = apps.get_model("scrobbles", "Scrobble")

View File

@ -1,3 +1,5 @@
from django.apps import apps
from trails.models import Trail
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
@ -9,3 +11,16 @@ class TrailListView(ScrobbleableListView):
class TrailDetailView(ScrobbleableDetailView):
model = Trail
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
Scrobble = apps.get_model("scrobbles", "Scrobble")
context["trail_gpx_url"] = None
latest = (
Scrobble.objects.filter(trail=self.object, gpx_file__isnull=False)
.order_by("-timestamp")
.first()
)
if latest and latest.gpx_file:
context["trail_gpx_url"] = latest.gpx_file.url
return context

View File

@ -161,6 +161,8 @@ def import_retroarch_lrtl_files(playlog_path: str, user_id: int) -> List[dict]:
playback_position_seconds=playback_position_seconds,
played_to_completion=True,
in_progress=False,
timezone=getattr(timestamp.tzinfo, "key", None)
or getattr(timestamp.tzinfo, "zone", None),
long_play_seconds=game_data["runtime"],
long_play_complete=long_play_complete,
user_id=user_id,

View File

@ -1,48 +0,0 @@
import logging
from cinemagoerng import web as imdb
from videos.metadata import VideoMetadata, VideoType
logger = logging.getLogger(__name__)
def lookup_video_from_imdb(imdb_id: str) -> VideoMetadata:
if not imdb_id.startswith("tt"):
logger.warning("This method requires an IMDB ID starting with 'tt'")
return VideoMetadata()
video_metadata = VideoMetadata(imdb_id=imdb_id)
imdb_result: dict = {}
imdb_result = imdb.get_title(imdb_id)
logger.debug(f"Found result from IMDB: {imdb_result.title}")
if not imdb_result:
logger.info(
f"[lookup_video_from_imdb] no video found on imdb",
extra={"name_or_id": name_or_id},
)
return None
if imdb_result.type_id.lower() in ["movie", "tvepisode"]:
video_metadata.base_run_time_seconds = imdb_result.runtime * 60
video_metadata.imdb_id = imdb_id
video_metadata.title = imdb_result.title
video_metadata.year = imdb_result.year
video_metadata.plot = imdb_result.plot.get("en-US", "")
video_metadata.imdb_rating = imdb_result.rating
video_metadata.genres = imdb_result.genres
video_metadata.cover_url = imdb_result.primary_image
video_metadata.video_type = VideoType.MOVIE.value
if imdb_result.type_id == "tvEpisode":
video_metadata.video_type = VideoType.TV_EPISODE.value
series = imdb_result.series
video_metadata.tv_series_imdb_id = series.imdb_id
video_metadata.tv_series_title = series.title
video_metadata.episode_number = imdb_result.episode
video_metadata.season_number = imdb_result.season
video_metadata.next_imdb_id = imdb_result.next_episode_id
return video_metadata

View File

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

Some files were not shown because too many files have changed in this diff Show More