Compare commits

...

248 Commits

Author SHA1 Message Date
a70343d6f3 [release] Bump to version 41.0
Some checks failed
build / test (push) Successful in 1m56s
deploy / test (push) Successful in 1m58s
deploy / build-and-deploy (push) Failing after 34s
- For any scrobble detail page with notes display them better
- Imports should send notifications
- Board game imports send duplicate ntfy message
- Too many geolocation notifications go out
- Fix bug where Weigh-in imports do not set title
2026-06-04 11:22:48 -04:00
3e72042c24 [justfile] push for release should only push tags
Some checks failed
build / test (push) Has been cancelled
2026-06-04 11:22:14 -04:00
087c7775ae [templates] Fix note parsing
Some checks failed
build / test (push) Has been cancelled
2026-06-04 11:15:58 -04:00
3f71065ad6 [notifications] Send ntfy on more imports
All checks were successful
build / test (push) Successful in 2m6s
2026-06-04 10:50:32 -04:00
801672124f [notifications] Fix duplicate ntfy for board games
All checks were successful
build / test (push) Successful in 1m58s
2026-06-04 10:47:22 -04:00
811e9c1ce9 [notfications] Dont send ntfy on non-titled geolocations
All checks were successful
build / test (push) Successful in 2m7s
2026-06-04 10:44:21 -04:00
415b32bdc7 [tasks] Scale task sets title properly
All checks were successful
build / test (push) Successful in 1m57s
2026-06-03 16:49:11 -04:00
22319c807a [tooling] Fix deploys for realz
All checks were successful
build / test (push) Successful in 2m0s
2026-06-03 16:38:10 -04:00
f9ba6fec14 [release] Bump to version 40.2
Some checks failed
build / test (push) Successful in 2m0s
deploy / test (push) Successful in 1m59s
deploy / build-and-deploy (push) Failing after 25s
- Try fixing deploy bugs again
2026-06-03 16:32:09 -04:00
5f55163147 [tooling] Try fixing deploys one more time 2026-06-03 16:31:09 -04:00
a6ef34623e [release] Bump to version 40.1
Some checks failed
deploy / test (push) Successful in 2m5s
build / test (push) Successful in 2m6s
deploy / build-and-deploy (push) Failing after 28s
- Releases are still broken
- Fix bug on chart pages where trail titles missing
2026-06-03 16:13:54 -04:00
7cb48d20f6 [tooling] Try to fix deploy issues
Some checks failed
build / test (push) Has been cancelled
2026-06-03 16:13:16 -04:00
445103a878 [trails] Fix display of trails in charts
All checks were successful
build / test (push) Successful in 2m7s
2026-06-03 15:47:56 -04:00
579da8c44e [release] Bump to version 40.0
Some checks failed
build / test (push) Successful in 1m59s
deploy / test (push) Successful in 2m3s
deploy / build-and-deploy (push) Failing after 19s
- Fix error in org-mode task sync
- Adjust how similar artists are shown
2026-06-01 11:12:31 -04:00
daabd2f37f [tasks] Fix bug in syncing orgmode tasks without notes
All checks were successful
build / test (push) Successful in 1m50s
2026-06-01 11:02:56 -04:00
039c58cf89 [tooling] Try fixing deploys again
All checks were successful
build / test (push) Successful in 1m53s
2026-06-01 10:50:38 -04:00
410c033f12 [music] Fix slow loading artist page 2026-06-01 10:50:22 -04:00
ce302e4d45 [release] Bump to version 39.3
Some checks failed
build / test (push) Successful in 1m53s
deploy / test (push) Successful in 1m55s
deploy / build-and-deploy (push) Failing after 19s
- Issue found when doing a release
- Fix deploy actions running twice
2026-06-01 10:35:51 -04:00
19589c9463 [tooling] Fix failing deploys 2026-06-01 10:35:10 -04:00
3d9506b14e [tooling] Actulaly fix the release problems
All checks were successful
build / test (push) Successful in 2m1s
Turns out we need build and deploy in separate files to trigger at
different times. Now we build on all pushes, but only deploy on tag pushes.
2026-06-01 10:21:56 -04:00
23b87278b2 [tooling] Stop running release task twice 2026-06-01 10:12:00 -04:00
0b8e027c30 [release] Bump to version 39.2
Some checks failed
build & deploy / test (push) Successful in 1m59s
build & deploy / build-and-deploy (push) Failing after 24s
- Releases do not pin commit to the repo for display
- Fix the way timestamps are stored for notes on tasks
2026-06-01 10:11:02 -04:00
1bd9f0d942 [tooling] Fix commit stamping on release
All checks were successful
build & deploy / test (push) Successful in 1m56s
build & deploy / build-and-deploy (push) Has been skipped
2026-06-01 09:58:59 -04:00
fa7890cb21 [tasks] Fix timestamps for notes and syncing from org-mode (32973bb3)
All checks were successful
build & deploy / test (push) Successful in 1m54s
build & deploy / build-and-deploy (push) Has been skipped
2026-06-01 09:37:41 -04:00
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
f160f5a7b8 [birds] Add much better bird importing
Some checks failed
build & deploy / test (push) Failing after 1m19s
build & deploy / build-and-deploy (push) Has been skipped
2026-05-15 12:10:20 -04:00
b967c526f1 [birds] Add birding csv importer 2026-05-15 11:45:59 -04:00
77f143299d [birds] Add birding location scrobbling 2026-05-15 11:36:04 -04:00
7b7c66de8f [project] Update todos 2026-05-15 10:54:38 -04:00
3b8d7421b1 [webhooks] Fix auth for Emacs webhooks 2026-05-15 10:53:47 -04:00
e707c94b70 [boardgames] Add raw data to scrobbles
All checks were successful
build & deploy / test (push) Successful in 1m45s
build & deploy / build-and-deploy (push) Successful in 30s
2026-05-12 12:11:07 -04:00
a5510d7294 [templates] Limit home scrobbles to 20 by default, configurable
All checks were successful
build & deploy / test (push) Successful in 2m18s
build & deploy / build-and-deploy (push) Successful in 36s
2026-05-07 15:48:41 -04:00
666224875b [locations] Allow editing from detail page
All checks were successful
build & deploy / test (push) Successful in 2m3s
build & deploy / build-and-deploy (push) Successful in 32s
2026-05-02 19:23:33 -04:00
1866b43cbe [locations] Shim fix for logdata for GeoLocs
All checks were successful
build & deploy / test (push) Successful in 2m0s
build & deploy / build-and-deploy (push) Successful in 40s
2026-05-02 11:49:54 -04:00
df6a16f1e7 [location] Fix scrobble time
All checks were successful
build & deploy / test (push) Successful in 1m56s
build & deploy / build-and-deploy (push) Successful in 30s
2026-05-01 21:08:44 -04:00
752b4afaa9 [scrobbles] Add fix playback position command
All checks were successful
build & deploy / test (push) Successful in 1m59s
build & deploy / build-and-deploy (push) Successful in 29s
2026-05-01 21:02:45 -04:00
5175a9a39a [templates] Fix geolocation templates
All checks were successful
build & deploy / test (push) Successful in 1m59s
build & deploy / build-and-deploy (push) Successful in 37s
2026-05-01 20:50:36 -04:00
9d519138aa [locations] Biking is slower than that
All checks were successful
build & deploy / test (push) Successful in 1m56s
build & deploy / build-and-deploy (push) Successful in 30s
2026-05-01 18:42:55 -04:00
cc31c7e22e [locations] Continue to adjust thresholds
Some checks failed
build & deploy / build-and-deploy (push) Has been cancelled
build & deploy / test (push) Has been cancelled
2026-05-01 18:40:31 -04:00
eb604f5eb2 [locations] Update bike and drive thresholds
All checks were successful
build & deploy / test (push) Successful in 1m56s
build & deploy / build-and-deploy (push) Successful in 30s
2026-05-01 18:30:55 -04:00
2b2b20d1b7 [deploy] Use the first 8 chars of the hash
All checks were successful
build & deploy / test (push) Successful in 1m53s
build & deploy / build-and-deploy (push) Successful in 29s
2026-05-01 17:26:37 -04:00
41a7255ed8 [deploy] One more time to fix commits
All checks were successful
build & deploy / test (push) Successful in 1m56s
build & deploy / build-and-deploy (push) Successful in 31s
2026-05-01 17:22:15 -04:00
fc4db68725 Track _commit.py in git for build process
All checks were successful
build & deploy / test (push) Successful in 1m58s
build & deploy / build-and-deploy (push) Successful in 40s
2026-05-01 17:03:04 -04:00
b1d6f4726b [deploy] Fix deploy failing
Some checks failed
build & deploy / test (push) Successful in 1m57s
build & deploy / build-and-deploy (push) Failing after 16s
2026-05-01 16:57:51 -04:00
cbdb5c49d0 [deploy] Add commit catpure to deploy step
Some checks failed
build & deploy / test (push) Failing after 1m31s
build & deploy / build-and-deploy (push) Has been skipped
2026-05-01 16:48:58 -04:00
df2108807d [migrations] Catch up after tag name changes 2026-05-01 16:48:05 -04:00
de733d5893 [locations] Add distance and speed calculations
All checks were successful
build & deploy / test (push) Successful in 1m57s
build & deploy / deploy (push) Successful in 27s
2026-05-01 11:50:24 -04:00
acf0c342bf [scrobbles] Rename tag fields
All checks were successful
build & deploy / test (push) Successful in 2m8s
build & deploy / deploy (push) Successful in 25s
2026-04-30 17:37:21 -04:00
f486b1614b [videos] Blow out imdb tests
All checks were successful
build & deploy / test (push) Successful in 1m54s
build & deploy / deploy (push) Successful in 19s
2026-04-28 17:29:25 -04:00
9642aebfc0 [videos] Fix broken install with cinemagoer
Some checks failed
build & deploy / test (push) Failing after 1m39s
build & deploy / deploy (push) Has been skipped
2026-04-28 17:25:29 -04:00
9780e39825 [books] Clean up metadata import failure 2026-04-28 17:23:45 -04:00
5739b23f99 [moods] Source should be Website 2026-04-28 17:23:24 -04:00
181700b05e [moods] Add check in form
Some checks failed
build & deploy / test (push) Failing after 1m19s
build & deploy / deploy (push) Has been skipped
2026-04-28 17:00:02 -04:00
05cbcc1967 [reqs] remove cinemagoerng
Some checks failed
build & deploy / test (push) Failing after 1m19s
build & deploy / deploy (push) Has been skipped
2026-04-28 15:54:33 -04:00
ef4e510814 [moods] Add a nice form for checking in
All checks were successful
build & deploy / test (push) Successful in 2m2s
build & deploy / deploy (push) Successful in 38s
2026-04-28 15:44:48 -04:00
e912eda6e4 [moods] Update moods to Apple wellness flow 2026-04-28 15:16:39 -04:00
caf56289b4 [charts] Add Spotify charts!
All checks were successful
build & deploy / test (push) Successful in 1m56s
build & deploy / deploy (push) Successful in 28s
2026-04-13 15:59:44 -04:00
49a2429a4c [templates} Fix escaping HTML bugs in notes]
All checks were successful
build & deploy / test (push) Successful in 1m53s
build & deploy / deploy (push) Successful in 26s
2026-04-12 12:48:22 -04:00
88178e5ad2 [tasks] Add auto tagging from titles
All checks were successful
build & deploy / test (push) Successful in 1m52s
build & deploy / deploy (push) Successful in 24s
2026-04-10 14:52:34 -04:00
69dd47eac7 [profiles] Make the todoist connected widget clearer
Some checks failed
build & deploy / test (push) Successful in 1m58s
build & deploy / deploy (push) Failing after 2m2s
2026-04-10 08:42:34 -04:00
b11bb782e3 [scrobbles] Catch attribute error in overlapping
All checks were successful
build & deploy / test (push) Successful in 1m50s
build & deploy / deploy (push) Successful in 30s
2026-04-03 22:30:53 -04:00
523ed3a499 [scrobbles] Add overlap map
All checks were successful
build & deploy / test (push) Successful in 1m55s
build & deploy / deploy (push) Successful in 30s
2026-04-03 11:59:39 -04:00
decaba82f2 [scrobbles] Fix task title display
All checks were successful
build & deploy / test (push) Successful in 1m46s
build & deploy / deploy (push) Successful in 22s
2026-04-02 22:14:38 -04:00
4931e2d87b [scrobbles] Add emojis to the scrobble list
All checks were successful
build & deploy / test (push) Successful in 1m53s
build & deploy / deploy (push) Successful in 29s
2026-04-02 21:56:03 -04:00
25a60f4d60 [scrobbles] Add title of task to list
All checks were successful
build & deploy / test (push) Successful in 1m52s
build & deploy / deploy (push) Successful in 21s
2026-04-02 16:25:48 -04:00
6aef34e43f [scrobbles] Missed the bag tag url
All checks were successful
build & deploy / test (push) Successful in 1m50s
build & deploy / deploy (push) Successful in 20s
2026-04-02 15:55:59 -04:00
710aff5de4 [scrobbles] Fix tag filtering
Some checks failed
build & deploy / test (push) Successful in 1m53s
build & deploy / deploy (push) Has been cancelled
2026-04-02 15:53:55 -04:00
df673eaccc [templates] Clean up all list titles
All checks were successful
build & deploy / test (push) Successful in 1m48s
build & deploy / deploy (push) Successful in 22s
2026-04-02 12:31:48 -04:00
3b266083b0 [scrobbles] Fix filtering by tags
All checks were successful
build & deploy / test (push) Successful in 1m47s
build & deploy / deploy (push) Successful in 22s
2026-04-02 12:23:05 -04:00
29e179adad [scrobbles] Move tags out of org title and into page 2026-04-02 11:55:29 -04:00
b6af201ba3 [videos] Add preliminary Twitch video support 2026-04-02 11:10:39 -04:00
1bf558938d [templates] Clean up chart displays
Some checks failed
build & deploy / test (push) Successful in 1m49s
build & deploy / deploy (push) Failing after 2m4s
2026-04-02 10:54:16 -04:00
ce7128c7ac [videos] Add Twitch video scrobbling
All checks were successful
build & deploy / test (push) Successful in 1m51s
build & deploy / deploy (push) Successful in 26s
2026-04-01 12:09:05 -04:00
9ce6dd8876 [tasks] Fix todoist login link on settings
Some checks failed
build & deploy / deploy (push) Has been cancelled
build & deploy / test (push) Has been cancelled
2026-04-01 12:07:18 -04:00
78651af802 [tasks] Fix Todoist callback to lookup by user 2026-04-01 11:53:04 -04:00
0896517345 [tasks] Fix todoist webhook
All checks were successful
build & deploy / test (push) Successful in 1m46s
build & deploy / deploy (push) Successful in 21s
2026-03-31 19:19:46 -04:00
0d6fb5928b [tasks] Trying to fix webhooks for Todoist
All checks were successful
build & deploy / test (push) Successful in 1m46s
build & deploy / deploy (push) Successful in 22s
2026-03-31 18:38:04 -04:00
2dbd752609 [tasks] Clean up todoist scrobbling
All checks were successful
build & deploy / test (push) Successful in 1m47s
build & deploy / deploy (push) Successful in 21s
2026-03-31 18:28:49 -04:00
01aa0cba76 [tasks] Revert webhook for Todoist
All checks were successful
build & deploy / test (push) Successful in 1m49s
build & deploy / deploy (push) Successful in 21s
2026-03-31 18:05:06 -04:00
c5b7e57005 [widgets] Fix music aggregator
All checks were successful
build & deploy / test (push) Successful in 1m48s
build & deploy / deploy (push) Successful in 22s
2026-03-31 17:37:06 -04:00
0a74c692d2 [widgets] Fix the date filter
All checks were successful
build & deploy / test (push) Successful in 1m48s
build & deploy / deploy (push) Successful in 22s
2026-03-31 17:19:14 -04:00
192d0c489b [widgets] Add tasks
All checks were successful
build & deploy / test (push) Successful in 1m51s
build & deploy / deploy (push) Successful in 23s
2026-03-31 16:45:10 -04:00
9a8d5a7608 [widgets] Add food and trails widgets 2026-03-31 16:41:19 -04:00
6f6063e204 [widgets] Add date filtering to widgets 2026-03-31 16:38:05 -04:00
59998ac849 [scrobbles] Allow Bearer or Token
All checks were successful
build & deploy / test (push) Successful in 1m40s
build & deploy / deploy (push) Successful in 23s
2026-03-31 14:43:40 -04:00
3058bfdd22 [scrobbles] Use Bearer token keyword
Some checks failed
build & deploy / test (push) Failing after 1m22s
build & deploy / deploy (push) Has been skipped
2026-03-31 14:31:13 -04:00
173bbfa91f [scrobbles] Add tokenauth to webhookview
All checks were successful
build & deploy / test (push) Successful in 1m50s
build & deploy / deploy (push) Successful in 22s
2026-03-31 14:18:13 -04:00
6b278ad99f [books] Update widget to show all scrobbles WITH complete status
All checks were successful
build & deploy / test (push) Successful in 1m53s
build & deploy / deploy (push) Successful in 33s
2026-03-31 13:41:45 -04:00
2ca3dd1ed9 [scrobbles] Update webhook view permissions and small template changes 2026-03-30 12:54:21 -04:00
1e21bd9481 Revert "[views] Update permission order"
Some checks failed
build & deploy / test (push) Failing after 1m35s
build & deploy / deploy (push) Has been skipped
This reverts commit bf01f45eca.
2026-03-27 16:38:04 -04:00
ea66ee376d [tests] Fix scrobble tests
Some checks failed
build & deploy / test (push) Successful in 1m49s
build & deploy / deploy (push) Failing after 2m3s
2026-03-27 16:06:24 -04:00
64854f78a6 [templates] Clean up scrobble detail page
Some checks failed
build & deploy / test (push) Failing after 1m29s
build & deploy / deploy (push) Has been skipped
2026-03-27 15:35:05 -04:00
3d2f3cbe71 [tags] Add tags to scrobble media models
Some checks failed
build & deploy / test (push) Failing after 1m19s
build & deploy / deploy (push) Has been skipped
2026-03-26 17:32:47 -04:00
931246e043 [books] Fix book lookup when OL fails
Some checks failed
build & deploy / test (push) Failing after 1m22s
build & deploy / deploy (push) Has been skipped
2026-03-26 17:19:09 -04:00
77fd289c38 [reqs] Poetry update
Some checks failed
build & deploy / test (push) Failing after 1m24s
build & deploy / deploy (push) Has been skipped
2026-03-26 17:02:26 -04:00
a5e8c97063 [books] Fix lookup of date from OL 2026-03-26 17:02:13 -04:00
bf01f45eca [views] Update permission order 2026-03-26 17:01:52 -04:00
3beb0bc879 [books] Openlibrary uses requets now
All checks were successful
build & deploy / test (push) Successful in 1m52s
build & deploy / deploy (push) Successful in 27s
2026-03-26 16:40:32 -04:00
70bf06df38 [books] isort books models 2026-03-26 15:54:01 -04:00
062386a5b6 [books] Update lookup for books to use openlibrary 2026-03-26 15:53:15 -04:00
49f1410814 [books] Add an openlibrary source 2026-03-26 15:52:45 -04:00
2ab6fc6cba [charts] Use raw ID fields in the admin
All checks were successful
build & deploy / test (push) Successful in 1m47s
build & deploy / deploy (push) Successful in 21s
2026-03-25 13:02:05 -04:00
3db0330cfe [scrobbles] Properly serialize location_id 2026-03-25 12:59:39 -04:00
b949a84763 [boardgames] Fix looking up from bookmarklet
All checks were successful
build & deploy / test (push) Successful in 1m47s
build & deploy / deploy (push) Successful in 22s
2026-03-25 11:18:16 -04:00
45d524ca61 [boardgames] Format board game data
All checks were successful
build & deploy / test (push) Successful in 1m44s
build & deploy / deploy (push) Successful in 23s
2026-03-24 17:16:30 -04:00
5b3e91fdc1 [templates] Cleaning up how notes are displayed
All checks were successful
build & deploy / test (push) Successful in 1m43s
build & deploy / deploy (push) Successful in 22s
2026-03-24 16:07:17 -04:00
a79cc4c217 [scrobbles] Clean up notes on webpages
All checks were successful
build & deploy / test (push) Successful in 1m44s
build & deploy / deploy (push) Successful in 21s
2026-03-24 15:45:10 -04:00
06acc9ec2b [scrobbles] Fix note display to be sticky notes
All checks were successful
build & deploy / test (push) Successful in 1m44s
build & deploy / deploy (push) Successful in 21s
2026-03-24 15:20:03 -04:00
4121915aa3 [scrobbles] Fix display of task notes
All checks were successful
build & deploy / test (push) Successful in 1m40s
build & deploy / deploy (push) Successful in 21s
2026-03-24 15:10:33 -04:00
cce2db0ea1 [search] Add scrobble searching
All checks were successful
build & deploy / test (push) Successful in 1m44s
build & deploy / deploy (push) Successful in 22s
2026-03-24 14:23:24 -04:00
6766ea7dbb [scrobbles] Fix management command tag lookup
All checks were successful
build & deploy / test (push) Successful in 1m45s
build & deploy / deploy (push) Successful in 21s
2026-03-24 10:58:24 -04:00
125222845b [scrobbles] Add backfill tags command
All checks were successful
build & deploy / test (push) Successful in 1m45s
build & deploy / deploy (push) Successful in 22s
2026-03-24 10:45:49 -04:00
41bb52c551 [scrobbles] Add tagging from labels
All checks were successful
build & deploy / test (push) Successful in 1m45s
build & deploy / deploy (push) Successful in 21s
2026-03-24 10:42:10 -04:00
24c7c33ac2 [scrobbles] Fix checking for signal completion
All checks were successful
build & deploy / test (push) Successful in 1m43s
build & deploy / deploy (push) Successful in 26s
2026-03-24 09:36:40 -04:00
1ea29fee1b [charts] Fix bug where chart updates firing on wrong media types
Some checks failed
build & deploy / test (push) Successful in 1m41s
build & deploy / deploy (push) Failing after 2m3s
2026-03-23 19:26:34 -04:00
21355bae41 [settings] Send details task info to celery-db
All checks were successful
build & deploy / test (push) Successful in 1m45s
build & deploy / deploy (push) Successful in 22s
2026-03-23 16:53:43 -04:00
7bb53809ad [settings] Use json serializer for celery
All checks were successful
build & deploy / test (push) Successful in 1m44s
build & deploy / deploy (push) Successful in 23s
2026-03-23 16:48:10 -04:00
d576467db8 [black] Reformat to use 88 line lengths 2026-03-23 16:17:15 -04:00
ab4b5470b7 [charts] Async chart updates on demand again
All checks were successful
build & deploy / test (push) Successful in 1m43s
build & deploy / deploy (push) Successful in 23s
2026-03-23 16:16:11 -04:00
18e7af5052 [agent] Remind agents to respect pyproject 2026-03-23 16:13:59 -04:00
d4aefa6678 [charts] Upsert records instead of deleting
All checks were successful
build & deploy / test (push) Successful in 1m47s
build & deploy / deploy (push) Successful in 21s
2026-03-23 16:09:28 -04:00
0917025cac [charts] Add periodic rebuilding of charts 2026-03-23 15:54:14 -04:00
b61339b25c [just] Add celery and beat commands 2026-03-23 12:44:32 -04:00
e36a0726c8 Revert "[charts] Async creating of new charts"
This reverts commit 44c6e014f5.
2026-03-23 12:35:52 -04:00
44c6e014f5 [charts] Async creating of new charts 2026-03-23 12:11:05 -04:00
881450eb8d [charts] Clean up, add tests and signals
All checks were successful
build & deploy / test (push) Successful in 1m43s
build & deploy / deploy (push) Successful in 21s
2026-03-23 11:58:05 -04:00
d9a7a1cfd9 [templates] Fix navigation and add AGENTS file
All checks were successful
build & deploy / test (push) Successful in 1m48s
build & deploy / deploy (push) Successful in 21s
2026-03-23 10:40:23 -04:00
573321420d [templates] Add chart link to homepage
All checks were successful
build & deploy / test (push) Successful in 1m48s
build & deploy / deploy (push) Successful in 22s
2026-03-23 09:16:06 -04:00
a874e9c712 [charts] Fix lookup of latest chart
All checks were successful
build & deploy / test (push) Successful in 1m41s
build & deploy / deploy (push) Successful in 30s
2026-03-23 09:10:06 -04:00
8ca73693d1 [videos] TMDB should not truncate tt from imdb_id
All checks were successful
build & deploy / test (push) Successful in 1m57s
build & deploy / deploy (push) Successful in 23s
2026-03-22 21:14:27 -04:00
9f3b7b0361 [videogames] Fix bug in scraper 2026-03-22 21:13:45 -04:00
1a2c39b4d3 [videogames] Use reqeusts exceptions
All checks were successful
build & deploy / test (push) Successful in 1m52s
build & deploy / deploy (push) Successful in 24s
2026-03-22 15:47:04 -04:00
44eb193b33 [videogames] Dont die if arcadeitalia is down
All checks were successful
build & deploy / test (push) Successful in 1m51s
build & deploy / deploy (push) Successful in 26s
2026-03-22 14:08:12 -04:00
466d8d26c6 [project] Updating todos
All checks were successful
build & deploy / test (push) Successful in 1m55s
build & deploy / deploy (push) Successful in 35s
2026-03-22 13:03:50 -04:00
61c583e497 [videos] Make imdb_id and youtube_id unique together 2026-03-22 13:03:13 -04:00
83d89001a7 [charts] Fix chart building 2026-03-22 12:59:54 -04:00
02d13b5a99 [videos] Big guns, SQL delete
All checks were successful
build & deploy / test (push) Successful in 1m53s
build & deploy / deploy (push) Successful in 22s
2026-03-21 19:08:37 -04:00
bf17a0eeab [videos] More dedup scripts
All checks were successful
build & deploy / test (push) Successful in 1m47s
build & deploy / deploy (push) Successful in 22s
2026-03-21 18:48:04 -04:00
d29708bd59 [video] Add dedup scripts 2026-03-21 18:44:58 -04:00
d5d27256f8 [charts] Big revamp of the charts app
All checks were successful
build & deploy / test (push) Successful in 1m53s
build & deploy / deploy (push) Successful in 24s
2026-03-21 18:13:53 -04:00
565adfe58e [people] Add script to consolidate people
All checks were successful
build & deploy / test (push) Successful in 1m58s
build & deploy / deploy (push) Successful in 22s
2026-03-21 14:43:25 -04:00
16cd990c34 [scrobbles] Fix edit log ordering of people 2026-03-21 14:38:47 -04:00
190f486c49 [videos] Fix saving imdb_id duplicates
All checks were successful
build & deploy / test (push) Successful in 2m1s
build & deploy / deploy (push) Successful in 22s
2026-03-21 14:35:39 -04:00
327 changed files with 22058 additions and 3541 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: |
@ -70,14 +66,54 @@ jobs:
-d "❌ Build failed: ${{ gitea.repository }} @ ${{ gitea.sha }}" \
https://ntfy.unbl.ink/drone
deploy:
# Only deploy on tags (equivalent to Drone when: ref: refs/tags/*)
# if: startsWith(gitea.ref, 'refs/tags/')
build-and-deploy:
needs: [test]
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install Poetry
run: |
python -m pip install --upgrade pip
pip install poetry
- name: Write commit hash to build file
run: |
mkdir -p build_meta
echo "${{ gitea.sha }}" | cut -c1-8 > build_meta/commit.txt
- name: Build package with commit info
run: |
echo "commit = '$(echo ${{ gitea.sha }} | cut -c1-8)'" > vrobbler/_commit.py
poetry build
git checkout vrobbler/_commit.py
- name: Clean old wheels from server
uses: appleboy/ssh-action@v1.0.3
with:
host: vrobbler.service
username: root
key: ${{ secrets.JAIL_KEY }}
script: |
rm -f /var/lib/vrobbler/dist/*.whl
- name: Copy wheel to server and deploy
uses: appleboy/scp-action@v1.0.0
with:
host: vrobbler.service
username: root
key: ${{ secrets.JAIL_KEY }}
source: "dist/*.whl"
target: "/var/lib/vrobbler"
- name: Install wheel and restart services
uses: appleboy/ssh-action@v1.0.3
with:
host: vrobbler.service
@ -85,11 +121,16 @@ jobs:
key: ${{ secrets.JAIL_KEY }}
command_timeout: 2m
script: |
set -e
mkdir -p /var/lib/vrobbler
echo "${{ gitea.sha }}" | cut -c1-8 > /var/lib/vrobbler/commit.txt
pip uninstall -y vrobbler
pip install git+https://code.lab.unbl.ink/secstate/vrobbler.git@main
pip install /var/lib/vrobbler/dist/*.whl
rm -f /var/lib/vrobbler/dist/*.whl
python3 -c "import vrobbler; print(f'vrobbler {vrobbler.__version__} installed OK')"
vrobbler migrate
vrobbler collectstatic --noinput
immortalctl restart celery && immortalctl restart vrobbler
immortalctl restart vrobbler-celery && immortalctl restart vrobbler-celerybeat && immortalctl restart vrobbler
- name: Notify deploy success (ntfy)
if: success()

16
AGENTS.md Normal file
View File

@ -0,0 +1,16 @@
This is a Django-based web application that has an API, but primarily functions
with traditional Django views with HTML templates to display data that mostly
constitutes "scrobbled" items. The app started as a way to track a user's
watched videos via a Jellyfin server, but has since grown to keep track of a
number of media types: music tracks, tasks, videos, web pages, food, life
events, sports events, podcasts, video games, board games, beers, brick (lego)
sets, puzzles, books and geolocations.
The project is written in Python and prefers to use "fat" models where logical
methods are contained in either instance methods on instatiated data models, or
classmethods on the Django model class itself. When logic grows too complex,
helper functions should be pulled out into utils.py files and the model instance
ro class method should call the utility function.
Be sure to check pyproject.toml for project defaults. Specifically for black and
isort expectations.

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 [14/33]
* Backlog [0/14] :vrobbler:project:personal:
** TODO [#C] Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment:
:PROPERTIES:
:ID: 37781d6a-f3b0-48b2-bf98-33c2c791cf85
: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,8 +480,498 @@ 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:
*** 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
* Version 41.0 [5/5]
** DONE [#B] For any scrobble detail page with notes display them better :templates:notes:scrobbles:
:PROPERTIES:
:ID: c0dcf9da-227f-4a22-bcd9-9d46053607d9
:END:
*** Description
Currently notes are displayed as little post-it notes. This is cute, but not terribly useful.
We should update note rendering to be a simple newest to oldest display in a
single column with the timestamp has a small header, and the content rendered as
markdown with a small bar or horizontal divider marking them from the next note.
** DONE [#A] Imports should send notifications :feature:notifications:imports:
:PROPERTIES:
:ID: 6f78f8d5-ecaa-4d8a-a666-ae4e27653191
:END:
*** Description
Currently importing board games sends out a ntfy message when a scrobble is
created.
We should do the same thing for other import types; namely: gpx, ebird, and scale.
** DONE [#A] Board game imports send duplicate ntfy message :bug:notifications:boardgames:
:PROPERTIES:
:ID: 8f067432-0399-4b79-9e93-727edcccedbd
:END:
*** Description
When a board game scrobble is created via a bgstats import, ntfy messages are
sent.
But right now they are duplicated (two are sent at the same time). Can we review
the code to see why this is happening and fix it?
** DONE [#A] Too many geolocation notifications go out :bug:notifications:geolocations:
:PROPERTIES:
:ID: 6357ad7a-fe4e-49dd-a063-55d87e459c17
:END:
*** Description
Currently ntfy gets overwhelemed when there's more than a hundred or so messages left in a queue on a client.
It would be nice if we could not spam ntfy, and this is especially true with Geolocations, where we really
don't need to alert folks unless they have a named Geolocation (has a title). Can we adjust the ntfy
sending for Geolocations to only send if the scrobbled location has a title?
** DONE [#C] Fix bug where Weigh-in imports do not set title :bug:tasks:scale:
:PROPERTIES:
:ID: 622e354a-8e66-4ecd-9e1c-a53f0a2ec362
:END:
*** Description
Currently when we import a scale CSV row and create data, the title is left
blank which makes it look funny in a list view. Let's save the weight as the
title of the Weigh-in task.
* Version 40.2 [1/1]
** DONE [#A] Try fixing deploy bugs again :tooling:releases:bug:
:PROPERTIES:
:ID: 15894943-be1d-200f-8400-a136770ad9d2
:END:
* Version 40.1 [2/2]
** DONE [#A] Releases are still broken :bug:releases:tooling:
:PROPERTIES:
:ID: bca37a18-afa2-4ddd-a11b-ef0555f38bc9
:END:
*** Description
Deploys are still broken, even with them being pulled apart and run separately.
We need to address the way the commit ends up stashed in the codebase.
** DONE [#C] Fix bug on chart pages where trail titles missing :bug:trails:charts:
:PROPERTIES:
:ID: 21075430-8a93-4e59-9a02-479315960ae6
:END:
*** Description
When trails are rendered on the chart views, there are no titles, which means we don't see
anything except the ranking number.
* Version 40.0 [2/2]
** DONE [#A] Fix error in org-mode task sync :emacs:orgmode:tasks:bug:
:PROPERTIES:
:ID: 03256d2a-48aa-4be7-aeb3-fa1cfddc86bf
:END:
*** Description
#+begin_src python
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/tasks/webhooks.py", line 236, in post
emacs_scrobble_update_task(
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/scrobbles/scrobblers.py", line 844, in emacs_scrobble_update_task
for note in emacs_notes:
TypeError: 'NoneType' object is not iterable
#+end_src
** DONE [#B] Adjust how similar artists are shown :feature:templates:artists:music:
:PROPERTIES:
:ID: 2a081620-a0a2-4851-a7cf-4043f9c2ee31
:END:
*** Description
Currently we show the top 10 similar artists on the Artist detail page linked to
the artist detail page on Vrobbler.
First off, this is very slow. We should look into speeding up the rendering of
the similar artists widget.
Second, the artist name in the similar artist list should be a link to the
Vrobbler artist detail page, but there should also be a [musicbrainz] link next
to it, that links out to the musicbrainz page whether we have the artist in the
Vrobbler database or not.
* Version 39.3 [2/2]
** DONE [#A] Issue found when doing a release :bug:tooling:release:cicd:
:PROPERTIES:
:ID: a8fc3ec9-74ec-4190-8ac2-62cd8a33e828
:END:
*** Description
#+begin_src sh
err: ERROR: Cannot install vrobbler 0.16.1 (from /var/lib/vrobbler/dist/vrobbler-0.16.1-py3-none-any.whl) and vrobbler 38.0 (from /var/lib/vrobbler/dist/vrobbler-38.0-py3-none-any.whl) because these package versions have conflicting dependencies.
out: The conflict is caused by:
out: The user requested vrobbler 0.16.1 (from /var/lib/vrobbler/dist/vrobbler-0.16.1-py3-none-any.whl)
out: The user requested vrobbler 38.0 (from /var/lib/vrobbler/dist/vrobbler-38.0-py3-none-any.whl)
out: To fix this you could try to:
out: 1. loosen the range of package versions you've specified
out: 2. remove package versions to allow pip attempt to solve the dependency conflict
err: ERROR: ResolutionImpossible: for help visit https://pip.pypa.io/en/latest/topics/dependency-resolution/#dealing-with-dependency-conflicts
2026/06/01 14:15:00 Process exited with status 1
failed to remove container: Error response from daemon: removal of container 3fe0eaf032c5518aca4ab71734b52bda7c54ed406136b82136ab7155bf5ff3c1 is already in progress
#+end_src
** DONE [#A] Fix deploy actions running twice :bug:tooling:cicd:
:PROPERTIES:
:ID: aa56f2a6-2b61-4ddf-9e27-9eadcddf8412
:END:
*** Description
Turns out we're now running the build-deploy action twice, once on branch push
and once on tag push.
We should do builds on each push and build and deploys only when a new tag is
detected.
* Version 39.2 [2/2]
** DONE [#B] Releases do not pin commit to the repo for display :bug:tooling:releases:
:PROPERTIES:
:ID: 2a9f2ff5-2642-47ab-ba1d-e41825411713
:END:
*** Description
Somewhere in implementing the justfile release flow, we lost the capture of the
latest commit in the relesae flow so the footer now always says: vXX.x (unknown)
It should have the first bit of the commit in the parens at the end.
** DONE [#B] Fix the way timestamps are stored for notes on tasks :bug:scrobbles:tasks:
:PROPERTIES:
:ID: 32973bb3-079b-8cdf-6495-82f8ae907299
:END:
*** Description
:PROPERTIES:
:ID: d833a3df-6eb2-36bb-1863-e438e5d36151
:END:
Turns out Todoist uses a human-readable timestamp format for comments. We should
adapt that for use from org-mode as well.
Format should be: "%Y-%m-%dT%H:%M:%S.%fZ"
* Version 39.1 [1/1]
** DONE [#A] Fix bug in tests for notes saving :bug:scrobbles:forms:
:PROPERTIES:
:ID: 68a011b2-bb6f-3ba8-2312-5947c41db9ac
:END:
* Version 39.0 [3/3]
** DONE [#B] Clean up org-mode tasks metadata :bug:tasks:metadata:
:PROPERTIES:
:ID: 0c762d09-fc69-4e75-be40-7eaaf04f178e
:END:
*** Description
Org-mode tasks have a `Description` subheader, which should populate the
"Description" of a task.
The title should come from the actual TODO content, with tags coming from the
tags in colons after the task. This behaviour should already work.
Next, any comments should go under a `Comments` subheader. Under these should be a list tag like:
"Note taken on [2026-05-31 Sun 20:03]"
Which declares it's a note with a timestamp like "YYYY-MM-DD d HH:MM".
When scrobbling a task from org-mode, the description should always be copied to
the scrobble's log["description"].
Any comments that exist on the task when the scrobble is first created, should
be ignored.
Any comments on a task that is updated (a re-POST'd while the task is in
progress, should add a comment in the log["notes"], a dictionary, keyed off the
timestamp the note string. If a note for that datetime already exists, the
content should be replaced with the new comment.
Additionally, when a task is re-POST'd while the task is in progress, the
description should also be compared, and if different, the newest description
should be used.
Note, the biggest change for this flow is making sure comments that already
exist are not added to any new tasks and that both comments and descriptions are
added or updated if the new values are different than what is already in the
currently in-progress task.
If the task is completed, don't touch it.
*** Comments
- Note taken on [2026-05-31 Sun 20:03]
** DONE [#A] Actually push branches up and add a just command to do it :release:justfile:tooling:
:PROPERTIES:
:ID: 50aa5daa-a802-6aa9-38a3-218b7a9d4b34
:END:
** DONE [#A] Try to fix deploy failing with bad release plan :release:tooling:pyproject:
:PROPERTIES:
:ID: 63dc633c-4382-e6a5-e663-b01871ce86ce
:END:
* Version 38.0 [38/38]
** DONE [#A] Fix release flow to be easier to trigger :pyproject:release:tooling:
:PROPERTIES:
:ID: e321f8a3-26ea-b316-8493-294b50c0b516
:END:
*** Description
We should have a command like `just relesae <major|minor>` that can cut new
releases on command by updating the PROJECT.org file with a release header for
all DONE tasks and creating the appropriate release commits and tags based on
either the new release version type.
It should also update the pyproject file.
** DONE [#A] Move imported retroarch lrtl files to processed/ directory on WebDAV :webdav:retroarch:importers:
:PROPERTIES:
:ID: 881b5af6-e96f-4e71-9e66-2d30824e5504
:END:
- File: ~vrobbler/apps/scrobbles/importers/webdav.py~ (line 439)
- Same pattern as the GPX importer: after importing a =.csv= file from
WebDAV, move it to =var/retroarch/processed/= with a timestamp appended.
** DONE [#A] Add listenbrainz support for similar tracks :feature:music:metadata:
:PROPERTIES:
:ID: a3abba14-7603-db51-58e1-1941a47fa35e
:END:
** DONE [#B] Consolidate albums in the same musicbrainz_releasegroup_id :music:albums:metadata:
:PROPERTIES:
:ID: 6b331bdb-1516-bb86-7cb3-8cb9e3de59ce
:END:
*** Description
When we look up albums, we should check if one already exists with the same
musicbrainz_releasegroup_id and prefer that one, rather than creating a new
album.
Also, we should create a data migration to clean up albums with matching
musicbrainz_releasegroup_id fields by consolidating tracks around the first
album found.
Whether the track was enriched by musicbrainz or not, the track should get
tagged with `musicbrainz_enriched` or `musicbrainz_notfound`
** DONE [#A] Clean up metadata on music tracks :music:tracks:metadata:musicbrainz:
:PROPERTIES:
:ID: 3a007bd7-b901-43cb-892e-102d39fffc85
:END:
*** Description
There's a over 3K tracks without a musicbrainz_id and almost 30K without a
base_run_time_seconds. We should have a clean up script that can run through the
ones missing musicbrainz_ids and see about getting metadata from musicbrainz and
for the 30K without base_run_time_seconds, check with their musicbrainz_ids to
see if we can look up the track length, and if not, tag the track with
"missing-metadata" tag.
Also, if the tracks without musicbrainz_ids actually are not in the MB database,
we should tag those with "not-in-musicbrainz"
And a big part of this work will probably involve checking for "Various Artists"
tracks where the track erroneously got set with a generic artist. In those
cases, we should try just looking up by track title.
** DONE [#B] Make artists_m2m field source of artist truth for albums :music:bug:albums:
:PROPERTIES:
:ID: 05a24455-0e71-45ef-ac6e-c1b7b843047d
:END:
*** Description
Albums have an FK for album_artist, but like artists, the M2M should be the
source of truth. We should migrate all uses of album_artist to an `artist`
property on the Album model and use a data migration to populate artists with
the album_artist value.
** DONE [#A] Fix various artist album problem with Superwolves (track with multiple artists) :vrobbler:project:music:bug:artists:
:PROPERTIES:
:ID: 590bc038-745f-710b-8272-4d8a3d2efa01
:END:
*** Description
We have an issue with tracks where there are two artists, like `Matt Sweeney &
Bonnie "Prince" Billy`
I think we need to allow creating a single artist with both names, so the Artist
is the full name. That said, the ampersand is usually used to split feature
artsits (I think) so I'm not sure how this would work reliably.
Also, it's possible musicbrainz does not have dual artist listings. So it's
possible the longer term solution is to allow multiple artists per track the way
we now allow tracks to be on multiple albums. We could deprecate the Artist FK
or at least make it optional, and then require a M2M between Track and Artist.
Then this one would be a Track by both `Matt Sweeney` and `Bonnie "Prince"
Billy`
** DONE [#A] Move imported eBird CSV files to processed/ directory on WebDAV :webdav:ebird:importers:
:PROPERTIES:
:ID: 445e1253-d353-4b55-b1d8-39d0a0dcdd34
:END:
- File: ~vrobbler/apps/scrobbles/importers/webdav.py~ (line 439)
- Same pattern as the GPX importer: after importing a =.csv= file from
WebDAV, move it to =var/ebird/processed/= with a timestamp appended.
** DONE [#A] Move imported Board Game CSV files to processed/ directory on WebDAV :webdav:boardgames:importers:
:PROPERTIES:
:ID: a3f8d30c-d2c3-4ee7-b062-f0f16bd9b0b4
:END:
- File: ~vrobbler/apps/scrobbles/importers/webdav.py~ (line 496)
- Same pattern as the GPX importer: after importing a =.csv= file from
WebDAV, move it to =var/bgstats/processed/= with a timestamp appended.
** DONE [#A] Move imported Scale CSV files to processed/ directory on WebDAV :webdav:scale:importers:
:PROPERTIES:
:ID: 1a0de363-d1ea-466e-9966-e24941a6180b
:END:
- File: ~vrobbler/apps/scrobbles/importers/webdav.py~ (line 496)
- Same pattern as the GPX importer: after importing a =.csv= file from
WebDAV, move it to =var/scale/processed/= with a timestamp appended.
** DONE [#A] Allow special parameter to re-import already processed GPX files :imports:gpx:
:PROPERTIES:
:ID: 166c0809-c11a-4d02-8071-7a69dcb36e64
:END:
*** Description
Now that we stash imported GPX files in the processed/ subdirectory on import,
it would be nice to have a flag like --include-processed on the webdav importer
that would import files both in the root of gpx/ and also in the processed
directory. This would aide testing imports in staging quickly without constantly
moving files back and forth.
** DONE [#A] Move imported GPX files to processed/ directory on WebDAV :webdav:gpx:importers:
:PROPERTIES:
:ID: db2b02fc-817c-4c39-bd51-13f9b77c7888
:END:
- File: ~vrobbler/apps/scrobbles/importers/webdav.py~ (line 198)
- After importing a GPX/FIT file from WebDAV, move it to a =processed/=
subdirectory with a timestamp appended. This eliminates the DB lookup for
already-imported filenames — any file present in the top-level directory
is new. Also makes manual re-imports easy (just move a file back).
** DONE [#A] Add CSS Grid calendar view for scrobbles :vrobbler:personal:project:templates:feature:
:PROPERTIES:
:ID: be915acf-d803-466a-8770-823819ebf2a9
:END:
*** Description
Calendar view at /scrobbles/calendar/ showing select media types (Tasks,
Birding, Food, Trails, VideoGames, Books) in a CSS Grid month layout.
- Emoji badges per scrobble on each day cell, hover reveals title
- Click emoji to navigate to scrobble detail
- Prev/Next month navigation
- Based on the CodePen CSS Grid calendar pattern:
https://codepen.io/oliviale/pen/QYqybo
** DONE [#C] Come up with a possible flow using WebDAV and super-productivity for tasks :personal:feature:project:vrobbler:tasks:
:PROPERTIES:
:ID: dd751edf-6192-2ed0-cd85-905f423f82b0
:END:
** DONE [#B] Fix PuzzleLogData has no attribute form :vrobbler:puzzles:personal:project:logdata:
:PROPERTIES:
:ID: cb8e697a-9fc2-c463-5d4d-fa15d3e833a0
:END:
** DONE [#B] Add PuzzleLogData class with with_people and completed :vrobbler:feature:puzzles:logdata:personal:project:
:PROPERTIES:
:ID: 1b38c8a8-86d3-b73d-c584-dfb0497970dc
:END:
** DONE Add weather lookup to the mood check-in flow :vrobbler:project:moods:feature:checkin:
:PROPERTIES:
:ID: 175678e2-bfe1-0833-f2a9-f03b9323db78
:END:
<2026-05-20 Wed>
** DONE Add importing of openScale CSV files to Tasks :vrobbler:project:personal:tasks:openscale:
:PROPERTIES:
:ID: 344d1f5d-dda6-a1fb-da53-e00eebdd2262
:END:
** DONE Add ability to track Birding sessions via BirdingLocation scrobbles :vrobbler:project:birds:feature:
:PROPERTIES:
:ID: 8200ce29-a691-5cf6-c11a-c77e3d8b64c6
:END:
** DONE List only the last 20 scrobbles per category on the home page :vrobbler:project:scrobbles:templates:
:PROPERTIES:
:ID: b56402b9-5542-a10c-6c13-e12f56f2a2d1
:END:
** DONE Fix display of notes so they look like stickies :vrobbler:project:personal:notes:scrobbles:
:PROPERTIES:
:ID: 0ace8814-e96e-fa54-28d1-c57dcb508f1e
:END:
** DONE Add searching to scrobbles :vrobbler:project:personal:search:scrobbles:
:PROPERTIES:
:ID: b4690c76-d741-4320-20e8-c003251fe035
:END:
** DONE Fix uniqueness of imdb_id messing up youtube videos :vrobbler:project:bug:videos:
:PROPERTIES:
:ID: 610b8c4c-abf1-8630-a4fd-08d9939da9f5
:END:
Turns out you can't make imdb_id unique by itself or you never get to create
youtube videos. Rather, we should make imdb_id and youtube_id unique together so
imdb_id can be empty for every youtube_id and vice versa.
** DONE Fix genearting chart records :vrobbler:bug:personal:project:chartrecords:
:PROPERTIES:
:ID: e749d762-44ac-dd7e-017e-2423016737ff
:END:
** DONE [#A] Save raw scrobble request data to every scrobble log :vrobbler:personal:feature:scrobbles:
:PROPERTIES:
:ID: b4686db4-1319-399c-9b84-f0b1036c6815
@ -605,6 +1146,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.
```

5
data/birding-example.csv Normal file
View File

@ -0,0 +1,5 @@
Species,Count,Location,Observation Type,Observation Date,Start Time,Duration,Distance,Area,Party Size,Complete Checklist,# of species,Details
Canada Goose,6,"120 Perkins Street, Castine, Maine, US (44.384, -68.805)",Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,
Common Loon,1,"120 Perkins Street, Castine, Maine, US (44.384, -68.805)",Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,
Double-crested Cormorant,1,"120 Perkins Street, Castine, Maine, US (44.384, -68.805)",Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,
Boat-tailed Grackle,2,"120 Perkins Street, Castine, Maine, US (44.384, -68.805)",Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,Sitting together on roof line of a house on Water Street. 20 meters away. Both birds were mostly black with green accents on the breast with long tails which they were repeatedly fanning out to show the V shape.
1 Species Count Location Observation Type Observation Date Start Time Duration Distance Area Party Size Complete Checklist # of species Details
2 Canada Goose 6 120 Perkins Street, Castine, Maine, US (44.384, -68.805) Stationary May 10, 2026 4:15 PM 9 minute(s) 4 true 4 species
3 Common Loon 1 120 Perkins Street, Castine, Maine, US (44.384, -68.805) Stationary May 10, 2026 4:15 PM 9 minute(s) 4 true 4 species
4 Double-crested Cormorant 1 120 Perkins Street, Castine, Maine, US (44.384, -68.805) Stationary May 10, 2026 4:15 PM 9 minute(s) 4 true 4 species
5 Boat-tailed Grackle 2 120 Perkins Street, Castine, Maine, US (44.384, -68.805) Stationary May 10, 2026 4:15 PM 9 minute(s) 4 true 4 species Sitting together on roof line of a house on Water Street. 20 meters away. Both birds were mostly black with green accents on the breast with long tails which they were repeatedly fanning out to show the V shape.

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

@ -8,3 +8,15 @@ django:
shell:
poetry run python manage.py shell
celery:
poetry run celery -A vrobbler worker -l info --concurrency=2 --pool=threads
celery-beat:
poetry run celery -A vrobbler beat -l info
release kind="minor":
poetry run python scripts/release.py {{kind}}
push:
git push --tags && git push --tags gitea

1222
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,11 @@
[tool.poetry]
name = "vrobbler"
version = "0.16.1"
version = "41.0"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]
[tool.poetry.dependencies]
python = ">=3.11,<3.14"
python = ">=3.11,<3.15"
Django = "^4.0.3"
django-extensions = "^3.1.5"
python-dateutil = "^2.8.2"
@ -28,7 +28,6 @@ django-markdownify = "^0.9.1"
gunicorn = "^20.1.0"
django-simple-history = "^3.1.1"
musicbrainzngs = "^0.7.1"
cinemagoerng = {git = "https://github.com/cinemagoer/cinemagoerng"}
pysportsdb = "^0.1.0"
pytz = "^2022.7.1"
django-redis = "^5.2.0"
@ -60,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
@ -88,7 +90,7 @@ testpaths = ["tests"]
DJANGO_SETTINGS_MODULE='vrobbler.settings-testing'
[tool.black]
line-length = 79
line-length = 88
target-version = ["py39", "py310"]
include = ".py$"
exclude = "migrations"

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

View File

@ -0,0 +1,70 @@
import tempfile
import pytest
from birds.models import (
Bird,
BirdSightingEntry,
BirdSightingLogData,
BirdingLocation,
)
from django.contrib.auth import get_user_model
from scrobbles.models import Scrobble
User = get_user_model()
@pytest.fixture
def user(db):
return User.objects.create(email="birder@example.com")
@pytest.fixture
def bird(db):
return Bird.objects.create(
common_name="Northern Cardinal",
scientific_name="Cardinalis cardinalis",
)
@pytest.fixture
def birding_location(db):
return BirdingLocation.objects.create(title="Test Park")
@pytest.fixture
def birding_csv_content():
return """Species,Count,Location,Observation Type,Observation Date,Start Time,Duration,Distance,Area,Party Size,Complete Checklist,# of species,Details
Canada Goose,6,Test Park,Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,
Northern Cardinal,2,Test Park,Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,At the feeder
"""
@pytest.fixture
def birding_csv_file(birding_csv_content):
with tempfile.NamedTemporaryFile(
mode="w", suffix=".csv", delete=False, encoding="utf-8"
) as f:
f.write(birding_csv_content)
return f.name
@pytest.fixture
def scrobble_with_sightings(user, birding_location, bird):
return Scrobble.objects.create(
user=user,
birding_location=birding_location,
media_type=Scrobble.MediaType.BIRDING_LOCATION,
timestamp="2026-05-10 16:15:00+00:00",
played_to_completion=True,
log={
"birds": [
BirdSightingEntry(
bird_id=bird.id, quantity=2, sighting_notes="At the feeder"
).asdict
],
"duration_minutes": 9,
"observation_type": "Stationary",
"party_size": 4,
"complete_checklist": True,
},
)

View File

@ -0,0 +1,96 @@
from birds.models import BirdSightingEntry, BirdSightingLogData
class TestBirdSightingEntry:
def test_create_entry(self, db, bird):
entry = BirdSightingEntry(bird_id=bird.id, quantity=3)
assert entry.bird_id == bird.id
assert entry.quantity == 3
assert entry.sighting_notes is None
def test_entry_default_quantity(self, db, bird):
entry = BirdSightingEntry(bird_id=bird.id)
assert entry.quantity == 1
def test_entry_str(self, db, bird):
entry = BirdSightingEntry(
bird_id=bird.id, quantity=2, sighting_notes="in the tree"
)
expected = f"{bird.common_name} x2 (in the tree)"
assert str(entry) == expected
def test_entry_str_no_notes(self, db, bird):
entry = BirdSightingEntry(bird_id=bird.id, quantity=1)
expected = f"{bird.common_name} x1"
assert str(entry) == expected
def test_entry_bird_property(self, db, bird):
entry = BirdSightingEntry(bird_id=bird.id)
assert entry.bird == bird
def test_entry_bird_property_none(self, db):
entry = BirdSightingEntry(bird_id=None)
assert entry.bird is None
def test_entry_asdict(self, db, bird):
entry = BirdSightingEntry(
bird_id=bird.id, quantity=4, sighting_notes="flying south"
)
d = entry.asdict
assert d["bird_id"] == bird.id
assert d["quantity"] == 4
assert d["sighting_notes"] == "flying south"
class TestBirdSightingLogData:
def test_empty_logdata(self):
logdata = BirdSightingLogData()
assert logdata.birds is None
assert logdata.duration_minutes is None
assert logdata.observation_type is None
assert logdata.party_size is None
assert logdata.complete_checklist is None
def test_with_birds(self, db, bird):
entry = BirdSightingEntry(bird_id=bird.id, quantity=2).asdict
logdata = BirdSightingLogData(
birds=[entry],
duration_minutes=15,
observation_type="Traveling",
party_size=3,
complete_checklist=True,
)
assert len(logdata.birds) == 1
assert logdata.duration_minutes == 15
assert logdata.observation_type == "Traveling"
assert logdata.party_size == 3
assert logdata.complete_checklist is True
def test_bird_list_property(self, db, bird):
entry = BirdSightingEntry(bird_id=bird.id, quantity=2).asdict
logdata = BirdSightingLogData(birds=[entry])
assert bird.common_name in logdata.bird_list
def test_bird_list_empty(self):
logdata = BirdSightingLogData()
assert logdata.bird_list == ""
def test_as_html_with_all_fields(self, db, bird):
entry = BirdSightingEntry(bird_id=bird.id, quantity=2).asdict
logdata = BirdSightingLogData(
birds=[entry],
observation_type="Stationary",
distance="2 km",
area="Woodland",
party_size=4,
complete_checklist=True,
weather="Sunny",
)
html = logdata.as_html()
assert "Stationary" in html
assert "2 km" in html
assert "Woodland" in html
assert "Party size: 4" in html
assert "Complete checklist: True" in html
assert "Sunny" in html
assert bird.common_name in html

View File

@ -0,0 +1,139 @@
from datetime import timedelta
import pytest
from birds.importer import (
import_birding_csv,
parse_bool,
parse_coords,
parse_duration,
parse_int,
parse_timestamp,
)
from birds.models import Bird, BirdingLocation, BirdingCSVImport
from scrobbles.models import Scrobble
class TestParserHelpers:
def test_parse_duration(self):
assert parse_duration("9 minute(s)") == 9
assert parse_duration("120 minute(s)") == 120
assert parse_duration("") is None
assert parse_duration(None) is None
assert parse_duration("not a duration") is None
def test_parse_coords(self):
loc = "Some Place, US (44.384, -68.805)"
lat, lon = parse_coords(loc)
assert lat == 44.384
assert lon == -68.805
def test_parse_coords_no_match(self):
loc = "Some Place, US"
lat, lon = parse_coords(loc)
assert lat is None
assert lon is None
def test_parse_timestamp(self):
dt = parse_timestamp("May 10, 2026", "4:15 PM")
assert dt is not None
assert dt.year == 2026
assert dt.month == 5
assert dt.day == 10
assert dt.hour == 16
assert dt.minute == 15
def test_parse_timestamp_no_time(self):
dt = parse_timestamp("May 10, 2026", "")
assert dt is not None
assert dt.year == 2026
def test_parse_timestamp_invalid(self):
assert parse_timestamp("not a date", "") is None
def test_parse_bool(self):
assert parse_bool("true") is True
assert parse_bool("True") is True
assert parse_bool("yes") is True
assert parse_bool("1") is True
assert parse_bool("false") is False
assert parse_bool("") is None
assert parse_bool(None) is None
def test_parse_int(self):
assert parse_int("42") == 42
assert parse_int("") is None
assert parse_int(None) is None
assert parse_int("not a number") is None
class TestImportBirdingCSV:
def test_import_creates_birds(self, user, birding_csv_file):
import_birding_csv(birding_csv_file, user.id)
assert Bird.objects.filter(common_name="Canada Goose").exists()
assert Bird.objects.filter(common_name="Northern Cardinal").exists()
def test_import_creates_location(self, user, birding_csv_file):
import_birding_csv(birding_csv_file, user.id)
assert BirdingLocation.objects.filter(title="Test Park").exists()
def test_import_creates_scrobble(self, user, birding_csv_file):
import_birding_csv(birding_csv_file, user.id)
assert Scrobble.objects.filter(
source="Birding CSV Import"
).count() == 1
def test_import_logdata_fields(self, user, birding_csv_file):
import_birding_csv(birding_csv_file, user.id)
scrobble = Scrobble.objects.filter(source="Birding CSV Import").first()
log = scrobble.log
assert log["duration_minutes"] == 9
assert log["observation_type"] == "Stationary"
assert log["party_size"] == 4
assert log["complete_checklist"] is True
assert len(log["birds"]) == 2
def test_import_sighting_details(self, user, birding_csv_file):
import_birding_csv(birding_csv_file, user.id)
scrobble = Scrobble.objects.filter(source="Birding CSV Import").first()
birds = scrobble.log["birds"]
cardinal = next(b for b in birds if b["quantity"] == 2)
assert cardinal["sighting_notes"] == "At the feeder"
def test_import_idempotent(self, user, birding_csv_file):
import_birding_csv(birding_csv_file, user.id)
import_birding_csv(birding_csv_file, user.id)
assert Scrobble.objects.filter(
source="Birding CSV Import"
).count() == 1
def test_import_bird_quantities(self, user, birding_csv_file):
import_birding_csv(birding_csv_file, user.id)
scrobble = Scrobble.objects.filter(source="Birding CSV Import").first()
birds = scrobble.log["birds"]
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):
imp = BirdingCSVImport.objects.create(user=user)
assert imp.uuid is not None
assert imp.import_type == "Birding CSV"
assert "Birding" in str(imp)
@pytest.mark.django_db(transaction=True)
def test_process_via_model(self, user, birding_csv_file):
imp = BirdingCSVImport.objects.create(user=user)
with open(birding_csv_file, "rb") as f:
imp.csv_file.save("test.csv", f, save=True)
imp.process()
imp.refresh_from_db()
assert imp.process_count == 1
assert imp.processed_finished is not None

View File

@ -0,0 +1,30 @@
import pytest
from birds.models import Bird
class TestBirdModel:
def test_create_bird(self, db):
bird = Bird.objects.create(common_name="Blue Jay")
assert bird.common_name == "Blue Jay"
assert bird.uuid is not None
assert str(bird) == "Blue Jay"
def test_find_or_create_new(self, db):
bird = Bird.find_or_create("American Robin")
assert bird.common_name == "American Robin"
def test_find_or_create_existing(self, db, bird):
result = Bird.find_or_create("Northern Cardinal")
assert result.id == bird.id
assert result.common_name == "Northern Cardinal"
def test_find_or_create_case_insensitive(self, db, bird):
result = Bird.find_or_create("northern cardinal")
assert result.id == bird.id
def test_bird_str(self, db):
bird = Bird.objects.create(common_name="Mourning Dove")
assert str(bird) == "Mourning Dove"
def test_bird_scientific_name(self, db, bird):
assert bird.scientific_name == "Cardinalis cardinalis"

View File

@ -0,0 +1,42 @@
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()
class TestBirdingLocationViews:
def test_birding_location_list_anonymous(self, db):
client = Client()
response = client.get(reverse("birds:birding_location_list"))
assert response.status_code == 200
def test_bird_list_anonymous(self, db):
client = Client()
response = client.get(reverse("birds:bird_list"))
assert response.status_code == 200
class TestBirdingCSVImportViews:
def test_upload_view_requires_login(self, db):
client = Client()
response = client.get(reverse("birds:csv-upload"))
assert response.status_code == 302
def test_import_detail_view_requires_login(self, db):
client = Client()
response = client.get(
reverse("birds:csv_import_detail", kwargs={"slug": "00000000-0000-0000-0000-000000000001"})
)
assert response.status_code == 302
def test_import_detail_authenticated(self, db):
user = User.objects.create(email="birder@example.com")
client = Client()
client.force_login(user)
imp = EBirdCSVImport.objects.create(user=user)
response = client.get(
reverse("scrobbles:ebird-csv-import-detail", kwargs={"slug": imp.uuid})
)
assert response.status_code == 200

View File

@ -93,41 +93,29 @@ class TestRecipeScraperService:
assert result is True
def test_scrape_returns_title(self, scraper):
result = scraper.scrape(
RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe"
)
result = scraper.scrape(RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe")
assert result["title"] == "Test Recipe"
def test_scrape_returns_ingredients(self, scraper):
result = scraper.scrape(
RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe"
)
result = scraper.scrape(RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe")
assert len(result["ingredients"]) == 3
assert "1 cup flour" in result["ingredients"]
def test_scrape_returns_instructions(self, scraper):
result = scraper.scrape(
RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe"
)
result = scraper.scrape(RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe")
assert len(result["instructions"]) > 0
assert "Mix ingredients together" in result["instructions"]
def test_scrape_returns_yields(self, scraper):
result = scraper.scrape(
RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe"
)
result = scraper.scrape(RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe")
assert result["yields"] == "4 servings"
def test_scrape_returns_total_time(self, scraper):
result = scraper.scrape(
RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe"
)
result = scraper.scrape(RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe")
assert result["total_time"] == 30
def test_scrape_returns_url(self, scraper):
result = scraper.scrape(
RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe"
)
result = scraper.scrape(RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe")
assert result["url"] == "https://example.com/recipe"
def test_scrape_raises_on_invalid_html(self, scraper):

View File

@ -9,9 +9,7 @@ from foods.sources.usda import (
class TestUSDAFoodAPI:
@pytest.fixture
def usda_api(self):
with patch(
"vrobbler.apps.foods.sources.usda.settings"
) as mock_settings:
with patch("vrobbler.apps.foods.sources.usda.settings") as mock_settings:
mock_settings.USDA_API_KEY = "test_api_key"
return USDAFoodAPI(api_key="test_api_key")

View File

@ -1,9 +1,7 @@
import pytest
from vrobbler.apps.podcasts.scrapers import scrape_data_from_google_podcasts
expected_desc_snippet = (
"NPR's Up First is the news you need to start your day. "
)
expected_desc_snippet = "NPR's Up First is the news you need to start your day. "
expected_img_url = "https://encrypted-tbn2.gstatic.com/images?q=tbn:ANd9GcR1F0CfR24RR6sme531yIkCrnK4zzmo97jeualO5drVPKG6oCk"
expected_google_url = "https://podcasts.google.com/feed/aHR0cHM6Ly9mZWVkcy5ucHIub3JnLzUxMDMxOC9wb2RjYXN0LnhtbA"

View File

@ -68,9 +68,7 @@ class MopidyRequest:
"artist": kwargs.get("artist", self.artist),
"album": kwargs.get("album", self.album),
"track_number": int(kwargs.get("track_number", self.track_number)),
"run_time_ticks": int(
kwargs.get("run_time_ticks", self.run_time_ticks)
),
"run_time_ticks": int(kwargs.get("run_time_ticks", self.run_time_ticks)),
"run_time": int(kwargs.get("run_time", self.run_time)),
"playback_time_ticks": int(
kwargs.get("playback_time_ticks", self.playback_time_ticks)
@ -113,9 +111,7 @@ def mopidy_track():
@pytest.fixture
def mopidy_track_diff_album_request_data(**kwargs):
mb_album_id = "0c56c457-afe1-4679-baab-759ba8dd2a58"
return MopidyRequest(
album="Gold", musicbrainz_album_id=mb_album_id
).request_json
return MopidyRequest(album="Gold", musicbrainz_album_id=mb_album_id).request_json
@pytest.fixture

View File

@ -7,23 +7,37 @@ from django.contrib.auth import get_user_model
from django.urls import reverse
from django.utils import timezone
from music.aggregators import live_charts, scrobble_counts, week_of_scrobbles
from music.models import Album, Artist
from profiles.models import UserProfile
from scrobbles.models import Scrobble
def build_scrobbles(client, request_json, num=7, spacing=2):
def build_scrobbles(client, request_json, num=7, spacing=2, auth_token=None):
from rest_framework.authtoken.models import Token
import pytz
url = reverse("scrobbles:mopidy-webhook")
user = get_user_model().objects.create(username="Test User")
user.profile.timezone = "US/Eastern"
user.profile.save()
headers = {}
if auth_token:
headers = {"Authorization": f"Token {auth_token}"}
user = Token.objects.get(key=auth_token).user
client.post(url, request_json, content_type="application/json", headers=headers)
track = Scrobble.objects.last().track
est = pytz.timezone("US/Eastern")
for i in range(num):
client.post(url, request_json, content_type="application/json")
s = Scrobble.objects.last()
s.user = user
s.timestamp = timezone.now() - timedelta(days=i * spacing)
s.played_to_completion = True
s.save()
naive_time = timezone.now().replace(tzinfo=None) - timedelta(days=i * spacing)
aware_time = est.localize(naive_time)
Scrobble.objects.create(
user=user,
track=track,
timestamp=aware_time,
played_to_completion=True,
source="Mopidy",
)
return user
@pytest.mark.django_db
@ -41,9 +55,11 @@ def test_scrobble_counts_data(
mock_get_album,
client,
mopidy_track,
valid_auth_token,
):
build_scrobbles(client, mopidy_track.request_json)
user = get_user_model().objects.first()
user = build_scrobbles(
client, mopidy_track.request_json, auth_token=valid_auth_token
)
count_dict = scrobble_counts(user)
assert count_dict == {
"alltime": 7,
@ -69,9 +85,11 @@ def test_live_charts(
mock_get_album,
client,
mopidy_track,
valid_auth_token,
):
build_scrobbles(client, mopidy_track.request_json, 7, 1)
user = get_user_model().objects.first()
user = build_scrobbles(
client, mopidy_track.request_json, 7, 1, auth_token=valid_auth_token
)
week = week_of_scrobbles(user)
assert list(week.values()) == [1, 1, 1, 1, 1, 1, 1]

View File

@ -3,6 +3,24 @@ from unittest.mock import MagicMock, patch
from scrobbles.scrobblers import jellyfin_scrobble_media, mopidy_scrobble_media
def test_jellyfin_scrobble_video_with_no_imdb_id():
with patch("scrobbles.scrobblers.Video") as mock_video_class:
mock_video_class.find_or_create.return_value = None
post_data = {
"ItemType": "Video",
"Name": "Test Video",
"Provider_imdb": "",
"PlaybackPosition": "00:05:00",
"NotificationType": "PlaybackProgress",
"UtcTimestamp": "2024-01-15T10:30:00Z",
}
result = jellyfin_scrobble_media(post_data, 1)
mock_video_class.find_or_create.assert_called_once_with(None)
def test_jellyfin_scrobble_media_ignores_progress_with_zero_position():
post_data = {

View File

@ -6,7 +6,5 @@ from vrobbler.apps.scrobbles.utils import timestamp_user_tz_to_utc
def test_timestamp_user_tz_to_utc():
timestamp = timestamp_user_tz_to_utc(
1685561082, pytz.timezone("US/Eastern")
)
timestamp = timestamp_user_tz_to_utc(1685561082, pytz.timezone("US/Eastern"))
assert timestamp == datetime(2023, 5, 31, 23, 24, 42, tzinfo=pytz.utc)

View File

@ -1,12 +1,12 @@
from datetime import datetime, timedelta
from unittest.mock import patch, MagicMock
from django.utils import timezone
from django.contrib.auth import get_user_model
from unittest.mock import MagicMock, patch
import pytest
import time_machine
from django.contrib.auth import get_user_model
from django.urls import reverse
from music.models import Track, Artist, Album
from django.utils import timezone
from music.models import Album, Artist, Track
from podcasts.models import PodcastEpisode
from scrobbles.models import Scrobble
from tasks.models import Task
@ -32,7 +32,9 @@ def test_get_not_allowed_from_jellyfin(client, valid_auth_token):
def test_bad_mopidy_request_data(client, valid_auth_token):
url = reverse("scrobbles:mopidy-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
response = client.post(url, headers)
response = client.post(
url, "not valid json", content_type="application/json", headers=headers
)
assert response.status_code == 400
assert (
response.data["detail"]
@ -44,7 +46,9 @@ def test_bad_mopidy_request_data(client, valid_auth_token):
def test_bad_jellyfin_request_data(client, valid_auth_token):
url = reverse("scrobbles:jellyfin-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
response = client.post(url, headers)
response = client.post(
url, "not valid json", content_type="application/json", headers=headers
)
assert response.status_code == 400
assert (
response.data["detail"]
@ -139,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
@ -177,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
@ -221,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
@ -261,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
@ -299,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
@ -343,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
@ -498,9 +507,7 @@ def test_scrobble_detail_view_with_notes_as_flat_list(client):
user = get_user_model().objects.create_user(
username="testuser", email="test@example.com", password="testpass"
)
task = Task.objects.create(
title="Test Task", description="Test description"
)
task = Task.objects.create(title="Test Task", description="Test description")
scrobble = Scrobble.objects.create(
task=task,
media_type="Task",
@ -522,9 +529,7 @@ def test_scrobble_detail_view_with_notes_as_dict_timestamps(client):
user = get_user_model().objects.create_user(
username="testuser", email="test@example.com", password="testpass"
)
task = Task.objects.create(
title="Test Task", description="Test description"
)
task = Task.objects.create(title="Test Task", description="Test description")
scrobble = Scrobble.objects.create(
task=task,
media_type="Task",
@ -552,9 +557,7 @@ def test_scrobble_detail_view_with_notes_and_labels(client):
user = get_user_model().objects.create_user(
username="testuser", email="test@example.com", password="testpass"
)
task = Task.objects.create(
title="Test Task", description="Test description"
)
task = Task.objects.create(title="Test Task", description="Test description")
scrobble = Scrobble.objects.create(
task=task,
media_type="Task",
@ -580,9 +583,7 @@ def test_scrobble_detail_view_post_updates_log(client):
user = get_user_model().objects.create_user(
username="testuser", email="test@example.com", password="testpass"
)
task = Task.objects.create(
title="Test Task", description="Test description"
)
task = Task.objects.create(title="Test Task", description="Test description")
scrobble = Scrobble.objects.create(
task=task,
media_type="Task",
@ -606,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")
@ -708,3 +710,32 @@ def test_scrobble_jellyfin_track_create_new(
scrobble = Scrobble.objects.get(id=1)
assert scrobble.media_obj.__class__ == Track
assert scrobble.media_obj.title == "Emotion"
@pytest.mark.django_db
def test_get_not_allowed_from_gps(client, valid_auth_token):
url = reverse("scrobbles:gps-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
response = client.get(url, headers=headers)
assert response.status_code == 405
@pytest.mark.django_db
def test_gps_webhook_creates_location(client, valid_auth_token):
url = reverse("scrobbles:gps-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
gps_data = {
"lat": "40.7128",
"lon": "-74.0060",
"alt": "10.5",
"time": "2024-01-14T12:00:00Z",
"prov": "gps",
}
response = client.post(
url,
gps_data,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
assert "scrobble_id" in response.data

View File

@ -0,0 +1,127 @@
from unittest.mock import MagicMock, patch
import pytest
from django.contrib.auth import get_user_model
User = get_user_model()
@pytest.fixture
def user_profile(db):
user = User.objects.create_user(username="testuser", password="testpass")
return user.profile
@pytest.mark.django_db
class TestGenerateTodoistOauthUrl:
def test_generates_url_with_state(self, user_profile):
from tasks.todoist import generate_todoist_oauth_url
url = generate_todoist_oauth_url(user_profile.user_id)
user_profile.refresh_from_db()
assert user_profile.todoist_state is not None
assert len(user_profile.todoist_state) == 32
assert url.startswith("https://todoist.com/oauth/authorize")
assert user_profile.todoist_state in url
def test_updates_existing_state(self, user_profile):
from tasks.todoist import generate_todoist_oauth_url
old_state = "oldstate12345678901234567890123"
user_profile.todoist_state = old_state
user_profile.save()
url = generate_todoist_oauth_url(user_profile.user_id)
user_profile.refresh_from_db()
assert user_profile.todoist_state != old_state
@pytest.mark.django_db
class TestGetTodoistAccessToken:
def test_raises_when_profile_not_found(self):
from tasks.todoist import get_todoist_access_token
with pytest.raises(Exception, match="Could not find profile"):
get_todoist_access_token(user_id=999, state="anystate", code="anycode")
def test_raises_when_state_mismatch(self, user_profile):
from tasks.todoist import get_todoist_access_token
user_profile.todoist_state = "correctstate1234567890123"
user_profile.save()
with pytest.raises(Exception, match="state mismatch"):
get_todoist_access_token(
user_id=user_profile.user_id, state="wrongstate", code="anycode"
)
@patch("tasks.todoist.requests.post")
def test_exchanges_code_for_token(self, mock_post, user_profile):
from tasks.todoist import get_todoist_access_token
user_profile.todoist_state = "correctstate1234567890123"
user_profile.save()
mock_token_response = MagicMock()
mock_token_response.status_code = 200
mock_token_response.json.return_value = {"access_token": "test_access_token"}
mock_post.return_value = mock_token_response
get_todoist_access_token(
user_id=user_profile.user_id,
state="correctstate1234567890123",
code="testcode",
)
user_profile.refresh_from_db()
assert user_profile.todoist_auth_key == "test_access_token"
assert user_profile.todoist_state is None
@patch("tasks.todoist.requests.post")
def test_fetches_todoist_user_id(self, mock_post, user_profile):
from tasks.todoist import get_todoist_access_token
user_profile.todoist_state = "correctstate1234567890123"
user_profile.save()
mock_token_response = MagicMock()
mock_token_response.status_code = 200
mock_token_response.json.return_value = {"access_token": "test_access_token"}
mock_sync_response = MagicMock()
mock_sync_response.status_code = 200
mock_sync_response.json.return_value = {"user": {"id": "12345"}}
mock_post.side_effect = [mock_token_response, mock_sync_response]
get_todoist_access_token(
user_id=user_profile.user_id,
state="correctstate1234567890123",
code="testcode",
)
user_profile.refresh_from_db()
assert user_profile.todoist_user_id == "12345"
@patch("tasks.todoist.requests.post")
def test_handles_token_exchange_failure(self, mock_post, user_profile):
from tasks.todoist import get_todoist_access_token
user_profile.todoist_state = "correctstate1234567890123"
user_profile.save()
mock_response = MagicMock()
mock_response.status_code = 400
mock_post.return_value = mock_response
get_todoist_access_token(
user_id=user_profile.user_id,
state="correctstate1234567890123",
code="badcode",
)
user_profile.refresh_from_db()
assert user_profile.todoist_auth_key is None
assert user_profile.todoist_state == "correctstate1234567890123"

View File

@ -1,8 +1,9 @@
import os
from unittest.mock import patch, MagicMock
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from vrobbler.context_processors import version_info
@ -13,10 +14,9 @@ def mock_request():
class TestVersionInfo:
def test_returns_version_and_commit(self, mock_request):
"""Test that git commit is returned when _commit.py doesn't exist"""
with (
patch(
"vrobbler.context_processors.get_version"
) as mock_get_version,
patch("vrobbler.context_processors.get_version") as mock_get_version,
patch(
"vrobbler.context_processors.subprocess.check_output"
) as mock_check_output,
@ -24,7 +24,18 @@ class TestVersionInfo:
mock_get_version.return_value = "1.0.0"
mock_check_output.return_value = b"abc1234"
result = version_info(mock_request)
# Mock the import to raise ImportError so git is used
import builtins
original_import = builtins.__import__
def mock_import(name, *args, **kwargs):
if name == "vrobbler._commit":
raise ImportError("No module named 'vrobbler._commit'")
return original_import(name, *args, **kwargs)
with patch("builtins.__import__", side_effect=mock_import):
result = version_info(mock_request)
assert result["app_version"] == "1.0.0"
assert result["git_commit"] == "abc1234"
@ -32,9 +43,7 @@ class TestVersionInfo:
def test_uses_env_commit_if_set(self, mock_request):
with (
patch.dict(os.environ, {"VROBBLER_COMMIT": "env_commit_hash"}),
patch(
"vrobbler.context_processors.get_version"
) as mock_get_version,
patch("vrobbler.context_processors.get_version") as mock_get_version,
):
mock_get_version.return_value = "1.0.0"
@ -42,11 +51,72 @@ class TestVersionInfo:
assert result["git_commit"] == "env_commit_hash"
def test_uses_commit_from_module_when_available(self, mock_request):
"""Test that commit from _commit.py module is used when available"""
with (patch("vrobbler.context_processors.get_version") as mock_get_version,):
mock_get_version.return_value = "1.0.0"
result = version_info(mock_request)
# Should use whatever value is in vrobbler/_commit.py
# Could be "unknown" or an actual commit hash
assert "git_commit" in result
assert result["git_commit"] != ""
def test_uses_commit_from_file_when_module_unavailable(self, mock_request):
"""Test that commit from /var/lib/vrobbler/commit.txt is used"""
with (
patch("vrobbler.context_processors.get_version") as mock_get_version,
patch("pathlib.Path.exists", return_value=True),
patch("pathlib.Path.read_text", return_value="file_commit_hash"),
):
mock_get_version.return_value = "1.0.0"
# Mock the import to raise ImportError
import builtins
original_import = builtins.__import__
def mock_import(name, *args, **kwargs):
if name == "vrobbler._commit":
raise ImportError("No module named 'vrobbler._commit'")
return original_import(name, *args, **kwargs)
with patch("builtins.__import__", side_effect=mock_import):
result = version_info(mock_request)
assert result["git_commit"] == "file_commit_hash"
def test_falls_back_to_git_when_file_unavailable(self, mock_request):
"""Test fallback to git when _commit.py and file don't exist"""
with (
patch("vrobbler.context_processors.get_version") as mock_get_version,
patch("pathlib.Path.exists", return_value=False),
patch(
"vrobbler.context_processors.subprocess.check_output"
) as mock_check_output,
):
mock_get_version.return_value = "1.0.0"
mock_check_output.return_value = b"git_commit_hash"
# Mock the import to raise ImportError
import builtins
original_import = builtins.__import__
def mock_import(name, *args, **kwargs):
if name == "vrobbler._commit":
raise ImportError("No module named 'vrobbler._commit'")
return original_import(name, *args, **kwargs)
with patch("builtins.__import__", side_effect=mock_import):
result = version_info(mock_request)
assert result["git_commit"] == "git_commit_hash"
def test_returns_unknown_when_version_fails(self, mock_request):
with (
patch(
"vrobbler.context_processors.get_version"
) as mock_get_version,
patch("vrobbler.context_processors.get_version") as mock_get_version,
patch(
"vrobbler.context_processors.subprocess.check_output"
) as mock_check_output,
@ -62,9 +132,7 @@ class TestVersionInfo:
import subprocess
with (
patch(
"vrobbler.context_processors.get_version"
) as mock_get_version,
patch("vrobbler.context_processors.get_version") as mock_get_version,
patch(
"vrobbler.context_processors.subprocess.check_output"
) as mock_check_output,
@ -80,9 +148,7 @@ class TestVersionInfo:
import subprocess
with (
patch(
"vrobbler.context_processors.get_version"
) as mock_get_version,
patch("vrobbler.context_processors.get_version") as mock_get_version,
patch(
"vrobbler.context_processors.subprocess.check_output"
) as mock_check_output,

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

@ -48,12 +48,8 @@ class TestVideoAPI:
assert response.status_code == 200
assert response.data["title"] == "Test Video"
def test_filter_videos_by_channel(
self, client, auth_headers, channel, video
):
response = client.get(
f"/api/v1/videos/?channel={channel.id}", **auth_headers
)
def test_filter_videos_by_channel(self, client, auth_headers, channel, video):
response = client.get(f"/api/v1/videos/?channel={channel.id}", **auth_headers)
assert response.status_code == 200
assert len(response.data["results"]) == 1
assert response.data["results"][0]["channel"] == channel.id
@ -68,9 +64,7 @@ class TestChannelAPI:
assert response.data["results"][0]["name"] == "Test Channel"
def test_get_channel(self, client, auth_headers, channel):
response = client.get(
f"/api/v1/channels/{channel.id}/", **auth_headers
)
response = client.get(f"/api/v1/channels/{channel.id}/", **auth_headers)
assert response.status_code == 200
assert response.data["name"] == "Test Channel"

View File

@ -1,14 +0,0 @@
import pytest
from videos.sources.imdb import lookup_video_from_imdb
@pytest.mark.skip(reason="Should not hit IMDB api in CI")
def test_lookup_imdb_without_tt():
metadata = lookup_video_from_imdb("8946378")
assert not metadata.imdb_id
@pytest.mark.skip(reason="Should not hit IMDB api in CI")
def test_lookup_imdb_with_tt():
metadata = lookup_video_from_imdb("tt8946378")
assert metadata.title == "Knives Out"

View File

@ -0,0 +1,17 @@
import pytest
from videos.models import Video
@pytest.mark.django_db
class TestVideoFindOrCreate:
def test_find_or_create_with_none_returns_none(self):
result = Video.find_or_create(None)
assert result is None
def test_find_or_create_with_empty_string_returns_none(self):
result = Video.find_or_create("")
assert result is None
def test_find_or_create_with_invalid_id_returns_none(self):
result = Video.find_or_create("invalid-id")
assert result is None

1
vrobbler/_commit.py Normal file
View File

@ -0,0 +1 @@
commit = "unknown"

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.29 on 2026-03-26 21:25
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
("beers", "0006_remove_beer_run_time_seconds_and_more"),
]
operations = [
migrations.AddField(
model_name="beer",
name="tags",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.29 on 2026-05-01 15:49
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0075_add_channel_scrobble"),
("beers", "0007_beer_tags"),
]
operations = [
migrations.AlterField(
model_name="beer",
name="genre",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="scrobbles.ObjectWithGenres",
to="scrobbles.Genre",
verbose_name="Genre",
),
),
]

View File

@ -67,9 +67,7 @@ class Beer(ScrobblableMixin):
)
untappd_id = models.CharField(max_length=255, **BNULL)
untappd_rating = models.FloatField(**BNULL)
producer = models.ForeignKey(
BeerProducer, on_delete=models.DO_NOTHING, **BNULL
)
producer = models.ForeignKey(BeerProducer, on_delete=models.DO_NOTHING, **BNULL)
def get_absolute_url(self) -> str:
return reverse("beers:beer_detail", kwargs={"slug": self.uuid})
@ -130,9 +128,7 @@ class Beer(ScrobblableMixin):
)
style_ids.append(style_inst.id)
producer, _created = BeerProducer.objects.get_or_create(
**producer_dict
)
producer, _created = BeerProducer.objects.get_or_create(**producer_dict)
beer_dict["producer_id"] = producer.id
beer = Beer.objects.create(**beer_dict)
for style_id in style_ids:

View File

@ -85,9 +85,7 @@ def get_ibu_from_soup(soup) -> Optional[int]:
def get_rating_from_soup(soup) -> str:
rating = ""
try:
rating = float(
soup.find(class_="num").get_text().strip("(").strip(")")
)
rating = float(soup.find(class_="num").get_text().strip("(").strip(")"))
except AttributeError:
rating = None
except ValueError:
@ -124,9 +122,7 @@ def get_beer_from_untappd_id(untappd_id: str) -> dict:
beer_dict = {"untappd_id": untappd_id}
if response.status_code != 200:
logger.warn(
"Bad response from untappd.com", extra={"response": response}
)
logger.warn("Bad response from untappd.com", extra={"response": response})
return beer_dict
soup = BeautifulSoup(response.text, "html.parser")

View File

View File

@ -0,0 +1,30 @@
from birds.models import Bird, BirdingCSVImport, BirdingLocation
from django.contrib import admin
from scrobbles.admin import ScrobbleInline
@admin.register(Bird)
class BirdAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = ("uuid", "common_name", "scientific_name", "ebird_code")
ordering = ("-created",)
search_fields = ("common_name", "scientific_name")
@admin.register(BirdingLocation)
class BirdingLocationAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = ("uuid", "title")
ordering = ("-created",)
raw_id_fields = ("geo_location",)
search_fields = ("title",)
inlines = [
ScrobbleInline,
]
@admin.register(BirdingCSVImport)
class BirdingCSVImportAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = ("uuid", "process_count", "processed_finished", "processing_started")
ordering = ("-created",)

View File

View File

@ -0,0 +1,14 @@
from rest_framework import serializers
from birds.models import Bird, BirdingLocation
class BirdSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Bird
fields = "__all__"
class BirdingLocationSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = BirdingLocation
fields = "__all__"

View File

@ -0,0 +1,15 @@
from rest_framework import permissions, viewsets
from birds.api import serializers
from birds import models
class BirdViewSet(viewsets.ModelViewSet):
queryset = models.Bird.objects.all().order_by("-created")
serializer_class = serializers.BirdSerializer
permission_classes = [permissions.IsAuthenticated]
class BirdingLocationViewSet(viewsets.ModelViewSet):
queryset = models.BirdingLocation.objects.all().order_by("-created")
serializer_class = serializers.BirdingLocationSerializer
permission_classes = [permissions.IsAuthenticated]

View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class BirdsConfig(AppConfig):
name = "birds"

View File

@ -0,0 +1,85 @@
import json
from birds.models import Bird, BirdSightingEntry
from django import forms
class BirdSightingsWidget(forms.Widget):
template_name = "birds/bird_sightings_widget.html"
class Media:
js = ("birds/bird_sightings.js",)
def value_from_datadict(self, data, files, name):
bird_ids = data.getlist(f"{name}_bird_id")
quantities = data.getlist(f"{name}_quantity")
notes = data.getlist(f"{name}_sighting_notes")
return {
"bird_id": bird_ids,
"quantity": quantities,
"sighting_notes": notes,
}
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
sightings = []
if value:
if isinstance(value, str):
try:
value = json.loads(value)
except (json.JSONDecodeError, TypeError):
value = []
for item in (value or []):
if isinstance(item, dict):
sightings.append(item)
elif isinstance(item, BirdSightingEntry):
sightings.append(item.asdict)
context["widget"]["sightings"] = sightings
context["widget"]["birds"] = Bird.objects.all().order_by("common_name")
return context
class BirdSightingsField(forms.Field):
widget = BirdSightingsWidget
def clean(self, value):
if not value:
return None
result = []
bird_ids = value.get("bird_id", []) if isinstance(value, dict) else []
quantities = value.get("quantity", []) if isinstance(value, dict) else []
notes_list = (
value.get("sighting_notes", []) if isinstance(value, dict) else []
)
if isinstance(bird_ids, list):
for i, bird_id in enumerate(bird_ids):
if not bird_id:
continue
try:
bird_id = int(bird_id)
quantity = int(quantities[i]) if i < len(quantities) else 1
except (ValueError, TypeError, IndexError):
continue
note = notes_list[i] if i < len(notes_list) else ""
entry = BirdSightingEntry(
bird_id=bird_id,
quantity=quantity,
sighting_notes=note or None,
)
result.append(entry.asdict)
elif bird_ids:
try:
bird_id = int(bird_ids)
quantity = int(quantities) if quantities else 1
except (ValueError, TypeError):
raise forms.ValidationError("Invalid bird sighting data")
note = notes_list if notes_list else ""
entry = BirdSightingEntry(
bird_id=bird_id,
quantity=quantity,
sighting_notes=note or None,
)
result.append(entry.asdict)
return result if result else None

View File

@ -0,0 +1,189 @@
import csv
import logging
import re
from collections import defaultdict
from datetime import datetime, timedelta
from django.contrib.auth import get_user_model
from birds.models import Bird, BirdSightingEntry, BirdSightingLogData, BirdingLocation
from scrobbles.models import Scrobble
from scrobbles.notifications import ScrobbleNtfyNotification
logger = logging.getLogger(__name__)
User = get_user_model()
LOCATION_COORDS_RE = re.compile(r"\(([\d\.\-]+),\s*([\d\.\-]+)\)")
DURATION_RE = re.compile(r"(\d+)\s*minute")
def parse_duration(duration_str):
if not duration_str:
return None
match = DURATION_RE.search(duration_str)
if match:
return int(match.group(1))
return None
def parse_coords(location_str):
match = LOCATION_COORDS_RE.search(location_str)
if match:
return float(match.group(1)), float(match.group(2))
return None, None
def parse_timestamp(date_str, time_str):
try:
dt = datetime.strptime(f"{date_str} {time_str}", "%B %d, %Y %I:%M %p")
return dt
except (ValueError, TypeError):
try:
dt = datetime.strptime(date_str, "%B %d, %Y")
return dt
except (ValueError, TypeError):
logger.warning(f"Could not parse date/time: {date_str} {time_str}")
return None
def parse_bool(value):
if not value:
return None
return value.strip().lower() in ("true", "yes", "1")
def parse_int(value):
if not value:
return None
try:
return int(value.strip())
except (ValueError, TypeError):
return None
def import_birding_csv(file_path, user_id):
user = User.objects.get(id=user_id)
new_scrobbles = []
with open(file_path, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
rows = list(reader)
groups = defaultdict(list)
for row in rows:
key = (
row.get("Location", "").strip(),
row.get("Observation Date", "").strip(),
row.get("Start Time", "").strip(),
)
groups[key].append(row)
for (location_str, date_str, time_str), sighting_rows in groups.items():
if not location_str:
logger.warning("Skipping rows with no location")
continue
timestamp = parse_timestamp(date_str, time_str)
if not timestamp:
continue
timestamp = user.profile.get_timestamp_with_tz(timestamp)
location_title = (
LOCATION_COORDS_RE.sub("", location_str).strip().rstrip(",").strip()
)
if not location_title:
location_title = location_str
location = BirdingLocation.find_or_create(location_title)
lat, lon = parse_coords(location_str)
if lat and lon and not location.geo_location:
from locations.models import GeoLocation
geo, _ = GeoLocation.objects.get_or_create(
lat=round(lat, 6),
lon=round(lon, 6),
defaults={"altitude": None},
)
location.geo_location = geo
location.save(update_fields=["geo_location"])
first_row = sighting_rows[0]
birds_data = []
for row in sighting_rows:
species = row.get("Species", "").strip()
if not species:
continue
count = parse_int(row.get("Count")) or 1
details = row.get("Details", "").strip()
bird = Bird.find_or_create(species)
entry = BirdSightingEntry(
bird_id=bird.id, quantity=count, sighting_notes=details or None
)
birds_data.append(entry.asdict)
duration_minutes = parse_duration(first_row.get("Duration", ""))
logdata = BirdSightingLogData(
birds=birds_data,
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,
party_size=parse_int(first_row.get("Party Size")),
complete_checklist=parse_bool(first_row.get("Complete Checklist")),
)
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,
played_to_completion=True,
in_progress=False,
media_type=Scrobble.MediaType.BIRDING_LOCATION,
)
existing = Scrobble.objects.filter(
timestamp=timestamp,
birding_location=location,
user=user,
).first()
if existing:
logger.debug(f"Skipping existing scrobble for {location}")
continue
new_scrobbles.append(scrobble)
created = Scrobble.objects.bulk_create(new_scrobbles)
logger.info(f"Created {len(created)} birding scrobbles")
for scrobble in created:
ScrobbleNtfyNotification(scrobble).send()
return created

View File

@ -0,0 +1,144 @@
# Generated by Django 4.2.29 on 2026-05-15 15:05
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
import taggit.managers
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
("locations", "0010_clean_start"),
("scrobbles", "0075_add_channel_scrobble"),
]
operations = [
migrations.CreateModel(
name="Bird",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
(
"uuid",
models.UUIDField(
blank=True, default=uuid.uuid4, editable=False, null=True
),
),
("common_name", models.CharField(max_length=255)),
(
"scientific_name",
models.CharField(blank=True, max_length=255, null=True),
),
("description", models.TextField(blank=True, null=True)),
(
"ebird_code",
models.CharField(
blank=True, db_index=True, max_length=255, null=True
),
),
(
"photo",
models.ImageField(blank=True, null=True, upload_to="birds/photos/"),
),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
migrations.CreateModel(
name="BirdingLocation",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
(
"uuid",
models.UUIDField(
blank=True, default=uuid.uuid4, editable=False, null=True
),
),
("title", models.CharField(blank=True, max_length=255, null=True)),
("base_run_time_seconds", models.IntegerField(blank=True, null=True)),
("description", models.TextField(blank=True, null=True)),
(
"ebird_hotspot_id",
models.CharField(blank=True, max_length=255, null=True),
),
(
"genre",
taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="scrobbles.ObjectWithGenres",
to="scrobbles.Genre",
verbose_name="Genre",
),
),
(
"geo_location",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="locations.geolocation",
),
),
(
"tags",
taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
],
options={
"abstract": False,
},
),
]

View File

@ -0,0 +1,67 @@
# Generated by Django 4.2.29 on 2026-05-15 15:41
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
import uuid
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("birds", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="BirdingCSVImport",
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="birding-csv-uploads/"
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "Birding CSV Import",
},
),
]

View File

@ -0,0 +1,302 @@
import logging
from dataclasses import dataclass
from functools import cached_property
from typing import Optional
from uuid import uuid4
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.files import File
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django_extensions.db.models import TimeStampedModel
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from locations.models import GeoLocation
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
User = get_user_model()
logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
@dataclass
class BirdSightingEntry(BaseLogData):
bird_id: Optional[int] = None
quantity: int = 1
sighting_notes: Optional[str] = None
@property
def bird(self) -> Optional["Bird"]:
if not self.bird_id:
return None
return Bird.objects.filter(id=self.bird_id).first()
def __str__(self) -> str:
name = self.bird.common_name if self.bird else "Unknown"
out = f"{name} x{self.quantity}"
if self.sighting_notes:
out += f" ({self.sighting_notes})"
return out
@dataclass
class BirdSightingLogData(BaseLogData, WithPeopleLogData):
birds: Optional[list[BirdSightingEntry]] = None
duration_minutes: Optional[int] = None
observation_type: Optional[str] = None
distance: Optional[str] = None
area: Optional[str] = None
party_size: Optional[int] = None
complete_checklist: Optional[bool] = None
weather: Optional[str] = None
temperature: Optional[int] = None
guide: Optional[str] = None
_excluded_fields = {}
@cached_property
def bird_list(self) -> str:
if self.birds:
return ", ".join(
[BirdSightingEntry(**b).__str__() for b in self.birds]
)
return ""
def as_html(self) -> str:
html_parts = []
if self.observation_type:
html_parts.append(
f'<div class="birding-obs-type">Type: {self.observation_type}</div>'
)
if self.distance:
html_parts.append(
f'<div class="birding-distance">Distance: {self.distance}</div>'
)
if self.area:
html_parts.append(
f'<div class="birding-area">Area: {self.area}</div>'
)
if self.party_size:
html_parts.append(
f'<div class="birding-party">Party size: {self.party_size}</div>'
)
if self.complete_checklist is not None:
html_parts.append(
f'<div class="birding-checklist">Complete checklist: {self.complete_checklist}</div>'
)
if self.weather:
html_parts.append(
f'<div class="birding-weather">Weather: {self.weather}</div>'
)
if self.temperature:
html_parts.append(
f'<div class="birding-temp">Temp: {self.temperature}°</div>'
)
if self.guide:
html_parts.append(
f'<div class="birding-guide">Guide: {self.guide}</div>'
)
if self.duration_minutes:
html_parts.append(
f'<div class="birding-duration">Duration: {self.duration_minutes} min</div>'
)
if self.birds:
birds_html = []
for bird_data in self.birds:
sighting = BirdSightingEntry(**bird_data)
bird_info = sighting.bird.common_name if sighting.bird else "Unknown"
extra = f" x{sighting.quantity}"
if sighting.sighting_notes:
extra += f" \u2014 {sighting.sighting_notes}"
birds_html.append(
f'<div class="bird-sighting">{bird_info}{extra}</div>'
)
html_parts.append(
f'<div class="bird-sightings">{"".join(birds_html)}</div>'
)
return "".join(html_parts)
@classmethod
def override_fields(cls) -> dict:
from birds.forms import BirdSightingsField
fields = {}
for base in cls.mro()[1:]:
if hasattr(base, "override_fields"):
base_fields = base.override_fields()
fields.update(base_fields)
custom_fields = {
"birds": BirdSightingsField(required=False),
}
fields.update(custom_fields)
return fields
class Bird(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
common_name = models.CharField(max_length=255)
scientific_name = models.CharField(max_length=255, **BNULL)
description = models.TextField(**BNULL)
ebird_code = models.CharField(max_length=255, **BNULL, db_index=True)
photo = models.ImageField(upload_to="birds/photos/", **BNULL)
photo_small = ImageSpecField(
source="photo",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
photo_medium = ImageSpecField(
source="photo",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
)
def __str__(self):
return self.common_name
def get_absolute_url(self):
return reverse("birds:bird_detail", kwargs={"slug": self.uuid})
@classmethod
def find_or_create(cls, common_name: str) -> "Bird":
bird = cls.objects.filter(common_name__iexact=common_name).first()
if not bird:
bird = cls.objects.create(common_name=common_name)
return bird
class BirdingLocation(ScrobblableMixin):
description = models.TextField(**BNULL)
geo_location = models.ForeignKey(
GeoLocation, **BNULL, on_delete=models.DO_NOTHING
)
ebird_hotspot_id = models.CharField(max_length=255, **BNULL)
def get_absolute_url(self):
return reverse("birds:birding_location_detail", kwargs={"slug": self.uuid})
@property
def subtitle(self):
return ""
@property
def strings(self) -> ScrobblableConstants:
return ScrobblableConstants(verb="Birding at", tags="bird")
@property
def logdata_cls(self):
return BirdSightingLogData
def primary_image_url(self) -> str:
return ""
def fix_metadata(self) -> None:
pass
@classmethod
def find_or_create(cls, title: str) -> "BirdingLocation":
location = cls.objects.filter(title__iexact=title).first()
if not location:
location = cls.objects.create(title=title)
return location
class BirdingCSVImport(TimeStampedModel):
user = models.ForeignKey(User, on_delete=models.DO_NOTHING, **BNULL)
uuid = models.UUIDField(editable=False, default=uuid4)
processing_started = models.DateTimeField(**BNULL)
processed_finished = models.DateTimeField(**BNULL)
process_log = models.TextField(**BNULL)
process_count = models.IntegerField(**BNULL)
csv_file = models.FileField(upload_to="birding-csv-uploads/", **BNULL)
class Meta:
verbose_name = "Birding CSV Import"
def __str__(self):
return f"Birding import on {self.human_start}"
@property
def human_start(self):
start = "Unknown"
if self.processing_started:
start = self.processing_started.strftime("%B %d, %Y at %H:%M")
return start
@property
def import_type(self):
return "Birding CSV"
def get_absolute_url(self):
return reverse("birds:csv_import_detail", kwargs={"slug": self.uuid})
@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
def mark_started(self):
self.processing_started = timezone.now()
self.save(update_fields=["processing_started"])
def mark_finished(self):
self.processed_finished = timezone.now()
self.save(update_fields=["processed_finished"])
def record_log(self, scrobbles):
self.process_log = ""
if not scrobbles:
self.process_count = 0
self.save(update_fields=["process_log", "process_count"])
return
for count, scrobble in enumerate(scrobbles):
scrobble_str = (
f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.media_obj}"
)
log_line = f"{scrobble_str}"
if count > 0:
log_line = "\n" + log_line
self.process_log += log_line
self.process_count = len(scrobbles)
self.save(update_fields=["process_log", "process_count"])
def scrobbles(self):
from scrobbles.models import Scrobble
scrobble_ids = []
if self.process_log:
for line in self.process_log.split("\n"):
sid = line.split("\t")[0]
if sid:
scrobble_ids.append(sid)
return Scrobble.objects.filter(id__in=scrobble_ids)
def process(self, force=False):
if self.processed_finished and not force:
logger.info(f"{self} already processed on {self.processed_finished}")
return
from birds.importer import import_birding_csv
self.mark_started()
scrobbles = import_birding_csv(self.upload_file_path, self.user_id)
self.record_log(scrobbles)
self.mark_finished()

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

@ -0,0 +1,46 @@
<div class="bird-sightings-widget">
<div class="bird-sightings-list">
{% for sighting in widget.sightings %}
<div class="bird-sighting-row row mb-2">
<div class="col-md-6">
<select name="{{widget.name}}_bird_id" class="form-control">
<option value="">Select bird...</option>
{% for bird in widget.birds %}
<option value="{{bird.id}}" {% if sighting.bird_id == bird.id %}selected{% endif %}>{{bird.common_name}}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<input type="number" name="{{widget.name}}_quantity" class="form-control" value="{{sighting.quantity|default:1}}" min="1" placeholder="Qty">
</div>
<div class="col-md-3">
<input type="text" name="{{widget.name}}_sighting_notes" class="form-control" value="{{sighting.sighting_notes|default:''}}" placeholder="Notes">
</div>
<div class="col-md-1">
<button type="button" class="btn btn-sm btn-outline-danger remove-sighting">&times;</button>
</div>
</div>
{% empty %}
<div class="bird-sighting-row row mb-2">
<div class="col-md-6">
<select name="{{widget.name}}_bird_id" class="form-control">
<option value="">Select bird...</option>
{% for bird in widget.birds %}
<option value="{{bird.id}}">{{bird.common_name}}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<input type="number" name="{{widget.name}}_quantity" class="form-control" value="1" min="1" placeholder="Qty">
</div>
<div class="col-md-3">
<input type="text" name="{{widget.name}}_sighting_notes" class="form-control" value="" placeholder="Notes">
</div>
<div class="col-md-1">
<button type="button" class="btn btn-sm btn-outline-danger remove-sighting">&times;</button>
</div>
</div>
{% endfor %}
</div>
<button type="button" class="btn btn-sm btn-outline-primary add-sighting-row mt-2">Add bird</button>
</div>

View File

@ -0,0 +1,37 @@
from birds import views
from django.urls import path
app_name = "birds"
urlpatterns = [
path(
"birding-locations/",
views.BirdingLocationListView.as_view(),
name="birding_location_list",
),
path(
"birding-locations/<slug:slug>/",
views.BirdingLocationDetailView.as_view(),
name="birding_location_detail",
),
path(
"birds/",
views.BirdListView.as_view(),
name="bird_list",
),
path(
"birds/<slug:slug>/",
views.BirdDetailView.as_view(),
name="bird_detail",
),
path(
"upload/birding-csv/",
views.BirdingCSVImportCreateView.as_view(),
name="csv-upload",
),
path(
"imports/birding-csv/<slug:slug>/",
views.BirdingCSVImportDetailView.as_view(),
name="csv_import_detail",
),
]

View File

@ -0,0 +1,63 @@
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,
JsonableResponseMixin,
)
class BirdingLocationListView(ScrobbleableListView):
model = BirdingLocation
class BirdingLocationDetailView(ScrobbleableDetailView):
model = BirdingLocation
class BirdListView(generic.ListView):
model = Bird
paginate_by = 200
ordering = "common_name"
class BirdDetailView(generic.DetailView):
model = Bird
slug_field = "uuid"
class BirdingCSVImportCreateView(
LoginRequiredMixin, JsonableResponseMixin, generic.CreateView
):
model = BirdingCSVImport
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 BirdingCSVImportDetailView(LoginRequiredMixin, generic.DetailView):
model = BirdingCSVImport
slug_field = "uuid"
template_name = "scrobbles/import_detail.html"
def get_queryset(self):
return super().get_queryset().filter(user=self.request.user)
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
context_data["title"] = "eBird CSV Import"
return context_data

View File

@ -14,9 +14,7 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
SEARCH_ID_URL = (
"https://boardgamegeek.com/xmlapi/search?search={query}&exact=1"
)
SEARCH_ID_URL = "https://boardgamegeek.com/xmlapi/search?search={query}&exact=1"
GAME_ID_URL = "https://boardgamegeek.com/xmlapi/boardgame/{id}"
BGG_ACCESS_TOKEN = getattr(settings, "BGG_ACCESS_TOKEN", "")
BASE_HEADERS = {

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.29 on 2026-03-26 21:25
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
("boardgames", "0013_boardgame_publishers"),
]
operations = [
migrations.AddField(
model_name="boardgame",
name="tags",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.29 on 2026-05-01 15:49
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0075_add_channel_scrobble"),
("boardgames", "0014_boardgame_tags"),
]
operations = [
migrations.AlterField(
model_name="boardgame",
name="genre",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="scrobbles.ObjectWithGenres",
to="scrobbles.Genre",
verbose_name="Genre",
),
),
]

View File

@ -80,6 +80,7 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
board: Optional[str] = None
rounds: Optional[int] = None
details: Optional[str] = None
raw_data: Optional[dict] = None
_excluded_fields = {
"lichess_id",
@ -89,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:
@ -99,29 +120,38 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
def player_log(self) -> str:
if self.players:
return ", ".join(
[
BoardGameScoreLogData(**player).__str__()
for player in self.players
]
[BoardGameScoreLogData(**player).__str__() for player in self.players]
)
return ""
@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(),
def as_html(self) -> str:
html_parts = []
if self.board:
html_parts.append(f'<div class="boardgame-board">{self.board}</div>')
if self.location:
html_parts.append(f'<div class="boardgame-location">{self.location}</div>')
if self.players:
players_html = []
for player_data in self.players:
player = BoardGameScoreLogData(**player_data)
player_info = player.name
if player.score:
player_info += f" ({player.score})"
if player.win:
player_info += " 🏆"
if player.new:
player_info += " (new)"
players_html.append(
f'<div class="boardgame-player">{player_info}</div>'
)
html_parts.append(
f'<div class="boardgame-players">{"".join(players_html)}</div>'
)
}
fields.update(custom_fields)
return fields
return "".join(html_parts)
class BoardGamePublisher(TimeStampedModel):
@ -134,9 +164,7 @@ class BoardGamePublisher(TimeStampedModel):
return self.name
def get_absolute_url(self):
return reverse(
"boardgames:publisher_detail", kwargs={"slug": self.uuid}
)
return reverse("boardgames:publisher_detail", kwargs={"slug": self.uuid})
class BoardGameDesigner(TimeStampedModel):
@ -149,9 +177,7 @@ class BoardGameDesigner(TimeStampedModel):
return str(self.name)
def get_absolute_url(self):
return reverse(
"boardgames:designer_detail", kwargs={"slug": self.uuid}
)
return reverse("boardgames:designer_detail", kwargs={"slug": self.uuid})
class BoardGameLocation(TimeStampedModel):
@ -159,23 +185,17 @@ class BoardGameLocation(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
bgstats_id = models.UUIDField(**BNULL)
description = models.TextField(**BNULL)
geo_location = models.ForeignKey(
GeoLocation, **BNULL, on_delete=models.DO_NOTHING
)
geo_location = models.ForeignKey(GeoLocation, **BNULL, on_delete=models.DO_NOTHING)
def __str__(self) -> str:
return str(self.name)
def get_absolute_url(self):
return reverse(
"boardgames:location_detail", kwargs={"slug": self.uuid}
)
return reverse("boardgames:location_detail", kwargs={"slug": self.uuid})
class BoardGame(ScrobblableMixin):
COMPLETION_PERCENT = getattr(
settings, "BOARD_GAME_COMPLETION_PERCENT", 100
)
COMPLETION_PERCENT = getattr(settings, "BOARD_GAME_COMPLETION_PERCENT", 100)
FIELDS_FROM_BGGEEK = [
"igdb_id",
@ -250,9 +270,7 @@ class BoardGame(ScrobblableMixin):
return self.title
def get_absolute_url(self):
return reverse(
"boardgames:boardgame_detail", kwargs={"slug": self.uuid}
)
return reverse("boardgames:boardgame_detail", kwargs={"slug": self.uuid})
@property
def logdata_cls(self):
@ -277,7 +295,6 @@ class BoardGame(ScrobblableMixin):
def fix_metadata(self, data: dict = {}, force_update=False) -> None:
if not self.published_date or force_update:
if not data:
data = lookup_boardgame_from_bgg(str(self.bggeek_id))
@ -316,9 +333,7 @@ class BoardGame(ScrobblableMixin):
self.cover.save(fname, ContentFile(r.content), save=True)
@classmethod
def find_or_create(
cls, lookup_id: str, data: dict[str, Any] = {}
) -> "BoardGame":
def find_or_create(cls, lookup_id: str, data: dict[str, Any] = {}) -> "BoardGame":
"""Given a Lookup ID (either BGG or BGA ID), return a board game object"""
game = cls.objects.filter(bggeek_id=lookup_id).first()
if not game:
@ -336,7 +351,10 @@ class BoardGame(ScrobblableMixin):
elif data.get("name"):
bgg_data = lookup_boardgame_from_bgg(title=data.get("name"))
else:
bgg_data = lookup_boardgame_from_bgg(title=lookup_id)
if int(lookup_id):
bgg_data = lookup_boardgame_from_bgg(lookup_id=lookup_id)
else:
bgg_data = lookup_boardgame_from_bgg(title=lookup_id)
mechanics = bgg_data.pop("mechanics", [])
designers = bgg_data.pop("designers", [])
@ -354,9 +372,7 @@ class BoardGame(ScrobblableMixin):
game.uses_teams = data.get("useTeams", False)
game.bgstats_id = data.get("uuid", None)
if publisher:
publisher, _ = BoardGamePublisher.objects.get_or_create(
name=publisher
)
publisher, _ = BoardGamePublisher.objects.get_or_create(name=publisher)
game.publisher = publisher
game.save()
@ -369,9 +385,7 @@ class BoardGame(ScrobblableMixin):
if publishers:
for name in publishers:
publisher, _ = BoardGamePublisher.objects.get_or_create(
name=name
)
publisher, _ = BoardGamePublisher.objects.get_or_create(name=name)
game.publishers.add(publisher)
return game

View File

@ -11,9 +11,7 @@ User = get_user_model()
def import_chess_games_for_user_id(user_id: int, commit: bool = False) -> dict:
user = User.objects.get(id=user_id)
client = berserk.Client(
session=berserk.TokenSession(settings.LICHESS_API_KEY)
)
client = berserk.Client(session=berserk.TokenSession(settings.LICHESS_API_KEY))
games = client.games.export_by_player(user.profile.lichess_username)
for game_dict in games:
chess, created = BoardGame.objects.get_or_create(title="Chess")
@ -62,9 +60,7 @@ def import_chess_games_for_user_id(user_id: int, commit: bool = False) -> dict:
white_player.get("aiLevel", "")
)
else:
other_player["name_str"] = white_player.get("user", {}).get(
"name", ""
)
other_player["name_str"] = white_player.get("user", {}).get("name", "")
other_player["lichess_username"] = other_player["name_str"]
other_player["color"] = "white"
@ -82,9 +78,7 @@ def import_chess_games_for_user_id(user_id: int, commit: bool = False) -> dict:
black_player.get("aiLevel", "")
)
else:
other_player["name_str"] = black_player.get("user", {}).get(
"name", ""
)
other_player["name_str"] = black_player.get("user", {}).get("name", "")
other_player["lichess_username"] = other_player["name_str"]
other_player["color"] = "black"
if winner == "white":
@ -119,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

@ -3,7 +3,11 @@ from django.utils import timezone
from django.views import generic
from boardgames.models import BoardGame, BoardGameDesigner, BoardGamePublisher
from scrobbles.models import Scrobble
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
from scrobbles.views import (
ChartContextMixin,
ScrobbleableListView,
ScrobbleableDetailView,
)
class BoardGameListView(ScrobbleableListView):
@ -33,9 +37,7 @@ class BoardGameListView(ScrobbleableListView):
for designer in scrobble.board_game.designers.all():
designers_this_week[designer.id] = {
"designer": designer,
"count": designers_this_week.get(designer.id, {}).get(
"count", 0
)
"count": designers_this_week.get(designer.id, {}).get("count", 0)
+ 1,
}
@ -44,9 +46,7 @@ class BoardGameListView(ScrobbleableListView):
for designer in scrobble.board_game.designers.all():
designers_this_month[designer.id] = {
"designer": designer,
"count": designers_this_month.get(designer.id, {}).get(
"count", 0
)
"count": designers_this_month.get(designer.id, {}).get("count", 0)
+ 1,
}
@ -64,7 +64,7 @@ class BoardGameListView(ScrobbleableListView):
return context_data
class BoardGameDetailView(ScrobbleableDetailView):
class BoardGameDetailView(ScrobbleableDetailView, ChartContextMixin):
model = BoardGame

View File

@ -6,9 +6,7 @@ import logging
logger = logging.getLogger(__name__)
USER_AGENT = (
"Mozilla/5.0 (Android 4.4; Mobile; rv:41.0) Gecko/41.0 Firefox/41.0"
)
USER_AGENT = "Mozilla/5.0 (Android 4.4; Mobile; rv:41.0) Gecko/41.0 Firefox/41.0"
AMAZON_SEARCH_URL = "https://www.amazon.com/s?k={amazon_id}"
@ -95,32 +93,20 @@ def get_amazon_product_dict(amazon_id: str) -> dict:
soup = BeautifulSoup(response.text, "html.parser")
try:
data_dict["title"] = soup.findAll("span", class_="celwidget")[
1
].text.strip()
data_dict["cover_url"] = soup.find("img", class_="frontImage").get(
"src"
)
data_dict["summary"] = soup.findAll(
"div", class_="a-expander-content"
)[1].text
data_dict["title"] = soup.findAll("span", class_="celwidget")[1].text.strip()
data_dict["cover_url"] = soup.find("img", class_="frontImage").get("src")
data_dict["summary"] = soup.findAll("div", class_="a-expander-content")[1].text
meta = soup.findAll("div", class_="rpi-attribute-value")
data_dict["isbn"] = meta[AmazonAttribute.ISBN_10.value].text.strip()
pages = meta[AmazonAttribute.PAGES.value].text
if "pages" in pages:
data_dict["pages"] = (
meta[AmazonAttribute.PAGES.value]
.text.split("pages")[0]
.strip()
meta[AmazonAttribute.PAGES.value].text.split("pages")[0].strip()
)
except IndexError as e:
logger.error(
f"Amazon lookup is failing for this product {amazon_id}: {e}"
)
logger.error(f"Amazon lookup is failing for this product {amazon_id}: {e}")
except AttributeError as e:
logger.error(
f"Amazon lookup is failing for this product {amazon_id}: {e}"
)
logger.error(f"Amazon lookup is failing for this product {amazon_id}: {e}")
return data_dict

View File

@ -1,4 +1,5 @@
#!/usr/bin/env python3
from enum import Enum
BOOKS_TITLES_TO_IGNORE = [
"KOReader Quickstart Guide",
@ -7,3 +8,16 @@ BOOKS_TITLES_TO_IGNORE = [
]
READCOMICSONLINE_URL = "https://readcomicsonline.ru"
class MediaSourceTag(str, Enum):
OPENLIBRARY = "source_openlibrary"
GOOGLE_BOOKS = "source_google_books"
COMICVINE = "source_comicvine"
LOCG = "source_locg"
KOREADER = "source_koreader"
SEMANTIC_SCHOLAR = "source_semantic_scholar"
@classmethod
def choices(cls):
return [(tag.value, tag.name.replace("_", " ").title()) for tag in cls]

View File

@ -146,9 +146,7 @@ def build_book_map(rows) -> dict:
)
continue
book = Book.objects.filter(
koreader_data_by_hash__icontains=book_row[
KoReaderBookColumn.MD5.value
]
koreader_data_by_hash__icontains=book_row[KoReaderBookColumn.MD5.value]
).first()
if not book:
@ -202,15 +200,11 @@ def build_page_data(page_rows: list, book_map: dict, user_tz=None) -> dict:
"end_ts": start_ts + duration,
}
if book_ids_not_found:
logger.info(
f"Found pages for books not in file: {set(book_ids_not_found)}"
)
logger.info(f"Found pages for books not in file: {set(book_ids_not_found)}")
return book_map
def build_scrobbles_from_book_map(
book_map: dict, user: "User"
) -> list["Scrobble"]:
def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobble"]:
Scrobble = apps.get_model("scrobbles", "Scrobble")
scrobbles_to_create = []
@ -240,9 +234,9 @@ def build_scrobbles_from_book_map(
seconds_from_last_page = 0
if prev_page_stats:
seconds_from_last_page = stats.get(
"end_ts"
) - prev_page_stats.get("start_ts")
seconds_from_last_page = stats.get("end_ts") - prev_page_stats.get(
"start_ts"
)
playback_position_seconds = playback_position_seconds + stats.get(
"duration"
@ -251,9 +245,7 @@ def build_scrobbles_from_book_map(
end_of_reading = pages_processed == total_pages_read
big_jump_to_this_page = (cur_page_number - last_page_number) > 10
is_session_gap = seconds_from_last_page > SESSION_GAP_SECONDS
if (
is_session_gap and not big_jump_to_this_page
) or end_of_reading:
if (is_session_gap and not big_jump_to_this_page) or end_of_reading:
should_create_scrobble = True
if should_create_scrobble:
@ -381,9 +373,7 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
return new_scrobbles
book_map = build_page_data(
cur.execute(
"SELECT * from page_stat_data ORDER BY id_book, start_time"
),
cur.execute("SELECT * from page_stat_data ORDER BY id_book, start_time"),
book_map,
tz,
)

View File

@ -13,9 +13,7 @@ HEADERS = {
}
LOCG_WRTIER_URL = ""
LOCG_WRITER_DETAIL_URL = "https://leagueofcomicgeeks.com/people/{slug}"
LOCG_SEARCH_URL = (
"https://leagueofcomicgeeks.com/search/ajax_issues?query={query}"
)
LOCG_SEARCH_URL = "https://leagueofcomicgeeks.com/search/ajax_issues?query={query}"
LOCG_DETAIL_URL = "https://leagueofcomicgeeks.com/comic/{locg_slug}"
@ -72,26 +70,19 @@ def lookup_comic_by_locg_slug(slug: str) -> dict:
attrs = soup.findAll("div", class_="details-addtl-block")
try:
data_dict["pages"] = (
attrs[1]
.find("div", class_="value")
.text.split("pages")[0]
.strip()
attrs[1].find("div", class_="value").text.split("pages")[0].strip()
)
except IndexError:
logger.warn(f"No ISBN field")
try:
data_dict["isbn"] = (
attrs[3].find("div", class_="value").text.strip()
)
data_dict["isbn"] = attrs[3].find("div", class_="value").text.strip()
except IndexError:
logger.warn(f"No ISBN field")
writer_slug = None
try:
writer_slug = (
soup.findAll("div", class_="name")[5]
.a.get("href")
.split("people/")[1]
soup.findAll("div", class_="name")[5].a.get("href").split("people/")[1]
)
except IndexError:
logger.warn(f"No wrtier found")

View File

@ -37,7 +37,5 @@ class Command(BaseCommand):
def handle(self, *args, **options):
scrobbles_to_create = []
for scrobble in Scrobble.objects.filter(
media_type="Book", source="KOReader"
):
for scrobble in Scrobble.objects.filter(media_type="Book", source="KOReader"):
update_scrobble_from_page_data(scrobble)

View File

@ -52,8 +52,7 @@ class Command(BaseCommand):
seconds_from_last_page = 0
if prev_page:
seconds_from_last_page = (
page.end_time.timestamp()
- prev_page.start_time.timestamp()
page.end_time.timestamp() - prev_page.start_time.timestamp()
)
playback_position_seconds = (
playback_position_seconds + page.duration_seconds
@ -62,9 +61,7 @@ class Command(BaseCommand):
end_of_reading = pages_processed == total_pages
big_jump_to_this_page = False
if prev_page:
big_jump_to_this_page = (
page.number - prev_page.number
) > 10
big_jump_to_this_page = (page.number - prev_page.number) > 10
if (
seconds_from_last_page > SESSION_GAP_SECONDS
and not big_jump_to_this_page
@ -113,9 +110,7 @@ class Command(BaseCommand):
user_id=user.id,
).first()
if scrobble:
logger.info(
f"Found existing scrobble {scrobble}, updating"
)
logger.info(f"Found existing scrobble {scrobble}, updating")
scrobble.book_page_data = scrobble_page_data
scrobble.playback_position_seconds = (
scrobble.calc_reading_duration()

View File

@ -1,10 +1,6 @@
from typing import Optional
YOUTUBE_VIDEO_URL = "https://www.youtube.com/watch?v="
IMDB_VIDEO_URL = "https://www.imdb.com/title/"
class BookType:
...

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.29 on 2026-03-26 21:23
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
("books", "0033_alter_book_issue_number_alter_book_volume_number"),
]
operations = [
migrations.AddField(
model_name="book",
name="tags",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.29 on 2026-03-26 21:25
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
("books", "0034_book_tags"),
]
operations = [
migrations.AddField(
model_name="paper",
name="tags",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
]

View File

@ -0,0 +1,37 @@
# Generated by Django 4.2.29 on 2026-05-01 15:49
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0075_add_channel_scrobble"),
("books", "0035_paper_tags"),
]
operations = [
migrations.AlterField(
model_name="book",
name="genre",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="scrobbles.ObjectWithGenres",
to="scrobbles.Genre",
verbose_name="Genre",
),
),
migrations.AlterField(
model_name="paper",
name="genre",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="scrobbles.ObjectWithGenres",
to="scrobbles.Genre",
verbose_name="Genre",
),
),
]

View File

@ -6,12 +6,24 @@ from typing import Optional
from uuid import uuid4
import requests
from books.constants import READCOMICSONLINE_URL
from books.constants import MediaSourceTag, READCOMICSONLINE_URL
from books.locg import (
lookup_comic_by_locg_slug,
lookup_comic_from_locg,
lookup_comic_writer_by_locg_slug,
)
from books.openlibrary import (
lookup_author_from_openlibrary,
lookup_book_from_openlibrary,
)
from books.sources.comicvine import (
ComicVineClient,
lookup_comic_from_comicvine,
)
from books.sources.google import lookup_book_from_google
from books.sources.openlibrary import (
lookup_book_from_openlibrary as lookup_book_from_ol,
)
from books.sources.semantic import lookup_paper_from_semantic
from books.utils import get_comic_issue_url
from django.conf import settings
@ -32,16 +44,6 @@ from scrobbles.utils import get_scrobbles_for_media, next_url_if_exists
from taggit.managers import TaggableManager
from thefuzz import fuzz
from vrobbler.apps.books.locg import (
lookup_comic_by_locg_slug,
lookup_comic_from_locg,
lookup_comic_writer_by_locg_slug,
)
from vrobbler.apps.books.sources.comicvine import (
ComicVineClient,
lookup_comic_from_comicvine,
)
COMICVINE_API_KEY = getattr(settings, "COMICVINE_API_KEY", "")
logger = logging.getLogger(__name__)
@ -106,11 +108,9 @@ class Author(TimeStampedModel):
def __str__(self):
return f"{self.name}"
def enrich_from_semantic(self, overwrite=False):
...
def enrich_from_semantic(self, overwrite=False): ...
def enrich_from_google_books(self, overwrite=False):
...
def enrich_from_google_books(self, overwrite=False): ...
def enrich_from_openlibrary(self, overwrite=False):
data_dict = lookup_author_from_openlibrary(self.openlibrary_id)
@ -133,9 +133,7 @@ class Author(TimeStampedModel):
class Book(LongPlayScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, "BOOK_COMPLETION_PERCENT", 95)
AVG_PAGE_READING_SECONDS = getattr(
settings, "AVERAGE_PAGE_READING_SECONDS", 60
)
AVG_PAGE_READING_SECONDS = getattr(settings, "AVERAGE_PAGE_READING_SECONDS", 60)
title = models.CharField(max_length=255)
original_title = models.CharField(max_length=255, **BNULL)
@ -172,7 +170,7 @@ class Book(LongPlayScrobblableMixin):
)
summary = models.TextField(**BNULL)
genre = TaggableManager(through=ObjectWithGenres)
genre = TaggableManager(through=ObjectWithGenres, blank=True, verbose_name="Genre")
def __str__(self) -> str:
if self.issue_number and "Issue" not in str(self.title):
@ -181,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}"
@ -203,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
@ -245,8 +254,9 @@ class Book(LongPlayScrobblableMixin):
def find_or_create(
cls,
title: str,
author: str | None = None,
url: str = "",
enrich: bool = False,
enrich: bool = True,
commit: bool = True,
):
"""Given a title, get a Book instance.
@ -260,9 +270,7 @@ class Book(LongPlayScrobblableMixin):
# TODO use either a Google Books id identifier or author name like for tracks
book, created = cls.objects.get_or_create(original_title=title)
if not created:
logger.info(
"Found exact match for book by title", extra={"title": title}
)
logger.info("Found exact match for book by title", extra={"title": title})
if not enrich:
logger.info(
@ -272,15 +280,25 @@ class Book(LongPlayScrobblableMixin):
return book
book_dict = None
source_tag = None
if READCOMICSONLINE_URL in url:
book_dict = lookup_comic_from_comicvine(title)
if book_dict:
source_tag = MediaSourceTag.COMICVINE
book_dict["readcomics_url"] = get_comic_issue_url(url)
book_dict["next_readcomics_url"] = next_url_if_exists(
book_dict["readcomics_url"]
)
if not book_dict:
book_dict = lookup_book_from_ol(title, author=author)
if book_dict:
source_tag = MediaSourceTag.OPENLIBRARY
if not book_dict:
book_dict = lookup_book_from_google(title)
if book_dict:
source_tag = MediaSourceTag.GOOGLE_BOOKS
if not book_dict:
logger.warning(
@ -291,14 +309,12 @@ class Book(LongPlayScrobblableMixin):
author_list = []
authors = book_dict.pop("authors", [])
cover_url = book_dict.pop("cover_url", "")
genres = book_dict.pop("generes", [])
genres = book_dict.pop("genres", book_dict.pop("generes", []))
if authors:
for author_str in authors:
if author_str:
author, a_created = Author.objects.get_or_create(
name=author_str
)
author, a_created = Author.objects.get_or_create(name=author_str)
author_list.append(author)
if a_created:
# TODO enrich author
@ -311,8 +327,11 @@ class Book(LongPlayScrobblableMixin):
book.save()
book.save_image_from_url(cover_url)
book.genre.add(*genres)
if genres:
book.genre.add(*genres)
book.authors.add(*author_list)
if source_tag:
book.tags.add(source_tag.value)
return book
@ -332,13 +351,9 @@ class Book(LongPlayScrobblableMixin):
if not data:
logger.warn(f"Checking openlibrary for {self.title}")
if self.openlibrary_id and force_update:
data = lookup_book_from_openlibrary(
str(self.openlibrary_id)
)
data = lookup_book_from_ol(str(self.openlibrary_id))
else:
data = lookup_book_from_openlibrary(
str(self.title), author_name
)
data = lookup_book_from_ol(str(self.title), author_name)
if not data:
if self.locg_slug:
@ -385,10 +400,7 @@ class Book(LongPlayScrobblableMixin):
if "pages" in data.keys() and data.get("pages") == None:
data.pop("pages")
if (
not isinstance(data.get("pages"), int)
and "pages" in data.keys()
):
if not isinstance(data.get("pages"), int) and "pages" in data.keys():
logger.info(
f"Pages for {self} from OL expected to be int, but got {data.get('pages')}"
)
@ -397,7 +409,7 @@ class Book(LongPlayScrobblableMixin):
# Pop this, so we can look it up later
cover_url = data.pop("cover_url", "")
subject_key_list = data.pop("subject_key_list", "")
subject_key_list = data.pop("subject_key_list", [])
# Fun trick for updating all fields at once
Book.objects.filter(pk=self.id).update(**data)
@ -412,17 +424,10 @@ 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):
author = Author.objects.filter(
openlibrary_id=openlibrary_author_id
).first()
author = Author.objects.filter(openlibrary_id=openlibrary_author_id).first()
if not author:
data = lookup_author_from_openlibrary(openlibrary_author_id)
author_image_url = data.pop("author_headshot_url", None)
@ -433,9 +438,7 @@ class Book(LongPlayScrobblableMixin):
r = requests.get(author_image_url)
if r.status_code == 200:
fname = f"{author.name}_{author.uuid}.jpg"
author.headshot.save(
fname, ContentFile(r.content), save=True
)
author.headshot.save(fname, ContentFile(r.content), save=True)
self.authors.add(author)
def get_author_from_locg(self, locg_slug):
@ -451,9 +454,7 @@ class Book(LongPlayScrobblableMixin):
author.headshot.save(fname, ContentFile(r.content), save=True)
self.authors.add(author)
def page_data_for_user(
self, user_id: int, convert_timestamps: bool = True
) -> dict:
def page_data_for_user(self, user_id: int, convert_timestamps: bool = True) -> dict:
scrobbles = self.scrobble_set.filter(user=user_id)
pages = {}
@ -461,9 +462,7 @@ class Book(LongPlayScrobblableMixin):
if scrobble.logdata.page_data:
for page, data in scrobble.logdata.page_data.items():
if convert_timestamps:
data["start_ts"] = datetime.fromtimestamp(
data["start_ts"]
)
data["start_ts"] = datetime.fromtimestamp(data["start_ts"])
data["end_ts"] = datetime.fromtimestamp(data["end_ts"])
pages[page] = data
sorted_pages = OrderedDict(
@ -502,9 +501,7 @@ class Paper(LongPlayScrobblableMixin):
"""Keeps track of Academic Papers"""
COMPLETION_PERCENT = getattr(settings, "PAPER_COMPLETION_PERCENT", 60)
AVG_PAGE_READING_SECONDS = getattr(
settings, "AVERAGE_PAGE_READING_SECONDS", 60
)
AVG_PAGE_READING_SECONDS = getattr(settings, "AVERAGE_PAGE_READING_SECONDS", 60)
title = models.CharField(max_length=255)
semantic_title = models.CharField(max_length=255, **BNULL)
@ -525,7 +522,7 @@ class Paper(LongPlayScrobblableMixin):
tldr = models.CharField(max_length=255, **BNULL)
openaccess_pdf_url = models.CharField(max_length=255, **BNULL)
genre = TaggableManager(through=ObjectWithGenres)
genre = TaggableManager(through=ObjectWithGenres, blank=True, verbose_name="Genre")
@classmethod
def get_from_semantic(cls, title: str, overwrite: bool = False) -> "Paper":

View File

@ -10,7 +10,9 @@ from thefuzz import fuzz
logger = logging.getLogger(__name__)
ISBN_URL = "https://openlibrary.org/isbn/{isbn}.json"
SEARCH_URL = "https://openlibrary.org/search.json?q={query}&sort=editions&mode=everything"
SEARCH_URL = (
"https://openlibrary.org/search.json?q={query}&sort=editions&mode=everything"
)
AUTHOR_SEARCH_URL = "https://openlibrary.org/search/authors.json?q={query}"
COVER_URL = "https://covers.openlibrary.org/b/olid/{id}-L.jpg"
AUTHOR_URL = "https://openlibrary.org/authors/{id}.json"
@ -80,9 +82,7 @@ def lookup_author_from_openlibrary(olid: str) -> dict:
}
def lookup_book_from_openlibrary(
title: str, author: Optional[str] = None
) -> dict:
def lookup_book_from_openlibrary(title: str, author: Optional[str] = None) -> dict:
title_quoted = urllib.parse.quote(title)
author_quoted = ""
if author:

View File

@ -160,9 +160,7 @@ class ComicVineClient(object):
# or not the request cache is to be used, store the procedure in a
# local function to avoid repetition.
def __httpget():
response = requests.get(
self.API_URL, headers=self.HEADERS, params=params
)
response = requests.get(self.API_URL, headers=self.HEADERS, params=params)
if not response.ok:
self._handle_http_error(response)
@ -214,19 +212,13 @@ def lookup_comic_from_comicvine(title: str) -> dict:
api_key = getattr(settings, "COMICVINE_API_KEY", "")
if not api_key:
logger.warning(
"No ComicVine API key configured, not looking anything up"
)
logger.warning("No ComicVine API key configured, not looking anything up")
return {}
client = ComicVineClient(
api_key=getattr(settings, "COMICVINE_API_KEY", None)
)
client = ComicVineClient(api_key=getattr(settings, "COMICVINE_API_KEY", None))
raw_results = client.search(title).get("results")
results = [
r for r in raw_results if r.get("resource_type") == resource_type
]
results = [r for r in raw_results if r.get("resource_type") == resource_type]
if not results:
logger.warning("No comic found on ComicVine")
return {}

View File

@ -6,9 +6,7 @@ import requests
from django.conf import settings
API_KEY = settings.GOOGLE_API_KEY
GOOGLE_BOOKS_URL = (
'https://www.googleapis.com/books/v1/volumes?q="{title}"&key={key}'
)
GOOGLE_BOOKS_URL = 'https://www.googleapis.com/books/v1/volumes?q="{title}"&key={key}'
logger = logging.getLogger(__name__)
@ -21,14 +19,10 @@ def lookup_book_from_google(title: str) -> dict:
response = requests.get(url, headers=headers)
if response.status_code != 200:
logger.warning(
"Bad response from Google", extra={"response": response}
)
logger.warning("Bad response from Google", extra={"response": response})
return book_dict
google_result = (
json.loads(response.content).get("items", [{}])[0].get("volumeInfo")
)
google_result = json.loads(response.content).get("items", [{}])[0].get("volumeInfo")
if not google_result:
return {}
@ -69,8 +63,8 @@ def lookup_book_from_google(title: str) -> dict:
book_dict["base_run_time_seconds"] = 3600
if book_dict.get("pages"):
book_dict["base_run_time_seconds"] = book_dict.get(
"pages", 10
) * getattr(settings, "AVERAGE_PAGE_READING_SECONDS", 60)
book_dict["base_run_time_seconds"] = book_dict.get("pages", 10) * getattr(
settings, "AVERAGE_PAGE_READING_SECONDS", 60
)
return book_dict

View File

@ -0,0 +1,160 @@
import logging
import requests
logger = logging.getLogger(__name__)
OPENLIBRARY_BASE_URL = "https://openlibrary.org"
def lookup_book_from_openlibrary(title: str, author: str | None = None) -> dict:
try:
response = requests.get(
f"{OPENLIBRARY_BASE_URL}/search.json",
params={"title": title, "limit": 5},
timeout=10,
)
response.raise_for_status()
search_results = response.json()
except Exception as e:
logger.warning(f"Could not search for {title}: {e}")
return {}
docs = search_results.get("docs", [])
title_lower = title.lower()
for work_doc in docs:
found_title = work_doc.get("title", "")
if found_title.lower() == title_lower:
olid = work_doc.get("key", "").replace("/works/", "")
if not olid:
continue
break
else:
logger.info(f"OpenLibrary: no close match for '{title}', skipping")
return {}
try:
work_response = requests.get(
f"{OPENLIBRARY_BASE_URL}/works/{olid}.json", timeout=10
)
work_response.raise_for_status()
work_json = work_response.json()
except Exception as e:
logger.warning(f"Could not get work JSON for {olid}: {e}")
return {}
book_dict: dict = {"title": work_json.get("title"), "openlibrary_id": olid}
description = work_json.get("description")
if description:
if isinstance(description, dict):
book_dict["summary"] = description.get("value", "")
else:
book_dict["summary"] = str(description)
subjects = work_json.get("subjects", [])
if subjects:
book_dict["genres"] = subjects[:10]
covers = work_json.get("covers", [])
if covers:
book_dict["cover_url"] = (
f"https://covers.openlibrary.org/b/id/{covers[0]}-L.jpg"
)
try:
editions_response = requests.get(
f"{OPENLIBRARY_BASE_URL}/works/{olid}/editions.json", timeout=10
)
if editions_response.status_code == 200:
editions_data = editions_response.json()
entries = editions_data.get("entries", [])
if entries:
edition = entries[0]
isbn_list = edition.get("isbn_10", [])
if isbn_list:
book_dict["isbn_10"] = isbn_list[0]
isbn_list = edition.get("isbn_13", [])
if isbn_list:
book_dict["isbn_13"] = isbn_list[0]
publishers = edition.get("publishers", [])
if publishers:
book_dict["publisher"] = publishers[0]
pub_date = edition.get("publish_date")
if pub_date:
if len(pub_date) >= 4:
try:
book_dict["first_publish_year"] = int(pub_date[:4])
except ValueError:
pass
pages = edition.get("number_of_pages_median")
if pages:
book_dict["pages"] = pages
edition_covers = edition.get("covers", [])
if edition_covers and not book_dict.get("cover_url"):
book_dict["cover_url"] = (
f"https://covers.openlibrary.org/b/id/{edition_covers[0]}-L.jpg"
)
except Exception as e:
logger.warning(f"Could not get editions for {olid}: {e}")
if author:
book_dict["authors"] = [author]
elif work_doc.get("author_name"):
book_dict["authors"] = work_doc.get("author_name", [])
return book_dict
def lookup_author_from_openlibrary(name: str) -> dict:
try:
response = requests.get(
f"{OPENLIBRARY_BASE_URL}/search.json",
params={"author": name, "limit": 1},
timeout=10,
)
response.raise_for_status()
search_results = response.json()
except Exception as e:
logger.warning(f"Could not search for author {name}: {e}")
return {}
docs = search_results.get("docs", [])
if not docs:
return {}
author_doc = docs[0]
author_olid = author_doc.get("key", "").replace("/authors/", "")
if not author_olid:
return {}
author_dict: dict = {
"name": author_doc.get("name", name),
"openlibrary_id": author_olid,
}
try:
author_response = requests.get(
f"{OPENLIBRARY_BASE_URL}/authors/{author_olid}.json", timeout=10
)
if author_response.status_code == 200:
author_json = author_response.json()
bio = author_json.get("bio")
if bio:
if isinstance(bio, dict):
author_dict["bio"] = bio.get("value", "")
else:
author_dict["bio"] = str(bio)
wikipedia = author_json.get("wikipedia")
if wikipedia:
author_dict["wikipedia_url"] = wikipedia
except Exception as e:
logger.warning(f"Could not get author JSON for {author_olid}: {e}")
author_dict["author_headshot_url"] = (
f"https://covers.openlibrary.org/a/olid/{author_olid}-L.jpg"
)
return author_dict

View File

@ -18,9 +18,7 @@ def get_api_result(url):
response = requests.get(url, headers=headers)
if response.status_code != 200:
logger.warning(
"Bad response from Semantic", extra={"response": response}
)
logger.warning("Bad response from Semantic", extra={"response": response})
return None
return response
@ -64,12 +62,10 @@ def lookup_paper_from_semantic(title: str) -> dict:
paper_dict["tldr"] = result.get("bib", {}).get("abstract")
paper_dict["journal"] = result.get("journal", {}).get("name")
paper_dict["journal_volume"] = result.get("journal", {}).get("volume")
paper_dict["openaccess_pdf_url"] = result.get("openAccessPdf", {}).get(
"url"
paper_dict["openaccess_pdf_url"] = result.get("openAccessPdf", {}).get("url")
paper_dict["base_run_time_seconds"] = paper_dict.get("pages", 10) * getattr(
settings, "AVERAGE_PAGE_READING_SECONDS", 60
)
paper_dict["base_run_time_seconds"] = paper_dict.get(
"pages", 10
) * getattr(settings, "AVERAGE_PAGE_READING_SECONDS", 60)
paper_dict["author_dicts"] = result.get("authors")
paper_dict["genres"] = result.get("fieldsOfStudy")

View File

@ -99,7 +99,9 @@ class KoReaderBookRows:
]
)
if end_session:
start_time += 3600 # one second over an hour, marking a new reading session
start_time += (
3600 # one second over an hour, marking a new reading session
)
end_session = False
else:
start_time += AVERAGE_PAGE_READING_SECONDS

View File

@ -32,17 +32,13 @@ def test_load_page_data_to_map(get_mock, koreader_rows, valid_response):
)
assert (
len(book_map[1]["pages"])
== koreader_rows.BOOK_ROWS[0][
KoReaderBookColumn.TOTAL_READ_PAGES.value
]
== koreader_rows.BOOK_ROWS[0][KoReaderBookColumn.TOTAL_READ_PAGES.value]
)
@pytest.mark.django_db
@mock.patch("requests.get")
def test_build_scrobbles_from_pages(
get_mock, koreader_rows, demo_user, valid_response
):
def test_build_scrobbles_from_pages(get_mock, koreader_rows, demo_user, valid_response):
get_mock.return_value = valid_response
book_map = build_book_map(koreader_rows.BOOK_ROWS)
book_map = build_page_data(koreader_rows.PAGE_STATS_ROWS, book_map)

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.29 on 2026-03-26 21:25
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
("bricksets", "0003_remove_brickset_run_time_seconds_and_more"),
]
operations = [
migrations.AddField(
model_name="brickset",
name="tags",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.29 on 2026-05-01 15:49
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0075_add_channel_scrobble"),
("bricksets", "0004_brickset_tags"),
]
operations = [
migrations.AlterField(
model_name="brickset",
name="genre",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="scrobbles.ObjectWithGenres",
to="scrobbles.Genre",
verbose_name="Genre",
),
),
]

View File

@ -26,9 +26,7 @@ class BrickSet(LongPlayScrobblableMixin):
number = models.CharField(max_length=10, **BNULL)
release_year = models.IntegerField(**BNULL)
piece_count = models.IntegerField(**BNULL)
brickset_rating = models.DecimalField(
max_digits=3, decimal_places=1, **BNULL
)
brickset_rating = models.DecimalField(max_digits=3, decimal_places=1, **BNULL)
lego_item_number = models.CharField(max_length=10, **BNULL)
box_image = models.ImageField(upload_to="brickset/boxes/", **BNULL)
box_image_small = ImageSpecField(

View File

View File

@ -0,0 +1,59 @@
from charts.models import ChartRecord
from django.contrib import admin
@admin.register(ChartRecord)
class ChartRecordAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"user",
"rank",
"count",
"year",
"month",
"week",
"day",
"media_name",
"media_type",
)
list_filter = (
"user",
"year",
"month",
"week",
"day",
)
ordering = ("-created",)
search_fields = (
"artist__name",
"album__name",
"track__title",
"tv_series__name",
"video__title",
"podcast__name",
"board_game__name",
"book__title",
"food__name",
"trail__name",
)
raw_id_fields = (
"user",
"artist",
"album",
"track",
"tv_series",
"video",
"podcast",
"podcast_episode",
"board_game",
"trail",
"geo_location",
"food",
"book",
)
def media_name(self, obj):
return obj.media_obj
def media_type(self, obj):
return obj.media_type

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ChartsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "charts"

View File

@ -0,0 +1,113 @@
import logging
from charts.utils import (
build_all_historical_charts,
build_charts_since,
rebuild_all_charts,
)
from django.core.management.base import BaseCommand
logger = logging.getLogger(__name__)
def get_chart_counts():
from charts.models import ChartRecord
daily = ChartRecord.objects.filter(day__isnull=False).count()
weekly = ChartRecord.objects.filter(week__isnull=False, day__isnull=True).count()
monthly = ChartRecord.objects.filter(
month__isnull=False, week__isnull=True, day__isnull=True
).count()
yearly = ChartRecord.objects.filter(
month__isnull=True, week__isnull=True, day__isnull=True
).count()
total = ChartRecord.objects.count()
return {
"daily": daily,
"weekly": weekly,
"monthly": monthly,
"yearly": yearly,
"total": total,
}
def print_chart_summary(before=None):
counts = get_chart_counts()
if before:
diff = {k: counts[k] - before[k] for k in counts}
print(
f" Daily: {counts['daily']} ({diff['daily']:+d}), "
f"Weekly: {counts['weekly']} ({diff['weekly']:+d}), "
f"Monthly: {counts['monthly']} ({diff['monthly']:+d}), "
f"Yearly: {counts['yearly']} ({diff['yearly']:+d}), "
f"Total: {counts['total']} ({diff['total']:+d})"
)
else:
print(
f" Daily: {counts['daily']}, "
f"Weekly: {counts['weekly']}, "
f"Monthly: {counts['monthly']}, "
f"Yearly: {counts['yearly']}, "
f"Total: {counts['total']}"
)
class Command(BaseCommand):
help = "Build missing charts for all users"
def add_arguments(self, parser):
parser.add_argument(
"--user-id",
type=int,
help="Build charts for a specific user only",
)
parser.add_argument(
"--full-rebuild",
action="store_true",
help="Delete existing chart records and rebuild from scratch",
)
def handle(self, *args, **options):
from django.contrib.auth import get_user_model
User = get_user_model()
full_rebuild = options["full_rebuild"]
if full_rebuild:
self.stdout.write("Full rebuild requested - will delete existing records")
if options["user_id"]:
user = User.objects.filter(id=options["user_id"]).first()
if not user:
self.stderr.write(
self.style.ERROR(f"User with id {options['user_id']} not found")
)
return
users = [user]
action = "Full rebuild for" if full_rebuild else "Progressive build for"
self.stdout.write(f"{action} user: {user}")
else:
users = User.objects.all()
action = "Full rebuild for" if full_rebuild else "Progressive build for"
self.stdout.write(f"{action} charts for {users.count()} users...")
before_counts = get_chart_counts()
self.stdout.write("Chart counts before:")
print_chart_summary()
for user in users:
try:
logger.info(f"Building charts for {user}")
if full_rebuild:
rebuild_all_charts(user)
else:
build_charts_since(user)
self.stdout.write(self.style.SUCCESS(f" OK {user}"))
except Exception as e:
self.stderr.write(f" FAILED {user}: {e}")
self.stdout.write("Chart counts after (diff):")
print_chart_summary(before_counts)
self.stdout.write(self.style.SUCCESS("Done!"))

View File

@ -0,0 +1,87 @@
import logging
from django.core.management.base import BaseCommand
from django.db.models import Count, Max
logger = logging.getLogger(__name__)
MEDIA_FIELDS = [
"artist",
"album",
"track",
"tv_series",
"video",
"podcast",
"podcast_episode",
"board_game",
"trail",
"geo_location",
"food",
"book",
]
class Command(BaseCommand):
help = "Clean up duplicate ChartRecords"
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be deleted without actually deleting",
)
def handle(self, *args, **options):
from charts.models import ChartRecord
dry_run = options["dry_run"]
total_deleted = 0
for media_field in MEDIA_FIELDS:
field_id = f"{media_field}_id"
duplicates = (
ChartRecord.objects.filter(**{f"{field_id}__isnull": False})
.values("user_id", "year", "month", "week", "day", field_id)
.annotate(
count=Count("id"),
max_id=Max("id"),
)
.filter(count__gt=1)
)
for dup in duplicates:
max_id = dup["max_id"]
to_delete = list(
ChartRecord.objects.filter(
user_id=dup["user_id"],
year=dup["year"],
month=dup["month"],
week=dup["week"],
day=dup["day"],
**{field_id: dup[field_id]},
)
.exclude(id=max_id)
.values_list("id", flat=True)
)
if dry_run:
self.stdout.write(
f"Would delete {len(to_delete)} duplicate ChartRecords "
f"({media_field}, user={dup['user_id']}, year={dup['year']}, "
f"month={dup['month']}, week={dup['week']}, day={dup['day']})"
)
else:
deleted, _ = ChartRecord.objects.filter(id__in=to_delete).delete()
total_deleted += deleted
self.stdout.write(
f"Deleted {deleted} duplicate ChartRecords "
f"({media_field}, user={dup['user_id']}, year={dup['year']}, "
f"month={dup['month']}, week={dup['week']}, day={dup['day']})"
)
if dry_run:
self.stdout.write(self.style.WARNING("Dry run complete - no changes made"))
else:
self.stdout.write(self.style.SUCCESS(f"Total deleted: {total_deleted}"))

View File

@ -0,0 +1,22 @@
from django.core.management.base import BaseCommand
from django.db import connection
class Command(BaseCommand):
help = "Copy ChartRecord data from scrobbles to charts app"
def handle(self, *args, **options):
with connection.cursor() as cursor:
cursor.execute(
"""
INSERT OR IGNORE INTO charts_chartrecord
(created, modified, user_id, rank, count, year, month, week, day,
video_id, series_id, artist_id, track_id, period_start, period_end)
SELECT created, modified, user_id, rank, count, year, month, week, day,
video_id, series_id, artist_id, track_id, period_start, period_end
FROM scrobbles_chartrecord
"""
)
self.stdout.write(
self.style.SUCCESS("Copied ChartRecord data to charts app")
)

View File

@ -0,0 +1,239 @@
# Generated by Django 4.2.29 on 2026-03-21 19:34
import django.db.models.deletion
import django_extensions.db.fields
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("boardgames", "0013_boardgame_publishers"),
("locations", "0008_remove_geolocation_run_time_seconds_and_more"),
("podcasts", "0018_remove_podcastepisode_run_time_seconds_and_more"),
("music", "0029_remove_track_run_time_seconds_and_more"),
("videos", "0025_add_imdb_id_unique"),
("trails", "0006_remove_trail_run_time_seconds_and_more"),
("books", "0033_alter_book_issue_number_alter_book_volume_number"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("foods", "0005_food_nutrition_and_sources"),
]
operations = [
migrations.CreateModel(
name="ChartRecord",
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"
),
),
("year", models.IntegerField()),
("month", models.IntegerField(blank=True, null=True)),
("week", models.IntegerField(blank=True, null=True)),
("day", models.IntegerField(blank=True, null=True)),
("rank", models.IntegerField()),
("count", models.IntegerField(default=0)),
(
"album",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="music.album",
),
),
(
"artist",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="music.artist",
),
),
(
"board_game",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="boardgames.boardgame",
),
),
(
"book",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="books.book",
),
),
(
"food",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="foods.food",
),
),
(
"geo_location",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="locations.geolocation",
),
),
(
"podcast",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="podcasts.podcast",
),
),
(
"podcast_episode",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="podcasts.podcastepisode",
),
),
(
"track",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="music.track",
),
),
(
"trail",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="trails.trail",
),
),
(
"tv_series",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="videos.series",
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
(
"video",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="videos.video",
),
),
],
options={
"indexes": [
models.Index(
fields=["user", "year", "artist", "rank"],
name="charts_char_user_id_dc09e6_idx",
),
models.Index(
fields=["user", "year", "album", "rank"],
name="charts_char_user_id_218be5_idx",
),
models.Index(
fields=["user", "year", "track", "rank"],
name="charts_char_user_id_db95f8_idx",
),
models.Index(
fields=["user", "year", "tv_series", "rank"],
name="charts_char_user_id_2a16e9_idx",
),
models.Index(
fields=["user", "year", "video", "rank"],
name="charts_char_user_id_717866_idx",
),
models.Index(
fields=["user", "year", "podcast", "rank"],
name="charts_char_user_id_c79e3b_idx",
),
models.Index(
fields=["user", "year", "board_game", "rank"],
name="charts_char_user_id_73fbd2_idx",
),
models.Index(
fields=["user", "year", "trail", "rank"],
name="charts_char_user_id_ad3c30_idx",
),
models.Index(
fields=["user", "year", "geo_location", "rank"],
name="charts_char_user_id_245e01_idx",
),
models.Index(
fields=["user", "year", "food", "rank"],
name="charts_char_user_id_f9fe30_idx",
),
models.Index(
fields=["user", "year", "book", "rank"],
name="charts_char_user_id_e0321e_idx",
),
models.Index(
fields=["user", "year", "week", "artist", "rank"],
name="charts_char_user_id_18f06e_idx",
),
models.Index(
fields=["user", "year", "week", "tv_series", "rank"],
name="charts_char_user_id_8dcc60_idx",
),
models.Index(
fields=["user", "year", "month", "artist", "rank"],
name="charts_char_user_id_3ef8c3_idx",
),
models.Index(
fields=["user", "year", "month", "tv_series", "rank"],
name="charts_char_user_id_dd54d7_idx",
),
],
},
),
]

View File

@ -0,0 +1,152 @@
import calendar
from django.contrib.auth import get_user_model
from django.db import models
from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
User = get_user_model()
BNULL = {"blank": True, "null": True}
class ChartRecord(TimeStampedModel):
"""Stores rankings for a user/media/time period combination.
Example entries:
- Bjork was #3 artist in 2022
- Homogenic was #10 album in April 2025
- The Office was #1 TV series in Week #34 2024
"""
user = models.ForeignKey(User, on_delete=models.CASCADE, **BNULL)
year = models.IntegerField()
month = models.IntegerField(**BNULL)
week = models.IntegerField(**BNULL)
day = models.IntegerField(**BNULL)
artist = models.ForeignKey("music.Artist", on_delete=models.CASCADE, **BNULL)
album = models.ForeignKey("music.Album", on_delete=models.CASCADE, **BNULL)
track = models.ForeignKey("music.Track", on_delete=models.CASCADE, **BNULL)
tv_series = models.ForeignKey("videos.Series", on_delete=models.CASCADE, **BNULL)
video = models.ForeignKey("videos.Video", on_delete=models.CASCADE, **BNULL)
podcast = models.ForeignKey("podcasts.Podcast", on_delete=models.CASCADE, **BNULL)
podcast_episode = models.ForeignKey(
"podcasts.PodcastEpisode", on_delete=models.CASCADE, **BNULL
)
board_game = models.ForeignKey(
"boardgames.BoardGame", on_delete=models.CASCADE, **BNULL
)
trail = models.ForeignKey("trails.Trail", on_delete=models.CASCADE, **BNULL)
geo_location = models.ForeignKey(
"locations.GeoLocation", on_delete=models.CASCADE, **BNULL
)
food = models.ForeignKey("foods.Food", on_delete=models.CASCADE, **BNULL)
book = models.ForeignKey("books.Book", on_delete=models.CASCADE, **BNULL)
rank = models.IntegerField()
count = models.IntegerField(default=0)
class Meta:
indexes = [
models.Index(fields=["user", "year", "artist", "rank"]),
models.Index(fields=["user", "year", "album", "rank"]),
models.Index(fields=["user", "year", "track", "rank"]),
models.Index(fields=["user", "year", "tv_series", "rank"]),
models.Index(fields=["user", "year", "video", "rank"]),
models.Index(fields=["user", "year", "podcast", "rank"]),
models.Index(fields=["user", "year", "board_game", "rank"]),
models.Index(fields=["user", "year", "trail", "rank"]),
models.Index(fields=["user", "year", "geo_location", "rank"]),
models.Index(fields=["user", "year", "food", "rank"]),
models.Index(fields=["user", "year", "book", "rank"]),
models.Index(fields=["user", "year", "week", "artist", "rank"]),
models.Index(fields=["user", "year", "week", "tv_series", "rank"]),
models.Index(fields=["user", "year", "month", "artist", "rank"]),
models.Index(fields=["user", "year", "month", "tv_series", "rank"]),
]
@property
def media_obj(self):
for attr in [
"artist",
"album",
"track",
"tv_series",
"video",
"podcast",
"podcast_episode",
"board_game",
"trail",
"geo_location",
"food",
"book",
]:
obj = getattr(self, attr)
if obj:
return obj
return None
@property
def media_type(self):
mapping = {
"artist": "Artist",
"album": "Album",
"track": "Track",
"tv_series": "TV Series",
"video": "Video",
"podcast": "Podcast",
"podcast_episode": "Podcast Episode",
"board_game": "Board Game",
"trail": "Trail",
"geo_location": "Location",
"food": "Food",
"book": "Book",
}
for attr, name in mapping.items():
if getattr(self, attr) is not None:
return name
return None
@property
def period_str(self) -> str:
if self.day:
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:
if self.day:
return "day"
if self.week:
return "week"
if self.month:
return "month"
return "year"
def __str__(self):
obj = self.media_obj
obj_name = str(obj) if obj else "Unknown"
return f"#{self.rank} {obj_name} in {self.period_str}"
def get_absolute_url(self):
return reverse("charts:charts-home")
@property
def chart_url(self) -> str:
url = reverse("charts:charts-home")
date_str = str(self.year)
if self.week:
date_str = f"{self.year}-W{self.week}"
elif self.month:
date_str = f"{self.year}-{self.month:02d}"
return f"{url}?date={date_str}"
@property
def rank_emoji(self) -> str:
emojis = {1: "🥇", 2: "🥈", 3: "🥉"}
return emojis.get(self.rank, f"#{self.rank}")

View File

@ -0,0 +1,357 @@
{% extends "base_list.html" %}
{% load chart_tags %}
{% block title %}Charts{% endblock %}
{% block head_extra %}
<style>
.container { margin-bottom:100px; }
h2 { padding-top:20px; }
.image-wrapper {
contain: content;
}
.image-wrapper :hover {
background:rgba(0,0,0,0.3);
}
.caption {
position: fixed;
top: 5px;
left: 5px;
padding: 3px;
font-size: 90%;
color:white;
background:rgba(0,0,0,0.4);
}
.caption-medium {
position: fixed;
top: 5px;
left: 5px;
padding: 3px;
font-size: 75%;
color:white;
background:rgba(0,0,0,0.4);
}
.caption-small {
position: fixed;
top: 5px;
left: 5px;
padding: 3px;
font-size: 60%;
color:white;
background:rgba(0,0,0,0.4);
}
</style>
{% endblock %}
{% block lists %}
<div class="row mb-3">
<div class="col-auto">
{% if prev_period %}
<a href="{{ prev_period }}" class="btn btn-outline-secondary btn-sm">← Previous</a>
{% endif %}
</div>
<div class="col-auto text-center">
<span class="badge bg-secondary">{{ period_type|title }}</span>
</div>
<div class="col-auto">
{% if next_period %}
<a href="{{ next_period }}" class="btn btn-outline-secondary btn-sm">Next →</a>
{% endif %}
</div>
<div class="col-auto ms-auto">
{% if chart_years %}
<div class="btn-group btn-group-sm">
{% for y in chart_years|slice:":5" %}
<a href="/charts/?date={{ y }}" class="btn btn-sm {% if year == y %}btn-primary{% else %}btn-outline-secondary{% endif %}">{{ y }}</a>
{% endfor %}
</div>
{% endif %}
</div>
</div>
<div class="mb-2">
<small class="text-muted me-2">Months:</small>
{% for m in "1,2,3,4,5,6,7,8,9,10,11,12"|split:"," %}
<a href="/charts/?date={{ year }}-{{ m|stringformat:"02d" }}" class="btn btn-xs {% if month == m %}btn-primary{% else %}btn-outline-secondary{% endif %}" style="padding:1px 4px;font-size:10px;">{{ m }}</a>
{% endfor %}
<span class="text-muted mx-2">|</span>
<small class="text-muted me-2">Weeks:</small>
{% for w in "1,4,8,13,17,21,26,30,34,39,43,47,52"|split:"," %}
<a href="/charts/?date={{ year }}-W{{ w|stringformat:"02d" }}" class="btn btn-xs {% if week == w %}btn-primary{% else %}btn-outline-secondary{% endif %}" style="padding:1px 3px;font-size:9px;">{{ w }}</a>
{% 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 %}
<div class="row">
{% if charts.artist %}
<div class="col-md">
<h2>🎤 Top Artists</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Artist</th>
<th scope="col">Scrobbles</th>
</tr>
</thead>
<tbody>
{% for chart in charts.artist %}
<tr>
<td>{{chart.rank}}</td>
<td><a href="{{chart.artist.get_absolute_url}}">{{chart.artist.name}}</a></td>
<td>{{chart.count}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if charts.track %}
<div class="col-md">
<h2>🎵 Top Tracks</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Track</th>
<th scope="col">Scrobbles</th>
</tr>
</thead>
<tbody>
{% for chart in charts.track %}
<tr>
<td>{{chart.rank}}</td>
<td><a href="{{chart.track.get_absolute_url}}">{{chart.track.title}}</a></td>
<td>{{chart.count}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if charts.tv_series %}
<div class="col-md">
<h2>📺 Top TV Shows</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">TV Show</th>
<th scope="col">Episodes Watched</th>
</tr>
</thead>
<tbody>
{% for chart in charts.tv_series %}
<tr>
<td>{{chart.rank}}</td>
<td><a href="{{chart.tv_series.get_absolute_url}}">{{chart.tv_series.name}}</a></td>
<td>{{chart.count}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if charts.album %}
<div class="col-md">
<h2>💿 Top Albums</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Album</th>
<th scope="col">Scrobbles</th>
</tr>
</thead>
<tbody>
{% for chart in charts.album %}
<tr>
<td>{{chart.rank}}</td>
<td><a href="{{chart.album.get_absolute_url}}">{{chart.album.title}}</a></td>
<td>{{chart.count}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if charts.video %}
<div class="col-md">
<h2>🎬 Top Videos</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Video</th>
<th scope="col">Plays</th>
</tr>
</thead>
<tbody>
{% for chart in charts.video %}
<tr>
<td>{{chart.rank}}</td>
<td><a href="{{chart.video.get_absolute_url}}">{{chart.video.title}}</a></td>
<td>{{chart.count}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if charts.board_game %}
<div class="col-md">
<h2>🎲 Top Board Games</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Board Game</th>
<th scope="col">Plays</th>
</tr>
</thead>
<tbody>
{% for chart in charts.board_game %}
<tr>
<td>{{chart.rank}}</td>
<td><a href="{{chart.board_game.get_absolute_url}}">{{chart.board_game.title}}</a></td>
<td>{{chart.count}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if charts.book %}
<div class="col-md">
<h2>📚 Top Books</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Book</th>
<th scope="col">Pages Read</th>
</tr>
</thead>
<tbody>
{% for chart in charts.book %}
<tr>
<td>{{chart.rank}}</td>
<td><a href="{{chart.book.get_absolute_url}}">{{chart.book.title}}</a></td>
<td>{{chart.count}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if charts.food %}
<div class="col-md">
<h2>🍽️ Top Foods</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Food</th>
<th scope="col">Times Eaten</th>
</tr>
</thead>
<tbody>
{% for chart in charts.food %}
<tr>
<td>{{chart.rank}}</td>
<td><a href="{{chart.food.get_absolute_url}}">{{chart.food.title}}</a></td>
<td>{{chart.count}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if charts.podcast %}
<div class="col-md">
<h2>🎙️ Top Podcasts</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Podcast</th>
<th scope="col">Episodes</th>
</tr>
</thead>
<tbody>
{% for chart in charts.podcast %}
<tr>
<td>{{chart.rank}}</td>
<td><a href="{{chart.podcast.get_absolute_url}}">{{chart.podcast.title}}</a></td>
<td>{{chart.count}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if charts.trail %}
<div class="col-md">
<h2>🥾 Top Trails</h2>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Rank</th>
<th scope="col">Trail</th>
<th scope="col">Hikes</th>
</tr>
</thead>
<tbody>
{% for chart in charts.trail %}
<tr>
<td>{{chart.rank}}</td>
<td><a href="{{chart.trail.get_absolute_url}}">{{chart.trail.title}}</a></td>
<td>{{chart.count}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,15 @@
from django import template
register = template.Library()
@register.filter
def get_item(dictionary, key):
if isinstance(dictionary, dict):
return dictionary.get(key)
return None
@register.filter
def split(value, arg):
return value.split(arg)

View File

@ -0,0 +1,268 @@
import datetime
import pytest
from django.contrib.auth import get_user_model
from django.utils import timezone
from charts.models import ChartRecord
from charts.utils import build_charts
User = get_user_model()
@pytest.fixture
def user(db):
return User.objects.create_user(username="charttester", password="testpass123")
@pytest.fixture
def scrobble_data(user, db):
from scrobbles.models import Scrobble
from music.models import Track, Artist, Album
artist = Artist.objects.create(name="Test Artist")
album = Album.objects.create(name="Test Album", album_artist=artist)
track = Track.objects.create(title="Test Track", artist=artist, album=album)
return {
"user": user,
"track": track,
}
@pytest.mark.django_db
def test_build_daily_charts_creates_daily_record(user, scrobble_data):
from scrobbles.models import Scrobble
Scrobble.objects.create(
user=user,
track=scrobble_data["track"],
played_to_completion=True,
timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 10, 0)),
)
build_charts(user, year=2024, month=3, day=15)
daily = ChartRecord.objects.filter(
user=user,
year=2024,
month=3,
day=15,
week__isnull=True,
track__isnull=False,
)
assert daily.exists()
assert daily.first().track == scrobble_data["track"]
@pytest.mark.django_db
def test_build_monthly_charts_creates_monthly_record(user, scrobble_data):
from scrobbles.models import Scrobble
Scrobble.objects.create(
user=user,
track=scrobble_data["track"],
played_to_completion=True,
timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 10, 0)),
)
build_charts(user, year=2024, month=3)
monthly = ChartRecord.objects.filter(
user=user,
year=2024,
month=3,
week__isnull=True,
day__isnull=True,
track__isnull=False,
)
assert monthly.exists()
assert monthly.first().track == scrobble_data["track"]
@pytest.mark.django_db
def test_build_weekly_charts_creates_weekly_record(user, scrobble_data):
from scrobbles.models import Scrobble
Scrobble.objects.create(
user=user,
track=scrobble_data["track"],
played_to_completion=True,
timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 10, 0)),
)
build_charts(user, year=2024, week=11)
weekly = ChartRecord.objects.filter(
user=user,
year=2024,
week=11,
month__isnull=True,
day__isnull=True,
track__isnull=False,
)
assert weekly.exists()
assert weekly.first().track == scrobble_data["track"]
@pytest.mark.django_db
def test_build_yearly_charts_creates_yearly_record(user, scrobble_data):
from scrobbles.models import Scrobble
Scrobble.objects.create(
user=user,
track=scrobble_data["track"],
played_to_completion=True,
timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 10, 0)),
)
build_charts(user, year=2024)
yearly = ChartRecord.objects.filter(
user=user,
year=2024,
month__isnull=True,
week__isnull=True,
day__isnull=True,
track__isnull=False,
)
assert yearly.exists()
assert yearly.first().track == scrobble_data["track"]
@pytest.mark.django_db
def test_build_charts_ranks_by_count(user, scrobble_data):
from scrobbles.models import Scrobble
from music.models import Track, Artist
track1 = scrobble_data["track"]
track2 = Track.objects.create(
title="Test Track 2",
artist=track1.artist,
album=track1.album,
)
Scrobble.objects.create(
user=user,
track=track1,
played_to_completion=True,
timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 10, 0)),
)
Scrobble.objects.create(
user=user,
track=track1,
played_to_completion=True,
timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 11, 0)),
)
Scrobble.objects.create(
user=user,
track=track2,
played_to_completion=True,
timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 12, 0)),
)
build_charts(user, year=2024, month=3, day=15)
daily = ChartRecord.objects.filter(
user=user,
year=2024,
month=3,
day=15,
track__isnull=False,
).order_by("rank")
assert daily.count() == 2
assert daily[0].track == track1
assert daily[0].rank == 1
assert daily[0].count == 2
assert daily[1].track == track2
assert daily[1].rank == 2
assert daily[1].count == 1
@pytest.mark.django_db
def test_build_daily_charts_deletes_existing_daily_record(user, scrobble_data):
from scrobbles.models import Scrobble
from music.models import Track, Artist
track1 = scrobble_data["track"]
track2 = Track.objects.create(
title="Test Track 2",
artist=track1.artist,
album=track1.album,
)
Scrobble.objects.create(
user=user,
track=track1,
played_to_completion=True,
timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 10, 0)),
)
build_charts(user, year=2024, month=3, day=15)
assert ChartRecord.objects.filter(
user=user, year=2024, month=3, day=15, track=track1
).exists()
Scrobble.objects.create(
user=user,
track=track2,
played_to_completion=True,
timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 11, 0)),
)
build_charts(user, year=2024, month=3, day=15)
daily = ChartRecord.objects.filter(
user=user,
year=2024,
month=3,
day=15,
track__isnull=False,
)
assert daily.count() == 2
assert daily.filter(track=track1).exists()
assert daily.filter(track=track2).exists()
@pytest.mark.django_db
def test_build_monthly_charts_deletes_existing_monthly_record(user, scrobble_data):
from scrobbles.models import Scrobble
from music.models import Track, Artist
track1 = scrobble_data["track"]
track2 = Track.objects.create(
title="Test Track 2",
artist=track1.artist,
album=track1.album,
)
Scrobble.objects.create(
user=user,
track=track1,
played_to_completion=True,
timestamp=timezone.make_aware(datetime.datetime(2024, 3, 15, 10, 0)),
)
build_charts(user, year=2024, month=3)
ChartRecord.objects.filter(user=user, year=2024, month=3, track=track1).delete()
Scrobble.objects.create(
user=user,
track=track2,
played_to_completion=True,
timestamp=timezone.make_aware(datetime.datetime(2024, 3, 16, 11, 0)),
)
build_charts(user, year=2024, month=3)
monthly = ChartRecord.objects.filter(
user=user,
year=2024,
month=3,
week__isnull=True,
day__isnull=True,
track__isnull=False,
)
assert monthly.count() == 2

View File

@ -0,0 +1,18 @@
from charts.views import (
BandcampTracksView,
BirdsChartView,
ChartDetailView,
ChartRecordView,
SpotifyTracksView,
)
from django.urls import path
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

@ -0,0 +1,419 @@
import calendar
import datetime
import logging
from typing import Optional
import pytz
from django.apps import apps
from django.conf import settings
from django.db.models import Count, Q
from django.utils import timezone
logger = logging.getLogger(__name__)
def get_time_filter(
year: Optional[int],
month: Optional[int],
week: Optional[int],
day: Optional[int],
user,
):
"""Build a Q filter for scrobble timestamps based on time period."""
if not year:
return Q()
if user and user.is_authenticated:
tz = pytz.timezone(user.profile.timezone)
else:
tz = pytz.timezone(settings.TIME_ZONE)
if day and month:
start = timezone.make_aware(datetime.datetime(year, month, day, 0, 0, 0))
end = timezone.make_aware(datetime.datetime(year, month, day, 23, 59, 59))
elif week:
period = datetime.date.fromisocalendar(year, week, 1)
start = timezone.make_aware(
datetime.datetime.combine(period, datetime.time.min)
)
end = start + datetime.timedelta(days=6, seconds=86399)
elif month:
last_day = calendar.monthrange(year, month)[1]
start = timezone.make_aware(datetime.datetime(year, month, 1))
end = timezone.make_aware(datetime.datetime(year, month, last_day, 23, 59, 59))
else:
start = timezone.make_aware(datetime.datetime(year, 1, 1))
end = timezone.make_aware(datetime.datetime(year, 12, 31, 23, 59, 59))
return Q(timestamp__gte=start, timestamp__lte=end)
def build_charts(
user,
year: Optional[int] = None,
month: Optional[int] = None,
week: Optional[int] = None,
day: Optional[int] = None,
media_types: Optional[list] = None,
):
"""Build chart records for all or specified media types using upsert."""
ChartRecord = apps.get_model("charts", "ChartRecord")
Scrobble = apps.get_model("scrobbles", "Scrobble")
if media_types is None:
media_types = [
"artist",
"album",
"track",
"tv_series",
"video",
"podcast",
"podcast_episode",
"board_game",
"trail",
"food",
"book",
]
period_filter = Q(year=year)
if day and month:
period_filter = (
period_filter & Q(day=day) & Q(month=month) & Q(week__isnull=True)
)
elif week:
period_filter = (
period_filter & Q(week=week) & Q(day__isnull=True) & Q(month__isnull=True)
)
elif month:
period_filter = (
period_filter & Q(month=month) & Q(week__isnull=True) & Q(day__isnull=True)
)
else:
period_filter = (
period_filter
& Q(month__isnull=True)
& Q(week__isnull=True)
& Q(day__isnull=True)
)
time_filter = get_time_filter(year, month, week, day, user)
base_qs = Scrobble.objects.filter(user=user, played_to_completion=True)
if time_filter:
base_qs = base_qs.filter(time_filter)
media_config = {
"artist": {
"filter": Q(track__isnull=False) & Q(track__artists__isnull=False),
"values": "track__artists",
"annotate": Count("id", distinct=True),
},
"album": {
"filter": Q(track__isnull=False) & Q(track__album__isnull=False),
"values": "track__album",
"annotate": Count("id", distinct=True),
},
"track": {
"filter": Q(track__isnull=False),
"values": "track",
"annotate": Count("id", distinct=True),
},
"tv_series": {
"filter": Q(video__isnull=False) & Q(video__tv_series__isnull=False),
"values": "video__tv_series",
"annotate": Count("id", distinct=True),
},
"video": {
"filter": Q(video__isnull=False),
"values": "video",
"annotate": Count("id", distinct=True),
},
"podcast": {
"filter": Q(podcast_episode__isnull=False)
& Q(podcast_episode__podcast__isnull=False),
"values": "podcast_episode__podcast",
"annotate": Count("id", distinct=True),
},
"podcast_episode": {
"filter": Q(podcast_episode__isnull=False),
"values": "podcast_episode",
"annotate": Count("id", distinct=True),
},
"board_game": {
"filter": Q(board_game__isnull=False),
"values": "board_game",
"annotate": Count("id", distinct=True),
},
"trail": {
"filter": Q(trail__isnull=False),
"values": "trail",
"annotate": Count("id", distinct=True),
},
"geo_location": {
"filter": Q(geo_location__isnull=False),
"values": "geo_location",
"annotate": Count("id", distinct=True),
},
"food": {
"filter": Q(food__isnull=False),
"values": "food",
"annotate": Count("id", distinct=True),
},
"book": {
"filter": Q(book__isnull=False),
"values": "book",
"annotate": Count("id", distinct=True),
},
}
for media_type in media_types:
if media_type not in media_config:
continue
config = media_config[media_type]
results = list(
base_qs.filter(config["filter"])
.values(config["values"])
.annotate(scrobble_count=config["annotate"])
.order_by("-scrobble_count")
)
if not results:
continue
unique_counts = sorted(set(r["scrobble_count"] for r in results), reverse=True)
ranks = {count: rank for rank, count in enumerate(unique_counts, start=1)}
media_field = f"{media_type}_id"
records_to_create = []
records_to_update = []
existing = ChartRecord.objects.filter(
period_filter, user=user, **{media_field + "__isnull": False}
)
existing_by_media_id = {getattr(r, media_field): r for r in existing}
found_media_ids = set()
for result in results:
media_id = result[config["values"]]
if media_id is None:
continue
found_media_ids.add(media_id)
chart_record_data = {
"user_id": user.id,
"year": year,
"month": month,
"week": week,
"day": day,
"rank": ranks[result["scrobble_count"]],
"count": result["scrobble_count"],
}
chart_record_data[media_field] = media_id
if media_id in existing_by_media_id:
existing_record = existing_by_media_id[media_id]
existing_record.rank = chart_record_data["rank"]
existing_record.count = chart_record_data["count"]
records_to_update.append(existing_record)
else:
records_to_create.append(ChartRecord(**chart_record_data))
ids_to_delete = [
r.id for r in existing if getattr(r, media_field) not in found_media_ids
]
if ids_to_delete:
ChartRecord.objects.filter(id__in=ids_to_delete).delete()
if records_to_update:
ChartRecord.objects.bulk_update(
records_to_update, ["rank", "count"], batch_size=500
)
if records_to_create:
ChartRecord.objects.bulk_create(records_to_create, batch_size=500)
logger.info(
f"Built {len(records_to_create)} new, {len(records_to_update)} updated "
f"chart records for {media_type}, period "
f"{year}-{month or ''}-{week or ''}-{day or ''} for {user}"
)
def build_yesterdays_charts(user, media_types: Optional[list] = None) -> None:
"""Build charts for yesterday's date."""
tz = pytz.timezone(settings.TIME_ZONE)
if user and user.is_authenticated:
tz = pytz.timezone(user.profile.timezone)
now = timezone.now().astimezone(tz)
yesterday = now - datetime.timedelta(days=1)
logger.info(f"Building charts for {yesterday.date()} for {user}")
build_charts(
user=user,
year=yesterday.year,
month=yesterday.month,
day=yesterday.day,
media_types=media_types,
)
def build_weekly_charts(
user, year: int, week: int, media_types: Optional[list] = None
) -> None:
"""Build weekly charts for a specific ISO week."""
build_charts(
user=user,
year=year,
week=week,
media_types=media_types,
)
def build_monthly_charts(
user, year: int, month: int, media_types: Optional[list] = None
) -> None:
"""Build monthly charts for a specific month."""
build_charts(
user=user,
year=year,
month=month,
media_types=media_types,
)
def build_yearly_charts(user, year: int, media_types: Optional[list] = None) -> None:
"""Build yearly charts for a specific year."""
build_charts(
user=user,
year=year,
media_types=media_types,
)
def build_all_historical_charts(user, media_types: Optional[list] = None) -> None:
"""Build all historical charts for a user."""
Scrobble = apps.get_model("scrobbles", "Scrobble")
first_scrobble = (
Scrobble.objects.filter(user=user, played_to_completion=True)
.order_by("timestamp")
.first()
)
if not first_scrobble:
logger.info(f"No scrobbles found for {user}")
return
now = timezone.now()
current = first_scrobble.timestamp.date()
last_year = None
last_week = None
while current <= now.date():
year = current.year
month = current.month
day = current.day
week = current.isocalendar()[1]
build_monthly_charts(user, year, month, media_types)
build_daily_charts(user, year, month, day, media_types)
if last_week != week:
build_weekly_charts(user, year, week, media_types)
last_week = week
if last_year != year:
build_yearly_charts(user, year, media_types)
last_year = year
current += datetime.timedelta(days=1)
def build_daily_charts(
user, year: int, month: int, day: int, media_types: Optional[list] = None
) -> None:
"""Build daily charts for a specific day."""
build_charts(
user=user,
year=year,
month=month,
day=day,
media_types=media_types,
)
def build_charts_since(user, media_types: Optional[list] = None) -> None:
"""Build/update charts starting from the last known ChartRecord date."""
ChartRecord = apps.get_model("charts", "ChartRecord")
Scrobble = apps.get_model("scrobbles", "Scrobble")
latest_daily = (
ChartRecord.objects.filter(user=user, day__isnull=False)
.order_by("-year", "-month", "-day")
.first()
)
if latest_daily:
start_date = datetime.date(
latest_daily.year, latest_daily.month, latest_daily.day
)
logger.info(
f"Building charts since {start_date} for {user} (last known record)"
)
else:
first_scrobble = (
Scrobble.objects.filter(user=user, played_to_completion=True)
.order_by("timestamp")
.first()
)
if not first_scrobble:
logger.info(f"No scrobbles found for {user}")
return
start_date = first_scrobble.timestamp.date()
logger.info(
f"No existing charts found for {user}, building from "
f"first scrobble: {start_date}"
)
start_date -= datetime.timedelta(days=1)
last_week = start_date.isocalendar()[1]
last_month = start_date.month
last_year = start_date.year
now = timezone.now()
current = start_date + datetime.timedelta(days=1)
while current <= now.date():
year = current.year
month = current.month
day = current.day
week = current.isocalendar()[1]
if last_month != month:
build_monthly_charts(user, year, month, media_types)
last_month = month
build_daily_charts(user, year, month, day, media_types)
if last_week != week:
build_weekly_charts(user, year, week, media_types)
last_week = week
if last_year != year:
build_yearly_charts(user, year, media_types)
last_year = year
current += datetime.timedelta(days=1)
def rebuild_all_charts(user, media_types: Optional[list] = None) -> None:
"""Delete all existing chart records for a user and rebuild them."""
ChartRecord = apps.get_model("charts", "ChartRecord")
deleted_count = ChartRecord.objects.filter(user=user).delete()[0]
logger.info(f"Deleted {deleted_count} existing chart records for {user}")
build_all_historical_charts(user, media_types)

View File

@ -0,0 +1,913 @@
import calendar
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
from scrobbles.models import Scrobble
MEDIA_TYPE_FILTERS = {
"artist": Q(artist__isnull=False),
"album": Q(album__isnull=False),
"track": Q(track__isnull=False),
"tv_series": Q(tv_series__isnull=False),
"video": Q(video__isnull=False),
"podcast": Q(podcast__isnull=False),
"podcast_episode": Q(podcast_episode__isnull=False),
"board_game": Q(board_game__isnull=False),
"trail": Q(trail__isnull=False),
"geo_location": Q(geo_location__isnull=False),
"food": Q(food__isnull=False),
"book": Q(book__isnull=False),
}
class ChartRecordView(TemplateView):
template_name = "charts/chart_index.html"
def get_charts(
self,
user,
media_type: str,
year=None,
month=None,
week=None,
day=None,
limit=20,
):
params = {"user": user}
if year is not None:
params["year"] = year
if month is not None:
params["month"] = month
if week is not None:
params["week"] = week
if day is not None:
params["day"] = day
filter_key = media_type.lower()
media_filter = MEDIA_TYPE_FILTERS.get(filter_key, Q())
return ChartRecord.objects.filter(media_filter, **params).order_by("rank")[
:limit
]
def get_charts_for_period(
self,
user,
media_type: str,
year=None,
month=None,
week=None,
day=None,
limit=20,
):
"""Get charts for a specific period, properly filtering by period type."""
params = {"user": user}
if year is not None:
params["year"] = year
if month is not None:
params["month"] = month
if week is not None:
params["week"] = week
if day is not None:
params["day"] = day
filter_key = media_type.lower()
media_filter = MEDIA_TYPE_FILTERS.get(filter_key, 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)
return qs.order_by("rank")[:limit]
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
user = self.request.user
chart_type = self.request.GET.get("chart_type", "standard")
context["chart_type"] = chart_type
date_param = self.request.GET.get("date")
now = timezone.now()
if user.is_authenticated:
now = now_user_timezone(user.profile)
today = now.date()
current_year = today.year
current_month = today.month
current_week = today.isocalendar()[1]
current_day = today.day
context["current_year"] = current_year
context["current_month"] = current_month
context["current_week"] = current_week
context["current_day"] = current_day
if chart_type == "maloja":
context["chart_keys"] = {
"today": "Today",
"week": "This Week",
"month": "This Month",
"year": "This Year",
"all": "All Time",
}
context["maloja_charts"] = {
"artist": {
"today": list(
self.get_charts_for_period(
user,
"artist",
year=current_year,
month=current_month,
day=current_day,
)
),
"week": list(
self.get_charts_for_period(
user,
"artist",
year=current_year,
week=current_week,
)
),
"month": list(
self.get_charts_for_period(
user,
"artist",
year=current_year,
month=current_month,
)
),
"year": list(
self.get_charts_for_period(user, "artist", year=current_year)
),
"all": list(self.get_charts_for_period(user, "artist")),
},
"track": {
"today": list(
self.get_charts_for_period(
user,
"track",
year=current_year,
month=current_month,
day=current_day,
)
),
"week": list(
self.get_charts_for_period(
user, "track", year=current_year, week=current_week
)
),
"month": list(
self.get_charts_for_period(
user,
"track",
year=current_year,
month=current_month,
)
),
"year": list(
self.get_charts_for_period(user, "track", year=current_year)
),
"all": list(self.get_charts_for_period(user, "track")),
},
"tv_series": {
"today": list(
self.get_charts_for_period(
user,
"tv_series",
year=current_year,
month=current_month,
day=current_day,
)
),
"week": list(
self.get_charts_for_period(
user,
"tv_series",
year=current_year,
week=current_week,
)
),
"month": list(
self.get_charts_for_period(
user,
"tv_series",
year=current_year,
month=current_month,
)
),
"year": list(
self.get_charts_for_period(user, "tv_series", year=current_year)
),
"all": list(self.get_charts_for_period(user, "tv_series")),
},
}
return context
if not date_param:
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
context["charts"] = {
"artist": list(
self.get_charts_for_period(
user, "artist", year=current_year, limit=20
)
),
"album": list(
self.get_charts_for_period(
user, "album", year=current_year, limit=20
)
),
"track": list(
self.get_charts_for_period(
user, "track", year=current_year, limit=20
)
),
"tv_series": list(
self.get_charts_for_period(
user, "tv_series", year=current_year, limit=20
)
),
"video": list(
self.get_charts_for_period(
user, "video", year=current_year, limit=20
)
),
"board_game": list(
self.get_charts_for_period(
user, "board_game", year=current_year, limit=20
)
),
"book": list(
self.get_charts_for_period(
user, "book", year=current_year, limit=20
)
),
"food": list(
self.get_charts_for_period(
user, "food", year=current_year, limit=20
)
),
"podcast": list(
self.get_charts_for_period(
user, "podcast", year=current_year, limit=20
)
),
"trail": list(
self.get_charts_for_period(
user, "trail", year=current_year, limit=20
)
),
}
else:
parts = date_param.split("-")
year = int(parts[0])
week = None
month = None
day = None
if len(parts) >= 2 and parts[1].startswith("W"):
week = int(parts[1].lstrip("W"))
elif len(parts) >= 2 and parts[1]:
try:
month = int(parts[1])
except ValueError:
pass
if len(parts) >= 3:
if parts[2].startswith("W"):
week = int(parts[2].lstrip("W"))
elif not parts[2].startswith("W"):
day = int(parts[2])
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
period_str = str(year)
if month:
period_str = f"{calendar.month_name[month]} {period_str}"
if week:
period_str = f"Week {week}, {period_str}"
if day:
period_str = f"{calendar.month_name[month]} {day}, {year}"
context["period_str"] = period_str
context["charts"] = {
"artist": list(
self.get_charts_for_period(
user,
"artist",
year=year,
month=month,
week=week,
day=day,
)
),
"album": list(
self.get_charts_for_period(
user,
"album",
year=year,
month=month,
week=week,
day=day,
)
),
"track": list(
self.get_charts_for_period(
user,
"track",
year=year,
month=month,
week=week,
day=day,
)
),
"tv_series": list(
self.get_charts_for_period(
user,
"tv_series",
year=year,
month=month,
week=week,
day=day,
)
),
"video": list(
self.get_charts_for_period(
user,
"video",
year=year,
month=month,
week=week,
day=day,
)
),
"board_game": list(
self.get_charts_for_period(
user,
"board_game",
year=year,
month=month,
week=week,
day=day,
)
),
"book": list(
self.get_charts_for_period(
user,
"book",
year=year,
month=month,
week=week,
day=day,
)
),
"food": list(
self.get_charts_for_period(
user,
"food",
year=year,
month=month,
week=week,
day=day,
)
),
"podcast": list(
self.get_charts_for_period(
user,
"podcast",
year=year,
month=month,
week=week,
day=day,
)
),
"trail": list(
self.get_charts_for_period(
user,
"trail",
year=year,
month=month,
week=week,
day=day,
)
),
}
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)
context["next_period"] = self.get_next_period_url(user)
return context
def get_available_years(self, user):
return list(
ChartRecord.objects.filter(user=user)
.values_list("year", flat=True)
.distinct()
.order_by("-year")
)
def get_period_type(self):
date_param = self.request.GET.get("date")
if not date_param:
return "current"
parts = date_param.split("-")
if len(parts) >= 3 and not parts[2].startswith("W"):
return "day"
if len(parts) >= 2 and parts[1].startswith("W"):
return "week"
if len(parts) >= 2:
return "month"
return "year"
def parse_date_params(self):
date_param = self.request.GET.get("date")
if not date_param:
return None, None, None, None
parts = date_param.split("-")
year = int(parts[0])
week = None
month = None
day = None
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])
return year, month, week, day
def get_prev_period_url(self, user):
period_type = self.get_period_type()
if period_type == "current":
return None
year, month, week, day = self.parse_date_params()
if year is None:
return None
available_years = self.get_available_years(user)
if period_type == "day" and month and day:
if month == 1:
prev_month = 12
prev_year = year - 1
else:
prev_month = month - 1
prev_year = year
return f"/charts/?date={prev_year}-{prev_month:02d}"
elif period_type == "month" and month:
if month == 1:
prev_month = 12
prev_year = year - 1
else:
prev_month = month - 1
prev_year = year
return f"/charts/?date={prev_year}-{prev_month:02d}"
elif period_type == "week" and week:
if week == 1:
prev_week = 52
prev_year = year - 1
else:
prev_week = week - 1
prev_year = year
return f"/charts/?date={prev_year}-W{prev_week:02d}"
elif period_type == "year":
if year in available_years:
idx = available_years.index(year)
if idx + 1 < len(available_years):
return f"/charts/?date={available_years[idx + 1]}"
return f"/charts/?date={year - 1}"
return None
def get_next_period_url(self, user):
period_type = self.get_period_type()
if period_type == "current":
return None
year, month, week, day = self.parse_date_params()
if year is None:
return None
available_years = self.get_available_years(user)
if period_type == "day" and month and day:
if month == 12:
next_month = 1
next_year = year + 1
else:
next_month = month + 1
next_year = year
return f"/charts/?date={next_year}-{next_month:02d}"
elif period_type == "month" and month:
if month == 12:
next_month = 1
next_year = year + 1
else:
next_month = month + 1
next_year = year
return f"/charts/?date={next_year}-{next_month:02d}"
elif period_type == "week" and week:
if week == 52:
next_week = 1
next_year = year + 1
else:
next_week = week + 1
next_year = year
return f"/charts/?date={next_year}-W{next_week:02d}"
elif period_type == "year":
if year in available_years:
idx = available_years.index(year)
if idx > 0:
return f"/charts/?date={available_years[idx - 1]}"
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]
)
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["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

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