Compare commits

...

400 Commits
31 ... 42.0

Author SHA1 Message Date
89817110de [release] Bump to version 42.0
Some checks failed
deploy / test (push) Successful in 2m6s
build / test (push) Successful in 2m1s
deploy / build-and-deploy (push) Failing after 26s
- Add ability to add track to current Mopidy queue
2026-06-04 13:13:21 -04:00
ee01e3d8df [tracks] Add mopidy queue button
All checks were successful
build / test (push) Successful in 2m2s
2026-06-04 13:11:21 -04:00
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
a36abacfbd [widgets] Fix import of live_charts
All checks were successful
build & deploy / test (push) Successful in 1m58s
build & deploy / deploy (push) Successful in 22s
2026-03-21 14:17:20 -04:00
7666958974 [tests] Add efficiency tests for scrobbles 2026-03-21 14:09:21 -04:00
3a02bcad9d [scrobbles] Improve query efficency
All checks were successful
build & deploy / test (push) Successful in 2m1s
build & deploy / deploy (push) Successful in 23s
2026-03-21 14:05:30 -04:00
6a2cb4a881 [scrobbles] Add PersonScrobble junction table
Some checks failed
build & deploy / test (push) Successful in 6m3s
build & deploy / deploy (push) Failing after 19s
2026-03-21 13:44:37 -04:00
10af7190ab [people] Fix the scrobble count fn
All checks were successful
build & deploy / test (push) Successful in 3m46s
build & deploy / deploy (push) Successful in 23s
2026-03-21 12:15:16 -04:00
62c81c65ef [people] Count people scrobbles by board games too
All checks were successful
build & deploy / test (push) Successful in 1m54s
build & deploy / deploy (push) Successful in 24s
2026-03-21 12:00:45 -04:00
29a677142c [people] Fix if person_ids is None
All checks were successful
build & deploy / test (push) Successful in 1m56s
build & deploy / deploy (push) Successful in 21s
2026-03-21 11:38:36 -04:00
29cd2f8015 [people] Fix scrobble count on sqlite 2026-03-21 11:37:18 -04:00
5e835595c3 [people] Add boardgamearena_id field 2026-03-21 11:32:08 -04:00
b75b259bd9 [people] Add rough scrobble count field for sorting
All checks were successful
build & deploy / test (push) Successful in 1m56s
build & deploy / deploy (push) Successful in 22s
2026-03-21 11:26:41 -04:00
c1ef057ad2 [people] Clean up form and add link to settings
All checks were successful
build & deploy / test (push) Successful in 1m50s
build & deploy / deploy (push) Successful in 22s
2026-03-21 11:10:14 -04:00
8bfc5646f9 [people] Add a create/edit page for people 2026-03-21 11:07:37 -04:00
851c747a60 [lifeevents] Add life events to index and templates
All checks were successful
build & deploy / test (push) Successful in 1m55s
build & deploy / deploy (push) Successful in 27s
2026-03-21 10:55:06 -04:00
49b8b86249 [templates] Clean up widgets
All checks were successful
build & deploy / test (push) Successful in 1m53s
build & deploy / deploy (push) Successful in 25s
2026-03-20 18:37:11 -04:00
e8f120f85e [boardgames] Fix lookups using BGG library
All checks were successful
build & deploy / test (push) Successful in 1m52s
build & deploy / deploy (push) Successful in 22s
2026-03-20 14:14:28 -04:00
34a48c8c7b [widgets] Add widget css customization 2026-03-20 13:27:33 -04:00
4a18c01cdb [settings] Clean up settings widget 2026-03-20 13:21:10 -04:00
f4de4dbcb7 [settings] Fix form display and show widgets 2026-03-20 13:17:53 -04:00
64ec3c1cca [widgets] Add first run at widgets
All checks were successful
build & deploy / test (push) Successful in 2m36s
build & deploy / deploy (push) Successful in 29s
2026-03-20 13:11:32 -04:00
7a74f7f882 [tests] Add scrobble tests
Some checks failed
build & deploy / test (push) Successful in 1m49s
build & deploy / deploy (push) Failing after 2m5s
2026-03-19 21:17:27 -04:00
373db5563a [videos] IMDB blew us up again, switch to TMdb
Some checks failed
build & deploy / test (push) Has been cancelled
build & deploy / deploy (push) Has been cancelled
2026-03-19 21:16:12 -04:00
349b10904a [podcasts] Add domain link
All checks were successful
build & deploy / test (push) Successful in 1m41s
build & deploy / deploy (push) Successful in 20s
2026-03-18 11:36:13 -04:00
8e8d25aa1d [podcasts] Add aggregation to templates
All checks were successful
build & deploy / test (push) Successful in 1m40s
build & deploy / deploy (push) Successful in 20s
2026-03-18 11:07:45 -04:00
28db747b59 [podcasts] Add podcast aggregation widget 2026-03-18 10:55:27 -04:00
a0b867e20a [videos] Clean up metadata enrichment
All checks were successful
build & deploy / test (push) Successful in 1m48s
build & deploy / deploy (push) Successful in 21s
2026-03-18 10:48:14 -04:00
6d25e5f663 [boardgames] Fix manual import and add aggregations
Some checks failed
build & deploy / deploy (push) Has been cancelled
build & deploy / test (push) Has been cancelled
2026-03-18 10:47:47 -04:00
2e7d6364a2 [templates] Adding some aggregation widgets
All checks were successful
build & deploy / test (push) Successful in 1m45s
build & deploy / deploy (push) Successful in 26s
2026-03-18 10:23:52 -04:00
a8dc336950 [format] Blacken some stuff
All checks were successful
build & deploy / test (push) Successful in 1m39s
build & deploy / deploy (push) Successful in 21s
2026-03-17 13:26:54 -04:00
653aabfbb1 [scrobbles] Fix adding comments to webpages
Some checks failed
build & deploy / test (push) Successful in 1m40s
build & deploy / deploy (push) Has been cancelled
2026-03-17 13:24:50 -04:00
48e13af2e8 [scrobbles] Add note taking to webpages
Some checks failed
build & deploy / test (push) Successful in 1m51s
build & deploy / deploy (push) Failing after 2m2s
2026-03-17 11:42:58 -04:00
f1f615f0ed [project] Update task list 2026-03-14 13:58:56 -04:00
052d75ea26 [webpages] Fix bad title scrapes
All checks were successful
build & deploy / test (push) Successful in 1m40s
build & deploy / deploy (push) Successful in 21s
2026-03-14 13:58:21 -04:00
223de52a12 [webpages] Add images to webpages
All checks were successful
build & deploy / test (push) Successful in 1m41s
build & deploy / deploy (push) Successful in 22s
2026-03-14 13:54:27 -04:00
a5ff6abf56 [foods] Add catch for common domain exclusions
All checks were successful
build & deploy / test (push) Successful in 1m42s
build & deploy / deploy (push) Successful in 23s
2026-03-14 13:45:15 -04:00
74252b8759 [project] Add justfile
All checks were successful
build & deploy / test (push) Successful in 1m40s
build & deploy / deploy (push) Successful in 21s
2026-03-14 13:34:08 -04:00
466f33dd3c [foods] Fix bug in exception handling 2026-03-14 13:33:50 -04:00
c9abb22bfb [notifications] Show a finish link when appropriate 2026-03-14 13:33:31 -04:00
18d21e6651 [scrobbles] Add auto stopping
All checks were successful
build & deploy / test (push) Successful in 1m44s
build & deploy / deploy (push) Successful in 27s
2026-03-14 13:19:18 -04:00
1f67d4c0a6 [music] Fix logdata bug
All checks were successful
build & deploy / test (push) Successful in 1m38s
build & deploy / deploy (push) Successful in 20s
2026-03-14 02:04:30 -04:00
ff3fe00afa [templates] Dont need two bootstrap.min.css files
All checks were successful
build & deploy / test (push) Successful in 1m40s
build & deploy / deploy (push) Successful in 19s
2026-03-14 02:01:14 -04:00
2e289f3852 [scrobbles] source_id is screwing things up 2026-03-14 02:00:36 -04:00
c0659f26a5 [scrobbles] Only redirect if media has a URL
All checks were successful
build & deploy / test (push) Successful in 1m41s
build & deploy / deploy (push) Successful in 20s
2026-03-13 18:15:22 -04:00
8ac6ed542f [black] Apparently we didn't get it all
Some checks failed
build & deploy / test (push) Successful in 1m41s
build & deploy / deploy (push) Failing after 2m2s
2026-03-13 17:57:39 -04:00
14e32dae12 [scrobbles] Add notes_as_str to all datalogs and fix homepage 2026-03-13 17:56:39 -04:00
31d3a85e8c [videos] Dont always lookup from IMDB
All checks were successful
build & deploy / test (push) Successful in 1m41s
build & deploy / deploy (push) Successful in 23s
2026-03-13 15:54:57 -04:00
7cf817025b [templates] Try using defensive checks for missing media
All checks were successful
build & deploy / test (push) Successful in 1m46s
build & deploy / deploy (push) Successful in 21s
2026-03-12 14:12:01 -04:00
f5675e5319 [scrobblers] Allow scrobbling food from the top bar
All checks were successful
build & deploy / test (push) Successful in 1m49s
build & deploy / deploy (push) Successful in 21s
2026-03-12 14:04:04 -04:00
40617b77e2 [charts] Display all top shows and channels
All checks were successful
build & deploy / test (push) Successful in 1m49s
build & deploy / deploy (push) Successful in 20s
2026-03-12 10:07:34 -04:00
64967aa357 [charts] Truncate images for shows and channels
All checks were successful
build & deploy / test (push) Successful in 1m42s
build & deploy / deploy (push) Successful in 20s
2026-03-12 10:00:50 -04:00
88166d01eb [project] Cleaning up backlog
All checks were successful
build & deploy / test (push) Successful in 1m45s
build & deploy / deploy (push) Successful in 20s
2026-03-12 09:53:43 -04:00
f506fa465f [charts] Add youtube aggregator
All checks were successful
build & deploy / test (push) Successful in 1m41s
build & deploy / deploy (push) Successful in 52s
2026-03-12 00:26:28 -04:00
5934dcdf8e [format] Blacken everything
All checks were successful
build & deploy / test (push) Successful in 1m53s
build & deploy / deploy (push) Successful in 1m12s
2026-03-11 23:54:24 -04:00
1e11679419 [project] Finish off video aggregators 2026-03-11 23:53:36 -04:00
0e5d8e6b2f [charts] Add TV charts 2026-03-11 23:53:08 -04:00
118e208a36 [project] Update task list
All checks were successful
build & deploy / test (push) Successful in 1m41s
build & deploy / deploy (push) Successful in 22s
2026-03-11 21:30:55 -04:00
93666cec54 [scrobbles] Fix log data messiness with Jellyfin and Mopidy 2026-03-11 21:30:38 -04:00
6d5ebb68e3 [videos] Don't spam IMDB with series metadata 2026-03-11 21:28:17 -04:00
c89b434d18 [tests] Add tests for the context processor
All checks were successful
build & deploy / test (push) Successful in 1m39s
build & deploy / deploy (push) Successful in 21s
2026-03-11 17:09:06 -04:00
e83f922e32 [format] Blacken tests
All checks were successful
build & deploy / test (push) Successful in 1m38s
build & deploy / deploy (push) Successful in 20s
2026-03-11 16:50:17 -04:00
508e2db60e [ci] Try fixing context processor 2026-03-11 16:50:04 -04:00
e9f9004d6d [scrobbles] Fix Album import
Some checks failed
build & deploy / deploy (push) Has been cancelled
build & deploy / test (push) Has been cancelled
2026-03-11 16:49:10 -04:00
e3364d15ce [scrobblers] Actually lookup the album ID
Some checks failed
build & deploy / test (push) Failing after 1m15s
build & deploy / deploy (push) Has been skipped
2026-03-11 16:34:48 -04:00
744e9f1d38 [format] Blacken some files
All checks were successful
build & deploy / test (push) Successful in 1m40s
build & deploy / deploy (push) Successful in 20s
2026-03-11 16:28:05 -04:00
a343e2f3fa [templates] Add version and commit to footer 2026-03-11 16:26:51 -04:00
4036e883fd [make] Update manual deploy make command
Some checks failed
build & deploy / test (push) Successful in 1m40s
build & deploy / deploy (push) Failing after 8s
2026-03-11 16:22:52 -04:00
ff6de28b24 [ci] Deploy if tests pass 2026-03-11 16:22:35 -04:00
ad03f22b20 [koreader] Fix broken tests
All checks were successful
build & deploy / test (push) Successful in 1m41s
build & deploy / deploy (push) Has been skipped
2026-03-11 16:21:01 -04:00
4f8cf4f244 [scrobbles] Fix mopidy lookup
All checks were successful
build & deploy / test (push) Successful in 1m38s
build & deploy / deploy (push) Has been skipped
2026-03-11 16:16:10 -04:00
d6efdb6979 [scrobbles] Blacken scrobblers.py
Some checks failed
build & deploy / test (push) Failing after 1m15s
build & deploy / deploy (push) Has been skipped
2026-03-11 16:08:58 -04:00
d38e960897 [scrobbles] Add same change to mopidy (album_id)
Some checks failed
build & deploy / deploy (push) Has been cancelled
build & deploy / test (push) Has been cancelled
2026-03-11 16:08:31 -04:00
88a8b8320c [scrobbles] Save album_id to log 2026-03-11 15:57:57 -04:00
5d9834b63d [scrobbles] Fix note str output
All checks were successful
build & deploy / test (push) Successful in 1m41s
build & deploy / deploy (push) Has been skipped
2026-03-11 11:35:01 -04:00
2dc7acc536 [scrobbles] Blacken views 2026-03-11 11:17:12 -04:00
3a79c17006 [scrobbles] Add admin list to index 2026-03-08 17:55:45 -04:00
c43771c757 [git] Ignore static files 2026-03-08 17:07:13 -04:00
a5eff556be [profiles] Fix migration
All checks were successful
build & deploy / test (push) Successful in 1m48s
build & deploy / deploy (push) Has been skipped
2026-03-08 17:06:48 -04:00
5b8559efd0 [scrobbles] Add raw data storage to Jellyfins scrobbles
All checks were successful
build & deploy / test (push) Successful in 1m46s
build & deploy / deploy (push) Has been skipped
2026-03-08 17:00:25 -04:00
e3fb529419 [scripts] Add koreader test scripts
All checks were successful
build & deploy / test (push) Successful in 1m42s
build & deploy / deploy (push) Has been skipped
2026-03-08 16:57:11 -04:00
8357ce8901 [scrobbles] Blacken files and add local dev static
All checks were successful
build & deploy / test (push) Successful in 1m47s
build & deploy / deploy (push) Has been skipped
2026-03-08 16:50:43 -04:00
8309a4e3c3 [scrobbles] Add timestamp indexes
All checks were successful
build & deploy / test (push) Successful in 1m45s
build & deploy / deploy (push) Has been skipped
2026-03-08 16:41:01 -04:00
7d8e1ac817 [tests] Add tests for koreader and views
All checks were successful
build & deploy / test (push) Successful in 1m44s
build & deploy / deploy (push) Has been skipped
2026-03-08 01:05:17 -05:00
446144bc51 [ci] Prob didn't need AI to add that env variable
All checks were successful
build & deploy / test (push) Successful in 1m41s
build & deploy / deploy (push) Has been skipped
2026-03-06 13:16:08 -05:00
d36502ec09 [reqs] Add recipe-scrapers
Some checks failed
build & deploy / test (push) Failing after 1m20s
build & deploy / deploy (push) Has been skipped
2026-03-06 12:26:32 -05:00
770a51b9c0 [foods] Add recipe website parsing
Some checks failed
build & deploy / test (push) Failing after 1m10s
build & deploy / deploy (push) Has been skipped
2026-03-06 10:46:34 -05:00
210ff0a4aa [deps] Upgrade dependencies 2026-03-05 10:56:05 -05:00
db52ebeea7 [ci] Clean up the button
All checks were successful
build & deploy / test (push) Successful in 1m36s
build & deploy / deploy (push) Has been skipped
2026-03-02 22:43:53 -05:00
0e7286ac4e [ci] Add changes and build to ntfy
All checks were successful
build & deploy / test (push) Successful in 1m36s
build & deploy / deploy (push) Has been skipped
2026-03-02 22:39:22 -05:00
4ee687b9c7 [ci] Fix runner label
All checks were successful
build & deploy / test (push) Successful in 5m0s
build & deploy / deploy (push) Has been skipped
2026-03-02 17:18:06 -05:00
4b8785ead7 [ci] Update runner label
Some checks failed
build & deploy / test (push) Has been cancelled
build & deploy / deploy (push) Has been cancelled
2026-03-02 12:53:06 -05:00
bec7ef337e [ci] Add gitea workflow
Some checks failed
build & deploy / test (push) Has been cancelled
build & deploy / deploy (push) Has been cancelled
2026-03-02 12:40:39 -05:00
e7bc38d0f8 [make] Fix bad call to code.unbl.ink 2026-03-02 10:36:39 -05:00
1be8e4b083 [ci] Update hostname to code repo 2026-03-02 10:33:58 -05:00
88a94aadca [git] Add tmp ignore 2026-03-02 10:30:39 -05:00
2d8f433314 [tests] Fix slow aggregation tests 2026-03-02 10:30:32 -05:00
d1844c01a0 [tests] Speed up tests 2026-03-02 09:19:36 -05:00
9848e5874d [videos] Fix Video API views 2026-03-02 08:48:53 -05:00
a027e877f7 [scrobblers] Use MediaSourceId to avoid multiple Jellyfin scrobbles 2026-03-02 08:42:01 -05:00
82a7fd8673 [videos] Fix bad imdb_link format 2026-01-22 23:18:07 -05:00
c897507de4 [boardgames] Fix bug in bggeek_id 2026-01-20 21:18:04 -05:00
009ed2ace3 [boardgames] Fix bgg_id not being save 2026-01-20 20:41:23 -05:00
d945acfb92 [git] Fix merge commit 2026-01-20 20:32:04 -05:00
8fd0ed6e17 [scrobbles] Auto set playback seconds 2025-12-22 10:23:34 -05:00
a5d72ce4c3 [videos] Linting the imports 2025-12-02 17:47:23 -05:00
809014736c [videos] Cleaning up tt on imdb videos 2025-12-02 17:47:07 -05:00
fcccd6d9a4 [project] Update org tasks 2025-12-02 17:47:07 -05:00
a5295d7973 [notifications] Fix bug in getting profile for mood notification 2025-11-24 15:30:46 -05:00
186ae18e1f [tasks] Fix when note is a string 2025-11-18 12:35:53 -05:00
6f7f739ca6 [videos] Add management command and fix lookup of series 2025-11-18 10:32:54 -05:00
c2ba8a48ac [release] Update project log for 37 2025-11-17 22:07:23 -05:00
1530de3188 [scrobbles] Remove debug printing 2025-11-17 21:45:38 -05:00
2d235c0577 [scrobbles] Fix missing note error 2025-11-17 21:45:17 -05:00
b0eb58953b [food] Add calories if they're missing 2025-11-17 21:45:08 -05:00
7309181fed [scrobbles] Fix dataclass dict converstion error 2025-11-17 21:44:50 -05:00
971fee5b4b [food] No need to rate food 2025-11-17 21:44:39 -05:00
920a9180c8 [books] Remove unused max_length parameter 2025-11-17 21:44:19 -05:00
d568a377f0 [templates] Add longplay complete to templates 2025-11-17 20:36:50 -05:00
3851624dd7 [tests] Fix IMDB test and bump reqs 2025-11-17 20:09:34 -05:00
8c865fe008 [release] Release version 36 2025-11-17 18:15:21 -05:00
572dbf7a88 [videos] Clean up utilities 2025-11-17 18:13:40 -05:00
7addd50577 [videos] Refactor lookup to use new library 2025-11-17 17:56:15 -05:00
cd5dc25642 [release] Update project file for release 35 2025-11-17 16:35:09 -05:00
9c2355978e [videos] Fix lookup for ids for videos 2025-11-17 16:34:38 -05:00
4b9b785e50 [videos] Add youtube link to detail page 2025-11-17 16:30:40 -05:00
050b2b9d77 [videos] Fix imdb lookups with new library 2025-11-17 16:25:46 -05:00
d12cca304f [api] Missed a few api endpoints 2025-11-17 16:11:31 -05:00
8603bbd5cb [api] Add many missing API endpoints 2025-11-17 16:00:10 -05:00
749e74a54c [scrobbles] Fix missed run_time_seconds cleanup 2025-11-05 09:58:48 -05:00
7b3692ef7b [books] Think I had DST logic messed up 2025-11-05 09:58:48 -05:00
c49f6a1740 [scrobbles] Clean up various sources 2025-11-03 08:49:53 -05:00
1d813e4643 [food] Fix error in calorie aggregation 2025-11-03 00:15:09 -05:00
5e0a429d81 [project] Cut version 34 2025-11-02 23:52:48 -05:00
d928d266b9 [videos] Add video to play again media 2025-11-02 23:52:26 -05:00
b4dbbb4211 [boardgames] Deprecate failing tests 2025-11-02 23:45:34 -05:00
dcb5260cfc [boardgames] Tighten up boardgame lookups 2025-11-02 23:43:19 -05:00
a8747dfe77 Update all the places we need base_run_time_seconds now 2025-11-02 21:18:52 -05:00
a474b5df48 [scrobbles] Refactor run time sec to be blank by default 2025-10-29 21:54:18 -04:00
082979bea6 [logs] Fix class name for scrobble_for_user 2025-10-29 19:56:55 -04:00
1275186d86 [videos] Fix next episode error 2025-10-29 19:45:50 -04:00
cd60ac6387 [tasks] Fix emacs not updating or completing 2025-10-29 17:12:48 -04:00
bdfbd3e5c0 [project] Update task list with version 33 2025-10-28 14:57:07 -04:00
dff63f325f [scrobbles] Fix calorie aggregation bug 2025-10-28 14:56:30 -04:00
2b634e3b7e [scrobbles] Fix look up of old scrobbles by total seconds 2025-10-28 14:41:52 -04:00
723d739405 [books] Clean up resume URLs 2025-10-28 14:41:16 -04:00
e62a07af37 [boardgames] Add auth to BGG API call 2025-10-28 14:38:36 -04:00
f86c3b2935 [project] Bump version to 32 2025-10-22 14:20:03 -04:00
050add8543 [books] Add utility urls to model and scrobbles 2025-10-22 14:18:01 -04:00
8faf0296a6 [project] Finish book resume link task 2025-10-22 12:18:40 -04:00
f209f3b107 [books] Set restart and resume urls on comic book scrobbles 2025-10-22 12:18:08 -04:00
b233b60ae0 [books] Add bookmark_url to logdata 2025-10-22 01:00:25 -04:00
e1d4a7c5a4 [books] Fix looking up comic by original title 2025-10-20 22:47:32 -04:00
440 changed files with 32000 additions and 5543 deletions

View File

@ -34,7 +34,7 @@ steps:
command_timeout: 2m
script:
- pip uninstall -y vrobbler
- pip install git+https://code.unbl.ink/secstate/vrobbler.git@main
- pip install git+https://code.lab.unbl.ink/secstate/vrobbler.git@main
- vrobbler migrate
- vrobbler collectstatic --noinput
- immortalctl restart celery && immortalctl restart vrobbler
@ -50,8 +50,15 @@ steps:
topic: drone
priority: low
tags:
- failure
- success
- vrobbler
actions:
- action: view
label: Changes
url: "{{ .Commit.Link }}"
- action: view
label: Build
url: "{{ .Build.Link }}"
- name: build failure notification
image: parrazam/drone-ntfy:0.3-linux-amd64
when:
@ -61,8 +68,15 @@ steps:
topic: drone
priority: high
tags:
- success
- failure
- vrobbler
actions:
- action: view
label: Changes
url: "{{ .Commit.Link }}"
- action: view
label: Build
url: "{{ .Build.Link }}"
volumes:
- name: docker
host:

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

155
.gitea/workflows/deploy.yml Normal file
View File

@ -0,0 +1,155 @@
name: deploy
on:
push:
tags: ["*"]
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
build-and-deploy:
needs: [test]
runs-on: ubuntu-latest
steps:
- 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
username: root
key: ${{ secrets.JAIL_KEY }}
command_timeout: 2m
script: |
set -e
mkdir -p /var/lib/vrobbler
echo "${{ gitea.sha }}" | cut -c1-8 > /var/lib/vrobbler/commit.txt
pip uninstall -y vrobbler
pip install /var/lib/vrobbler/dist/*.whl
rm -f /var/lib/vrobbler/dist/*.whl
python3 -c "import vrobbler; print(f'vrobbler {vrobbler.__version__} installed OK')"
vrobbler migrate
vrobbler collectstatic --noinput
immortalctl restart vrobbler-celery && immortalctl restart vrobbler-celerybeat && immortalctl restart vrobbler
- name: Notify deploy success (ntfy)
if: success()
run: |
curl -fsS \
-H "Title: vrobbler deploy success" \
-H "Priority: low" \
-H "Tags: success,vrobbler,deploy" \
-H "Actions: view, View changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, View build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
-d "🚀 Deploy succeeded: ${{ gitea.ref_name }} (${{ gitea.sha }})" \
https://ntfy.unbl.ink/drone
- name: Notify deploy failure (ntfy)
if: failure()
run: |
curl -fsS \
-H "Title: vrobbler deploy failure" \
-H "Priority: high" \
-H "Tags: failure,vrobbler,deploy" \
-H "Actions: view, View changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, View build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
-d "💥 Deploy failed: ${{ gitea.ref_name }} (${{ gitea.sha }})" \
https://ntfy.unbl.ink/drone

2
.gitignore vendored
View File

@ -3,3 +3,5 @@ vrobbler.conf
media/
dist/
.coverage
tmp/*
vrobbler/static/*

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,5 @@
deploy:
ssh vrobbler.service "rm -rf /usr/local/lib/python3.11/site-packages/vrobbler-0.15.4.dist-info/ && pip install git+https://code.unbl.ink/secstate/vrobbler.git@develop && immortalctl restart vrobbler && immortalctl restart vrobbler-celery && vrobbler migrate"
ssh vrobbler.service "pip uninstall vrobbler && pip install git+https://code.lab.unbl.ink/secstate/vrobbler.git && immortalctl restart vrobbler && immortalctl restart vrobbler-celery && vrobbler migrate"
logs:
ssh life.unbl.ink tail -n 100 -f /var/log/vrobbler.json
test:

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 [3/27]
* Backlog [0/15] :vrobbler:project:personal:
** TODO [#C] Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment:
:PROPERTIES:
:ID: 37781d6a-f3b0-48b2-bf98-33c2c791cf85
:END:
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles:
:PROPERTIES:
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
:END:
** TODO [#C] Move to using more robust mopidy-webhooks pacakge form pypi :utility:improvement:
:PROPERTIES:
:ID: ab31fdc3-359c-1b1d-6b9d-546b476021ba
@ -389,11 +397,614 @@ 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:
** TODO [#B] Clean up follow up notifications for board games and beer that ask if you're still scrobbling :vrobbler:personal:project:beers:boardgames:notifications:feature:
: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 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] 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] 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] \\
This may already be fixed ... need to check.
- Note taken on [2025-02-25 12:34] \\
The page data has the canonical date something was read in it, but it seems
to be an hour off. I traced this back to being off during DST, so we just need
the importer to be aware of whether a user is using DST or not and roll back
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.
*** Description
This is a long-standing problem when daylight saving time takes effect. Time is
manually set on a KoReader device (or at least, always saved in local time). So
whatever time KoReader reports, we need to know, given the date and the user
profile's historic timezone, how many hours to adjust the KoReader time to get
to GMT to save it in the database.
** TODO [#A] Orgmode tasks are not updated if in progress :tasks:orgmode:bug:
:PROPERTIES:
:ID: 7dcebb2c-7c4c-4ac5-bee6-c2e36c3811f9
:END:
*** Description
Currently if you POST to the orgmode webhook with a task that's already in progress, the request just stops there.
We should add logic where if the task is in-progress, instead of doing nothing,
it checks the webhook payload against the in-progress tasks and updates the
description of the scrobble.log with the incoming task description if it's
different. And the same for comments. If a comment (by timestamp key) is
different in the webhook than what's in the scrobble.log, update the comment in
the scrobble.log
** TODO [#A] Ignore tag 'inprogress' for Tasks :bug:tasks:tags:
*** Description
When scrobbling tasks from Todoist, the tag `inprogress` is always in the
payload, because that's how we parse tasks starting from the Todoist webhooks.
But we don't really need anything tagged as `inprogress` Can we ignore this tag
when applying tags to Task scrobbles coming from Todoist?`
* Version 42.0 [1/1]
** DONE [#B] Add ability to add track to current Mopidy queue :feature:mopidy:tracks:
:PROPERTIES:
:ID: 79d5b580-4ea6-461b-4c6c-2c950d8b3e4c
:END:
* Version 41.0 [5/5]
** DONE [#B] For any scrobble detail page with notes display them better :templates:notes:scrobbles:
:PROPERTIES:
:ID: c0dcf9da-227f-4a22-bcd9-9d46053607d9
:END:
*** Description
Currently notes are displayed as little post-it notes. This is cute, but not terribly useful.
We should update note rendering to be a simple newest to oldest display in a
single column with the timestamp has a small header, and the content rendered as
markdown with a small bar or horizontal divider marking them from the next note.
** DONE [#A] Imports should send notifications :feature:notifications:imports:
:PROPERTIES:
:ID: 6f78f8d5-ecaa-4d8a-a666-ae4e27653191
:END:
*** Description
Currently importing board games sends out a ntfy message when a scrobble is
created.
We should do the same thing for other import types; namely: gpx, ebird, and scale.
** DONE [#A] Board game imports send duplicate ntfy message :bug:notifications:boardgames:
:PROPERTIES:
:ID: 8f067432-0399-4b79-9e93-727edcccedbd
:END:
*** Description
When a board game scrobble is created via a bgstats import, ntfy messages are
sent.
But right now they are duplicated (two are sent at the same time). Can we review
the code to see why this is happening and fix it?
** DONE [#A] Too many geolocation notifications go out :bug:notifications:geolocations:
:PROPERTIES:
:ID: 6357ad7a-fe4e-49dd-a063-55d87e459c17
:END:
*** Description
Currently ntfy gets overwhelemed when there's more than a hundred or so messages left in a queue on a client.
It would be nice if we could not spam ntfy, and this is especially true with Geolocations, where we really
don't need to alert folks unless they have a named Geolocation (has a title). Can we adjust the ntfy
sending for Geolocations to only send if the scrobbled location has a title?
** DONE [#C] Fix bug where Weigh-in imports do not set title :bug:tasks:scale:
:PROPERTIES:
:ID: 622e354a-8e66-4ecd-9e1c-a53f0a2ec362
:END:
*** Description
Currently when we import a scale CSV row and create data, the title is left
blank which makes it look funny in a list view. Let's save the weight as the
title of the Weigh-in task.
* Version 40.2 [1/1]
** DONE [#A] Try fixing deploy bugs again :tooling:releases:bug:
:PROPERTIES:
:ID: 15894943-be1d-200f-8400-a136770ad9d2
:END:
* Version 40.1 [2/2]
** DONE [#A] Releases are still broken :bug:releases:tooling:
:PROPERTIES:
:ID: bca37a18-afa2-4ddd-a11b-ef0555f38bc9
:END:
*** Description
Deploys are still broken, even with them being pulled apart and run separately.
We need to address the way the commit ends up stashed in the codebase.
** DONE [#C] Fix bug on chart pages where trail titles missing :bug:trails:charts:
:PROPERTIES:
:ID: 21075430-8a93-4e59-9a02-479315960ae6
:END:
*** Description
When trails are rendered on the chart views, there are no titles, which means we don't see
anything except the ranking number.
* Version 40.0 [2/2]
** DONE [#A] Fix error in org-mode task sync :emacs:orgmode:tasks:bug:
:PROPERTIES:
:ID: 03256d2a-48aa-4be7-aeb3-fa1cfddc86bf
:END:
*** Description
#+begin_src python
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/tasks/webhooks.py", line 236, in post
emacs_scrobble_update_task(
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/scrobbles/scrobblers.py", line 844, in emacs_scrobble_update_task
for note in emacs_notes:
TypeError: 'NoneType' object is not iterable
#+end_src
** DONE [#B] Adjust how similar artists are shown :feature:templates:artists:music:
:PROPERTIES:
:ID: 2a081620-a0a2-4851-a7cf-4043f9c2ee31
:END:
*** Description
Currently we show the top 10 similar artists on the Artist detail page linked to
the artist detail page on Vrobbler.
First off, this is very slow. We should look into speeding up the rendering of
the similar artists widget.
Second, the artist name in the similar artist list should be a link to the
Vrobbler artist detail page, but there should also be a [musicbrainz] link next
to it, that links out to the musicbrainz page whether we have the artist in the
Vrobbler database or not.
* Version 39.3 [2/2]
** DONE [#A] Issue found when doing a release :bug:tooling:release:cicd:
:PROPERTIES:
:ID: a8fc3ec9-74ec-4190-8ac2-62cd8a33e828
:END:
*** Description
#+begin_src sh
err: ERROR: Cannot install vrobbler 0.16.1 (from /var/lib/vrobbler/dist/vrobbler-0.16.1-py3-none-any.whl) and vrobbler 38.0 (from /var/lib/vrobbler/dist/vrobbler-38.0-py3-none-any.whl) because these package versions have conflicting dependencies.
out: The conflict is caused by:
out: The user requested vrobbler 0.16.1 (from /var/lib/vrobbler/dist/vrobbler-0.16.1-py3-none-any.whl)
out: The user requested vrobbler 38.0 (from /var/lib/vrobbler/dist/vrobbler-38.0-py3-none-any.whl)
out: To fix this you could try to:
out: 1. loosen the range of package versions you've specified
out: 2. remove package versions to allow pip attempt to solve the dependency conflict
err: ERROR: ResolutionImpossible: for help visit https://pip.pypa.io/en/latest/topics/dependency-resolution/#dealing-with-dependency-conflicts
2026/06/01 14:15:00 Process exited with status 1
failed to remove container: Error response from daemon: removal of container 3fe0eaf032c5518aca4ab71734b52bda7c54ed406136b82136ab7155bf5ff3c1 is already in progress
#+end_src
** DONE [#A] Fix deploy actions running twice :bug:tooling:cicd:
:PROPERTIES:
:ID: aa56f2a6-2b61-4ddf-9e27-9eadcddf8412
:END:
*** Description
Turns out we're now running the build-deploy action twice, once on branch push
and once on tag push.
We should do builds on each push and build and deploys only when a new tag is
detected.
* Version 39.2 [2/2]
** DONE [#B] Releases do not pin commit to the repo for display :bug:tooling:releases:
:PROPERTIES:
:ID: 2a9f2ff5-2642-47ab-ba1d-e41825411713
:END:
*** Description
Somewhere in implementing the justfile release flow, we lost the capture of the
latest commit in the relesae flow so the footer now always says: vXX.x (unknown)
It should have the first bit of the commit in the parens at the end.
** DONE [#B] Fix the way timestamps are stored for notes on tasks :bug:scrobbles:tasks:
:PROPERTIES:
:ID: 32973bb3-079b-8cdf-6495-82f8ae907299
:END:
*** Description
:PROPERTIES:
:ID: d833a3df-6eb2-36bb-1863-e438e5d36151
:END:
Turns out Todoist uses a human-readable timestamp format for comments. We should
adapt that for use from org-mode as well.
Format should be: "%Y-%m-%dT%H:%M:%S.%fZ"
* Version 39.1 [1/1]
** DONE [#A] Fix bug in tests for notes saving :bug:scrobbles:forms:
:PROPERTIES:
:ID: 68a011b2-bb6f-3ba8-2312-5947c41db9ac
:END:
* Version 39.0 [3/3]
** DONE [#B] Clean up org-mode tasks metadata :bug:tasks:metadata:
:PROPERTIES:
:ID: 0c762d09-fc69-4e75-be40-7eaaf04f178e
:END:
*** Description
Org-mode tasks have a `Description` subheader, which should populate the
"Description" of a task.
The title should come from the actual TODO content, with tags coming from the
tags in colons after the task. This behaviour should already work.
Next, any comments should go under a `Comments` subheader. Under these should be a list tag like:
"Note taken on [2026-05-31 Sun 20:03]"
Which declares it's a note with a timestamp like "YYYY-MM-DD d HH:MM".
When scrobbling a task from org-mode, the description should always be copied to
the scrobble's log["description"].
Any comments that exist on the task when the scrobble is first created, should
be ignored.
Any comments on a task that is updated (a re-POST'd while the task is in
progress, should add a comment in the log["notes"], a dictionary, keyed off the
timestamp the note string. If a note for that datetime already exists, the
content should be replaced with the new comment.
Additionally, when a task is re-POST'd while the task is in progress, the
description should also be compared, and if different, the newest description
should be used.
Note, the biggest change for this flow is making sure comments that already
exist are not added to any new tasks and that both comments and descriptions are
added or updated if the new values are different than what is already in the
currently in-progress task.
If the task is completed, don't touch it.
*** Comments
- Note taken on [2026-05-31 Sun 20:03]
** DONE [#A] Actually push branches up and add a just command to do it :release:justfile:tooling:
:PROPERTIES:
:ID: 50aa5daa-a802-6aa9-38a3-218b7a9d4b34
:END:
** DONE [#A] Try to fix deploy failing with bad release plan :release:tooling:pyproject:
:PROPERTIES:
:ID: 63dc633c-4382-e6a5-e663-b01871ce86ce
:END:
* Version 38.0 [38/38]
** DONE [#A] Fix release flow to be easier to trigger :pyproject:release:tooling:
:PROPERTIES:
:ID: e321f8a3-26ea-b316-8493-294b50c0b516
:END:
*** Description
We should have a command like `just relesae <major|minor>` that can cut new
releases on command by updating the PROJECT.org file with a release header for
all DONE tasks and creating the appropriate release commits and tags based on
either the new release version type.
It should also update the pyproject file.
** DONE [#A] Move imported retroarch lrtl files to processed/ directory on WebDAV :webdav:retroarch:importers:
:PROPERTIES:
:ID: 881b5af6-e96f-4e71-9e66-2d30824e5504
:END:
- File: ~vrobbler/apps/scrobbles/importers/webdav.py~ (line 439)
- Same pattern as the GPX importer: after importing a =.csv= file from
WebDAV, move it to =var/retroarch/processed/= with a timestamp appended.
** DONE [#A] Add listenbrainz support for similar tracks :feature:music:metadata:
:PROPERTIES:
:ID: a3abba14-7603-db51-58e1-1941a47fa35e
:END:
** DONE [#B] Consolidate albums in the same musicbrainz_releasegroup_id :music:albums:metadata:
:PROPERTIES:
:ID: 6b331bdb-1516-bb86-7cb3-8cb9e3de59ce
:END:
*** Description
When we look up albums, we should check if one already exists with the same
musicbrainz_releasegroup_id and prefer that one, rather than creating a new
album.
Also, we should create a data migration to clean up albums with matching
musicbrainz_releasegroup_id fields by consolidating tracks around the first
album found.
Whether the track was enriched by musicbrainz or not, the track should get
tagged with `musicbrainz_enriched` or `musicbrainz_notfound`
** DONE [#A] Clean up metadata on music tracks :music:tracks:metadata:musicbrainz:
:PROPERTIES:
:ID: 3a007bd7-b901-43cb-892e-102d39fffc85
:END:
*** Description
There's a over 3K tracks without a musicbrainz_id and almost 30K without a
base_run_time_seconds. We should have a clean up script that can run through the
ones missing musicbrainz_ids and see about getting metadata from musicbrainz and
for the 30K without base_run_time_seconds, check with their musicbrainz_ids to
see if we can look up the track length, and if not, tag the track with
"missing-metadata" tag.
Also, if the tracks without musicbrainz_ids actually are not in the MB database,
we should tag those with "not-in-musicbrainz"
And a big part of this work will probably involve checking for "Various Artists"
tracks where the track erroneously got set with a generic artist. In those
cases, we should try just looking up by track title.
** DONE [#B] Make artists_m2m field source of artist truth for albums :music:bug:albums:
:PROPERTIES:
:ID: 05a24455-0e71-45ef-ac6e-c1b7b843047d
:END:
*** Description
Albums have an FK for album_artist, but like artists, the M2M should be the
source of truth. We should migrate all uses of album_artist to an `artist`
property on the Album model and use a data migration to populate artists with
the album_artist value.
** DONE [#A] Fix various artist album problem with Superwolves (track with multiple artists) :vrobbler:project:music:bug:artists:
:PROPERTIES:
:ID: 590bc038-745f-710b-8272-4d8a3d2efa01
:END:
*** Description
We have an issue with tracks where there are two artists, like `Matt Sweeney &
Bonnie "Prince" Billy`
I think we need to allow creating a single artist with both names, so the Artist
is the full name. That said, the ampersand is usually used to split feature
artsits (I think) so I'm not sure how this would work reliably.
Also, it's possible musicbrainz does not have dual artist listings. So it's
possible the longer term solution is to allow multiple artists per track the way
we now allow tracks to be on multiple albums. We could deprecate the Artist FK
or at least make it optional, and then require a M2M between Track and Artist.
Then this one would be a Track by both `Matt Sweeney` and `Bonnie "Prince"
Billy`
** DONE [#A] Move imported eBird CSV files to processed/ directory on WebDAV :webdav:ebird:importers:
:PROPERTIES:
:ID: 445e1253-d353-4b55-b1d8-39d0a0dcdd34
:END:
- File: ~vrobbler/apps/scrobbles/importers/webdav.py~ (line 439)
- Same pattern as the GPX importer: after importing a =.csv= file from
WebDAV, move it to =var/ebird/processed/= with a timestamp appended.
** DONE [#A] Move imported Board Game CSV files to processed/ directory on WebDAV :webdav:boardgames:importers:
:PROPERTIES:
:ID: a3f8d30c-d2c3-4ee7-b062-f0f16bd9b0b4
:END:
- File: ~vrobbler/apps/scrobbles/importers/webdav.py~ (line 496)
- Same pattern as the GPX importer: after importing a =.csv= file from
WebDAV, move it to =var/bgstats/processed/= with a timestamp appended.
** DONE [#A] Move imported Scale CSV files to processed/ directory on WebDAV :webdav:scale:importers:
:PROPERTIES:
:ID: 1a0de363-d1ea-466e-9966-e24941a6180b
:END:
- File: ~vrobbler/apps/scrobbles/importers/webdav.py~ (line 496)
- Same pattern as the GPX importer: after importing a =.csv= file from
WebDAV, move it to =var/scale/processed/= with a timestamp appended.
** DONE [#A] Allow special parameter to re-import already processed GPX files :imports:gpx:
:PROPERTIES:
:ID: 166c0809-c11a-4d02-8071-7a69dcb36e64
:END:
*** Description
Now that we stash imported GPX files in the processed/ subdirectory on import,
it would be nice to have a flag like --include-processed on the webdav importer
that would import files both in the root of gpx/ and also in the processed
directory. This would aide testing imports in staging quickly without constantly
moving files back and forth.
** DONE [#A] Move imported GPX files to processed/ directory on WebDAV :webdav:gpx:importers:
:PROPERTIES:
:ID: db2b02fc-817c-4c39-bd51-13f9b77c7888
:END:
- File: ~vrobbler/apps/scrobbles/importers/webdav.py~ (line 198)
- After importing a GPX/FIT file from WebDAV, move it to a =processed/=
subdirectory with a timestamp appended. This eliminates the DB lookup for
already-imported filenames — any file present in the top-level directory
is new. Also makes manual re-imports easy (just move a file back).
** DONE [#A] Add CSS Grid calendar view for scrobbles :vrobbler:personal:project:templates:feature:
:PROPERTIES:
:ID: be915acf-d803-466a-8770-823819ebf2a9
:END:
*** Description
Calendar view at /scrobbles/calendar/ showing select media types (Tasks,
Birding, Food, Trails, VideoGames, Books) in a CSS Grid month layout.
- Emoji badges per scrobble on each day cell, hover reveals title
- Click emoji to navigate to scrobble detail
- Prev/Next month navigation
- Based on the CodePen CSS Grid calendar pattern:
https://codepen.io/oliviale/pen/QYqybo
** DONE [#C] Come up with a possible flow using WebDAV and super-productivity for tasks :personal:feature:project:vrobbler:tasks:
:PROPERTIES:
:ID: dd751edf-6192-2ed0-cd85-905f423f82b0
:END:
** DONE [#B] Fix PuzzleLogData has no attribute form :vrobbler:puzzles:personal:project:logdata:
:PROPERTIES:
:ID: cb8e697a-9fc2-c463-5d4d-fa15d3e833a0
:END:
** DONE [#B] Add PuzzleLogData class with with_people and completed :vrobbler:feature:puzzles:logdata:personal:project:
:PROPERTIES:
:ID: 1b38c8a8-86d3-b73d-c584-dfb0497970dc
:END:
** DONE Add weather lookup to the mood check-in flow :vrobbler:project:moods:feature:checkin:
:PROPERTIES:
:ID: 175678e2-bfe1-0833-f2a9-f03b9323db78
:END:
<2026-05-20 Wed>
** DONE Add importing of openScale CSV files to Tasks :vrobbler:project:personal:tasks:openscale:
:PROPERTIES:
:ID: 344d1f5d-dda6-a1fb-da53-e00eebdd2262
:END:
** DONE Add ability to track Birding sessions via BirdingLocation scrobbles :vrobbler:project:birds:feature:
:PROPERTIES:
:ID: 8200ce29-a691-5cf6-c11a-c77e3d8b64c6
: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
:END:
The idea here is that no matter where the data comes from, we should just save
it in the scrobble for posterity, so we can always in some form recover the
original intent of the scrobble.
It may also allow us to clean up junk scrobbles if the data was just horribly
wrong.
** DONE [#B] Clean up follow up notifications for if you're still scrobbling :vrobbler:personal:project:beers:boardgames:notifications:feature:
:PROPERTIES:
:ID: 7937545f-2a62-4188-ad49-34f85fad0c25
:END:
- Note taken on [2025-09-30 Tue 09:32]
@ -402,33 +1013,213 @@ https://codepen.io/oliviale/pen/QYqybo
https://docs.ntfy.sh/publish/#send-http-request
** TODO [#B] Add AllTrails as a source for Trail data :vrobbler:trails:feature:personal:project:
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 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] Add youtube link in place of IMDB on video detail page :vrobbler:feature:videos:personal:project:
** 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:
** DONE [#A] Fix lookup of music tracks from Musicbrainz :vrobbler:bug:tracks:music:
:PROPERTIES:
:ID: 2bc1cc0b-6c4e-bc3d-e7c3-47df364df206
:END:
- Note taken on [2025-09-25 Thu 10:51]
Turns out we're not looking up music tracks properly, again.
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] Add classmethod for metadata fetching to tracks :vrobbler:feature:music:personal:project:
** DONE Check opencode about a way to present stats like movies per month :vrobbler:scrobbles:stats:personal:project:
:PROPERTIES:
:ID: eed39a0b-3aa7-dc65-9e03-a58fa401c8c6
:END:
** DONE Fix bug in Jellyfin audio track playback :vrobbler:personal:project:bug:music:jellyfin:
:PROPERTIES:
:ID: 002bd234-4507-5313-fb59-2d3baa49b847
:END:
#+begin_src shell
ERROR django.request:241 log_response Internal Server Error: /webhook/jellyfin/
Traceback (most recent call last):
File "/usr/local/lib/python3.11/site-packages/django/core/handlers/exception.py", line 55, in inner
response = get_response(request)
^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/core/handlers/base.py", line 197, in _get_response
response = wrapped_callback(request, *callback_args, **callback_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/views/decorators/csrf.py", line 56, in wrapper_view
return view_func(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/views/decorators/csrf.py", line 56, in wrapper_view
return view_func(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/django/views/generic/base.py", line 104, in view
return self.dispatch(request, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/rest_framework/views.py", line 509, in dispatch
response = self.handle_exception(exc)
^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/rest_framework/views.py", line 469, in handle_exception
self.raise_uncaught_exception(exc)
File "/usr/local/lib/python3.11/site-packages/rest_framework/views.py", line 480, in raise_uncaught_exception
raise exc
File "/usr/local/lib/python3.11/site-packages/rest_framework/views.py", line 506, in dispatch
response = handler(request, *args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/rest_framework/decorators.py", line 50, in handler
return func(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/scrobbles/views.py", line 494, in jellyfin_webhook
scrobble = jellyfin_scrobble_media(post_data, request.user.id)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/scrobbles/scrobblers.py", line 177, in jellyfin_scrobble_media
log["album_id"] = media_obj.album_id
~~~^^^^^^^^^^^^
TypeError: 'tuple' object does not support item assignment
#+end_src
** DONE [#B] Auto calc duration if no playback time seconds present :vrobbler:bug:scrobbles:personal:project:
:PROPERTIES:
:ID: e16228b2-b062-bd00-32e6-b2353e6406e9
:END:
** DONE Fix bug in video find_or_create :vrobbler:personal:project:bug:videos:
:PROPERTIES:
:ID: 10e611a5-fbcf-7473-25f0-c9bad7b79ffd
:END:
The error:
#+begin_src shell
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/scrobbles/views.py", line 493, in jellyfin_webhook
scrobble = jellyfin_scrobble_media(post_data, request.user.id)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/scrobbles/scrobblers.py", line 149, in jellyfin_scrobble_media
media_obj = Video.find_or_create(imdb_id)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/videos/models.py", line 390, in find_or_create
return cls.get_from_imdb_id(source_id, overwrite)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/videos/models.py", line 374, in get_from_imdb_id
video.tv_series = Series.find_or_create(
^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/videos/models.py", line 203, in find_or_create
vdict, _, cover, genres = lookup_video_from_imdb(
^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/vrobbler/apps/videos/sources/imdb.py", line 17, in lookup_video_from_imdb
imdb_result = imdb.get_title(imdb_id)
^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/cinemagoerng/web.py", line 127, in get_title
data = _scrape(spec=spec, context=context, headers=headers)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/cinemagoerng/web.py", line 120, in _scrape
document = fetch(url, headers=request_headers)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/site-packages/cinemagoerng/web.py", line 44, in fetch
with urlopen(request) as response:
^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/urllib/request.py", line 216, in urlopen
return opener.open(url, data, timeout)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/urllib/request.py", line 525, in open
response = meth(req, response)
^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/urllib/request.py", line 634, in http_response
response = self.parent.error(
^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/urllib/request.py", line 563, in error
return self._call_chain(*args)
^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/local/lib/python3.11/urllib/request.py", line 496, in _call_chain
result = func(*args)
^^^^^^^^^^^
File "/usr/local/lib/python3.11/urllib/request.py", line 643, in http_error_default
raise HTTPError(req.full_url, code, msg, hdrs, fp)
urllib.error.HTTPError: HTTP Error 500: Internal Server Error
#+end_src
- Note taken on [2026-03-11 Wed 20:53]
It turns out that every time a TV show was scrobbled were were hitting IMDB.
Woof.
** DONE Update admin page to be easier to use :vrobbler:djadmin:project:personal:
:PROPERTIES:
:ID: 0779be1f-1ad7-ddc7-8861-d3c7c833cbe9
:END:
** DONE Fix migrations and update repo :vrobbler:scrobbles:admin:personal:project:
:PROPERTIES:
:ID: ccbc2ef0-68bf-9ef7-dcaf-febab5ebefe2
:END:
** DONE Add recipe parsing for food lookups :vrobbler:foods:project:feature:personal:
:PROPERTIES:
:ID: 86456c78-247b-fb63-7ae8-d6d17e7666b1
:END:
** DONE [#A] Videos are scrobbling duplicates again :vrobbler:bug:videos:scrobbles:
:PROPERTIES:
:ID: a46884fe-7ef1-410a-316b-7ac6d7599331
:END:
<2026-03-06 Fri>
** DONE Fix board games not saving BGG id on lookup :vrobbler:bug:boardgames:
:PROPERTIES:
:ID: 506c2965-51d6-6cb9-fc4f-4f0468d2d62f
:END:
** DONE Fix board game lookup with name like Unmatched Game System :vrobbler:bug:boardgames:
:PROPERTIES:
:ID: 57956723-b34a-37ad-fde9-44947cc9bb65
:END:
** DONE [#A] Fix raw text webpage title not truncating to 254 chars :vrobbler:personal:bug:webpages:
:PROPERTIES:
:ID: 13fa0efd-2c3f-dd07-deb2-62882096feff
:END:
- 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:
:ID: c8410001-dbb7-1536-bd89-9784189e058f
:END:
** DONE [#A] Allow scrobbling from the Food list page's start links :vrobbler:bug:food:scrobbling:personal:project:
:PROPERTIES:
:ID: 00f99f60-ac00-6cde-311d-c31f41a01353
:END:
https://life.lab.unbl.ink/scrobble/e39779c8-62a5-46a6-bdef-fb7662810dc6/start/
** DONE [#B] Food scrobbles should inherit calories from obj if missing :vrobbler:feature:food:personal:project:
:PROPERTIES:
:ID: 3322ff69-4252-db65-36b3-fae56c1b9327
:END:
** DONE [#A] Puzzles (and all longplays) should have a "Completed?" column on their detail page :vrobbler:bug:puzzles:personal:project:
:PROPERTIES:
:ID: e3e49a9a-67d2-8ad8-1114-6f05effee9b7
:END:
* Version 36.0 [1/1]
** DONE [#A] Refactor how videos are scrobbled :vrobbler:vidoes:feature:personal:project:
:PROPERTIES:
:ID: 6034a11d-5376-994d-9a4b-e1640e258cfa
:END:
* Version 35.0 [3/3]
** DONE [#B] Add youtube link in place of IMDB on video detail page :vrobbler:feature:videos:personal:project:
:PROPERTIES:
:ID: 84064bd6-2258-a4de-f048-b131db9465c9
:END:
** DONE [#B] Add missing API lookups to resolve broken scrobbles endpoint :vrobbler:feature:api:scrobbles:personal:project:
:PROPERTIES:
:ID: 0f668a54-f587-3b17-353e-3a56969d3a82
:END:
** DONE [#A] IMDB lookups are not working :vrobbler:bug:videos:personal:project:
:PROPERTIES:
:ID: d1ba1ca1-509b-13a9-1307-b2dc94a2eafe
:END:
* Version 34.0 [4/4]
** DONE [#A] Use bgg-api for BoardGameGeek lookups :vrobbler:feature:boardgames:personal:project:
:PROPERTIES:
:ID: 738abb5a-c796-b16b-fe10-6e5639a0e10d
:END:
** DONE [#A] Add classmethod for metadata fetching to tracks :vrobbler:feature:music:personal:project:
:PROPERTIES:
:ID: bc4b45e5-4c65-13c5-ab7b-1937d3fbf5c2
:END:
** TODO [#A] Tasks from org-mode should properly update notes and leave them out of the body :vrobbler:bug:tasks:
** TODO [#A] Fix views for TV series where next episode is now None :vrobbler:bug:personal:videos:
- Note taken on [2025-10-29 Wed 21:44]
Beyond a classmethod (which I think we have now), we need to update the flow of how we look up tracks.
It's a hot mess right now where Various Artists walks over the actual artist, and we often hit MB when we don't have to.
** DONE [#A] Fix views for TV series where next episode is now None :vrobbler:bug:personal:videos:
:PROPERTIES:
:ID: d7014ac4-cda6-0802-2cdf-8f66c6389fea
:END:
#+begin_src python
ERROR django.request:241 log_response Internal Server Error: /series/c24100d1-da45-4abe-86bf-27cfce9b1f89/
@ -457,28 +1248,40 @@ Traceback (most recent call last):
TypeError: can only concatenate str (not "NoneType") to str
#+end_src
** TODO [#A] Fix koreader scrobble imports to use DST properly :vrobbler:personal:bug:books:imports:
** DONE [#A] Emacs tasks are duplicating rather than updating :vrobbler:bug:tasks:emacs:personal:project:
:PROPERTIES:
:ID: e93efc25-7ce9-8ef2-662e-0a19dd0b29c9
:END:
- Note taken on [2025-09-25 Thu 10:37] \\
- Note taken on [2025-10-29 Wed 16:38]
This may already be fixed ... need to check.
Turns out I was misusing `orgmode` for the source of tasks when it shoulda been `Org-mode`
- Note taken on [2025-02-25 12:34] \\
A good lesson in using constants for things.
The page data has the canonical date something was read in it, but it seems
to be an hour off. I traced this back to being off during DST, so we just need
the importer to be aware of whether a user is using DST or not and roll back
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] Allow scrobbling from the Food list page's start links :vrobbler:bug:food:scrobbling:personal:project:
https://life.lab.unbl.ink/scrobble/e39779c8-62a5-46a6-bdef-fb7662810dc6/start/
** TODO [#A] Puzzles (and all longplays) should have a "Completed?" column on their detail page :vrobbler:bug:puzzles:personal:project:
** TODO [#A] Fix raw text webpage title not truncating to 254 chars :vrobbler:personal:bug:webpages:
- Note taken on [2025-09-30 Tue 09:33]
This may have already been resolved ... need to just confirm it.
* Version 33.0 [3/3]
** DONE [#A] Fix bug where scrobble is_stale only uses seconds not total_seconds :vrobbler:bug:scrobbles:personal:project:
:PROPERTIES:
:ID: 7f6070ac-4f67-011d-ebd5-f3dc47da46ed
:END:
** DONE [#B] Fix duplicatged Read next issue for Comic books :vrobbler:bug:books:personal:project:
:PROPERTIES:
:ID: 97943040-1f03-b0b7-b0aa-123a783e4f7b
:END:
** DONE [#A] Add API authentication to BGG calls :vrobbler:bug:boardgames:personal:project:
:PROPERTIES:
:ID: 4955cc34-0882-50db-92f7-f36a95bf57a4
:END:
<2025-10-28 Tue>
* Version 32.0 [2/2]
** DONE [#B] Save path to reading source on book scrobbles and show it on the detail page :vrobbler:feature:books:personal:project:
:PROPERTIES:
:ID: f1ef3945-e6e4-66c1-b72e-3cede7a0f84a
:END:
** DONE [#B] Move comic resume URL to next page and check if it exists :vrobbler:feature:books:personal:project:
:PROPERTIES:
:ID: 9fe09567-11a3-7083-53c7-07458a9591d0
:END:
* Version 31.0 [3/3]
** DONE [#A] Stop comic book webpage scrobbles from overwriting old scrobbles :vrobbler:personal:bug:books:scrobbling:
: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.

File diff suppressed because one or more lines are too long

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

22
justfile Normal file
View File

@ -0,0 +1,22 @@
dj-port := "0.0.0.0:" + env_var_or_default("DJANGO_PORT", "8000")
default:
@just --list
django:
poetry run python manage.py runserver {{dj-port}}
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

5682
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 = "42.0"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]
[tool.poetry.dependencies]
python = ">=3.9,<3.12"
python = ">=3.11,<3.15"
Django = "^4.0.3"
django-extensions = "^3.1.5"
python-dateutil = "^2.8.2"
@ -16,8 +16,8 @@ httpx = "<=0.27.2"
djangorestframework = "^3.13.1"
Markdown = "^3.3.6"
django-filter = "^21.1"
Pillow = "^9.0.1"
psycopg2 = "^2.9.3"
Pillow = "^10.0.0"
psycopg2 = "2.9.10"
dj-database-url = "^0.5.0"
django-mathfilters = "^1.0.0"
django-allauth = "^0.50.0"
@ -28,7 +28,6 @@ django-markdownify = "^0.9.1"
gunicorn = "^20.1.0"
django-simple-history = "^3.1.1"
musicbrainzngs = "^0.7.1"
cinemagoer = "^2022.12.27"
pysportsdb = "^0.1.0"
pytz = "^2022.7.1"
django-redis = "^5.2.0"
@ -41,11 +40,11 @@ beautifulsoup4 = "^4.11.2"
django-storages = "^1.13.2"
stream-sqlite = "^0.0.41"
ipython = "^8.14.0"
pendulum = "^2.1.2"
pendulum = "^3"
trafilatura = "^1.6.3"
django-imagekit = "^5.0.0"
thefuzz = "^0.22.1"
dataclass-wizard = "0.22.0"
dataclass-wizard = "^0.35.0"
webdavclient3 = "^3.14.6"
boto3 = "^1.35.37"
urllib3 = "<2"
@ -58,6 +57,11 @@ tmdbv3api = "^1.9.0"
themoviedb = "^1.0.2"
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
@ -81,13 +85,12 @@ types-requests = "^2.27"
bandit = "^1.7.4"
[tool.pytest.ini_options]
minversion = "6.0"
addopts = "-ra -q --reuse-db"
addopts = "-ra -q --reuse-db --no-migrations"
testpaths = ["tests"]
DJANGO_SETTINGS_MODULE='vrobbler.settings-testing'
[tool.black]
line-length = 79
line-length = 88
target-version = ["py39", "py310"]
include = ".py$"
exclude = "migrations"

27
scripts/README.org Normal file
View File

@ -0,0 +1,27 @@
#+title: Readme
Scripts are a collection of helpful utility scripts, or simple gut-check tests for various functional pieces.
* test_recipe_scraper.py
Asserts various urls by making actual calls out to the internet, while our test suite mocks return values.
#+begin_src shell
python ../manage.py shell < ../scripts/test_recipe_scraper.py
#+end_src
#+RESULTS:
| Eagerly | running | all | tasks |
| Connected | to | sqlite@db.sqlite3 | |
| Checking: | https://cookingwithmike.com/quinoa-meatloaf/ | | |
| Checking: | https://www.kingarthurbaking.com/recipes/overnight-sourdough-waffles-recipe | | |
| Checking: | https://dirt.fyi/article/2026/02/25-years-of-ipod-brain?src=longreads | | |
* test_koreader_import.py
Run through an actual koreader sqlite file and make sure imports work as expected
#+begin_src shell
rm db.sqlite3
cp ../db.sqlite3 .
python ../manage.py shell < ../scripts/test_koreader_import.py
#+end_src

Binary file not shown.

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

@ -0,0 +1,6 @@
#!/usr/bin/env python3
from books.koreader import process_koreader_sqlite_file
process_koreader_sqlite_file("./koreader-test.sqlite3", 1)

View File

@ -0,0 +1,21 @@
import requests
from foods.sources.rscraper import (
RecipeScraperService,
)
test_urls = {
"https://cookingwithmike.com/quinoa-meatloaf/": True,
"https://www.kingarthurbaking.com/recipes/overnight-sourdough-waffles-recipe": True,
"https://dirt.fyi/article/2026/02/25-years-of-ipod-brain?src=longreads": False,
"https://tastesbetterfromscratch.com/belgian-waffles/": True,
}
for k, v in test_urls.items():
html = requests.get(k).text
print("Checking: ", k)
if v:
assert RecipeScraperService().is_recipe(html, k)
else:
assert not RecipeScraperService().is_recipe(html, k)

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

@ -1,3 +1,4 @@
import pytest
from boardgames.bgg import (
take_first,
lookup_boardgame_id_from_bgg,
@ -5,12 +6,14 @@ from boardgames.bgg import (
)
@pytest.mark.skip(reason="Deprecated library")
def test_take_first():
assert take_first([]) == ""
assert take_first(["a", "b"]) == "a"
@pytest.mark.skip(reason="Deprecated library")
def test_lookup_boardgame_id_from_bgg():
bgg_id = lookup_boardgame_id_from_bgg("Cosmic Encounter")
assert bgg_id == "15"
@ -19,6 +22,7 @@ def test_lookup_boardgame_id_from_bgg():
assert bgg_id == None
@pytest.mark.skip(reason="Deprecated library")
def test_lookup_boardgame_from_bgg():
bgg_result = lookup_boardgame_from_bgg(15)
assert bgg_result.get("bggeek_id") == 15

View File

View File

@ -0,0 +1,186 @@
import pytest
from foods.sources.rscraper import (
RecipeScraperService,
)
RECIPE_HTML_WITH_SCHEMA = """
<!DOCTYPE html>
<html>
<head>
<script type="application/ld+json">
{
"@context": "https://schema.org/",
"@type": "Recipe",
"name": "Test Recipe",
"author": {
"@type": "Person",
"name": "Test Author"
},
"recipeIngredient": ["1 cup flour", "2 eggs", "1/2 cup sugar"],
"recipeInstructions": [
{
"@type": "HowToStep",
"text": "Mix ingredients together"
}
],
"totalTime": "PT30M",
"recipeYield": "4 servings"
}
</script>
</head>
<body>
<h1>Test Recipe</h1>
</body>
</html>
"""
RECIPE_HTML_WITHOUT_SCHEMA = """
<!DOCTYPE html>
<html>
<head>
<title>Not a Recipe Page</title>
</head>
<body>
<h1>Welcome to My Blog</h1>
<p>This is just a regular blog post about cooking.</p>
</body>
</html>
"""
RECIPE_HTML_WITH_MICRODATA = """
<!DOCTYPE html>
<html>
<head>
<title>Test Recipe</title>
</head>
<body itemscope itemtype="http://schema.org/Recipe">
<h1 itemprop="name">Microdata Recipe</h1>
<div itemprop="author" itemscope itemtype="http://schema.org/Person">
<span itemprop="name">Test Author</span>
</div>
<div itemprop="recipeIngredient">1 cup flour</div>
<div itemprop="recipeIngredient">2 eggs</div>
<div itemprop="recipeInstructions">
<div itemprop="text">Mix all ingredients</div>
</div>
</body>
</html>
"""
class TestRecipeScraperService:
@pytest.fixture
def scraper(self):
return RecipeScraperService()
def test_is_recipe_with_valid_schema(self, scraper):
result = scraper.is_recipe(
RECIPE_HTML_WITH_SCHEMA, "https://example.com/recipe"
)
assert result is True
def test_is_recipe_without_schema(self, scraper):
result = scraper.is_recipe(
RECIPE_HTML_WITHOUT_SCHEMA, "https://example.com/blog"
)
assert result is False
def test_is_recipe_with_microdata(self, scraper):
result = scraper.is_recipe(
RECIPE_HTML_WITH_MICRODATA, "https://example.com/recipe"
)
assert result is True
def test_scrape_returns_title(self, scraper):
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")
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")
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")
assert result["yields"] == "4 servings"
def test_scrape_returns_total_time(self, scraper):
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")
assert result["url"] == "https://example.com/recipe"
def test_scrape_raises_on_invalid_html(self, scraper):
with pytest.raises(ValueError):
scraper.scrape("", "https://example.com/recipe")
def test_scrape_handles_missing_optional_fields(self, scraper):
minimal_html = """
<!DOCTYPE html>
<html>
<head>
<script type="application/ld+json">
{
"@context": "https://schema.org/",
"@type": "Recipe",
"name": "Minimal Recipe"
}
</script>
</head>
<body></body>
</html>
"""
result = scraper.scrape(minimal_html, "https://example.com/minimal")
assert result["title"] == "Minimal Recipe"
assert result["ingredients"] == []
assert result["instructions"] == []
def test_parse_servings(self, scraper):
assert scraper.parse_servings("4 servings") == 4
assert scraper.parse_servings("6 people") == 6
assert scraper.parse_servings("2") == 2
assert scraper.parse_servings("serves 8") == 8
assert scraper.parse_servings(None) is None
assert scraper.parse_servings("") is None
def test_extract_tags_from_cuisine(self, scraper):
recipe_data = {"cuisine": "Italian"}
tags = scraper.extract_tags(recipe_data)
assert "Italian" in tags
def test_extract_tags_from_cuisine_list(self, scraper):
recipe_data = {"cuisine": ["Italian", "Mexican"]}
tags = scraper.extract_tags(recipe_data)
assert "Italian" in tags
assert "Mexican" in tags
def test_extract_tags_from_dietary(self, scraper):
recipe_data = {"dietary": "Gluten-Free"}
tags = scraper.extract_tags(recipe_data)
assert "Gluten-Free" in tags
def test_extract_tags_from_course(self, scraper):
recipe_data = {"course": "Dessert"}
tags = scraper.extract_tags(recipe_data)
assert "Dessert" in tags
def test_extract_tags_from_keywords(self, scraper):
recipe_data = {"keywords": "easy, quick, healthy"}
tags = scraper.extract_tags(recipe_data)
assert "easy" in tags
assert "quick" in tags
assert "healthy" in tags
def test_extract_tags_from_keywords_list(self, scraper):
recipe_data = {"keywords": ["comfort food", "winter"]}
tags = scraper.extract_tags(recipe_data)
assert "comfort food" in tags
assert "winter" in tags

View File

@ -0,0 +1,133 @@
import pytest
from unittest.mock import patch
from foods.sources.usda import (
USDAFoodAPI,
NutritionCalculator,
)
class TestUSDAFoodAPI:
@pytest.fixture
def usda_api(self):
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")
def test_extract_nutrients_with_nutrient_number(self, usda_api):
food_data = {
"description": "Test Food",
"foodNutrients": [
{
"nutrientNumber": "203",
"nutrientName": "Protein",
"value": 10.0,
},
{
"nutrientNumber": "204",
"nutrientName": "Total lipid (fat)",
"value": 5.0,
},
{
"nutrientNumber": "205",
"nutrientName": "Carbohydrate, by difference",
"value": 20.0,
},
{
"nutrientNumber": "208",
"nutrientName": "Energy",
"value": 150.0,
},
{
"nutrientNumber": "269",
"nutrientName": "Sugars, total",
"value": 5.0,
},
],
}
result = usda_api.extract_nutrients(food_data)
assert result["protein"] == 10.0
assert result["fat"] == 5.0
assert result["carbohydrates"] == 20.0
assert result["calories"] == 150.0
assert result["sugar"] == 5.0
def test_extract_nutrients_with_nested_nutrient(self, usda_api):
food_data = {
"description": "Test Food",
"foodNutrients": [
{
"nutrient": {"id": 203, "name": "Protein"},
"value": 10.0,
},
],
}
result = usda_api.extract_nutrients(food_data)
assert result["protein"] == 10.0
def test_extract_nutrients_with_empty_nutrients(self, usda_api):
food_data = {"description": "Test Food", "foodNutrients": []}
result = usda_api.extract_nutrients(food_data)
assert result["protein"] == 0
assert result["calories"] == 0
def test_extract_nutrients_with_no_nutrients_key(self, usda_api):
food_data = {"description": "Test Food"}
result = usda_api.extract_nutrients(food_data)
assert result["protein"] == 0
class TestNutritionCalculator:
@pytest.fixture
def calculator(self):
with patch("vrobbler.apps.foods.sources.usda.USDAFoodAPI"):
return NutritionCalculator()
def test_parse_ingredient_with_fraction(self, calculator):
result = calculator.parse_ingredient("1/2 cup flour")
assert result["quantity"] == 0.5
assert result["unit"] == "cup"
assert result["ingredient"] == "flour"
def test_parse_ingredient_with_mixed_number(self, calculator):
result = calculator.parse_ingredient("1 1/2 cups sugar")
assert result["quantity"] == 1.5
assert result["unit"] == "cups"
assert result["ingredient"] == "sugar"
def test_parse_ingredient_with_decimal(self, calculator):
result = calculator.parse_ingredient("0.5 tsp salt")
assert result["quantity"] == 0.5
assert result["unit"] == "tsp"
assert result["ingredient"] == "salt"
def test_parse_ingredient_with_whole_number(self, calculator):
result = calculator.parse_ingredient("3 eggs")
assert result["quantity"] == 3
assert result["unit"] is None
assert result["ingredient"] == "eggs"
def test_parse_ingredient_with_no_quantity(self, calculator):
result = calculator.parse_ingredient("salt to taste")
assert result["quantity"] == 1
def test_clean_ingredient_name_removes_modifiers(self, calculator):
result = calculator._clean_ingredient_name("fresh chopped onions")
assert "fresh" not in result.lower()
assert "chopped" not in result.lower()
def test_clean_ingredient_name_removes_parentheses(self, calculator):
result = calculator._clean_ingredient_name("flour (sifted)")
assert "(" not in result
assert ")" not in result
def test_convert_to_grams_cup(self, calculator):
result = calculator._convert_to_grams(2, "cups", "flour")
assert result == 480
def test_convert_to_grams_tablespoon(self, calculator):
result = calculator._convert_to_grams(3, "tbsp", "olive oil")
assert result == 45
def test_convert_to_grams_unknown_unit(self, calculator):
result = calculator._convert_to_grams(1, "unknown", "something")
assert result == 100

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

@ -22,8 +22,18 @@ def boardgame_scrobble():
played_to_completion=True,
log={
"players": [
{"person_id": first.id, "win": True, "score": 30, "color": "Blue"},
{"person_id": second.id, "win": False, "score": 28, "color": "Red"}
{
"person_id": first.id,
"win": True,
"score": 30,
"color": "Blue",
},
{
"person_id": second.id,
"win": False,
"score": 28,
"color": "Red",
},
],
},
)
@ -34,7 +44,7 @@ def test_track():
Track.objects.create(
title="Emotion",
artist=Artist.objects.create(name="Carly Rae Jepsen"),
run_time_seconds=60,
base_run_time_seconds=60,
)
@ -58,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)
@ -103,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
@ -115,6 +121,7 @@ def mopidy_podcast_request_data():
mopidy_uri=mopidy_uri, artist="NPR", album="Up First"
).request_json
@pytest.fixture
def mopidy_podcast_https_request_data():
mopidy_uri = "podcast+https://feeds.npr.org/510318/podcast.xml#85b9c4c4-ae09-43d9-8853-31ccf43f68e6"
@ -122,6 +129,7 @@ def mopidy_podcast_https_request_data():
mopidy_uri=mopidy_uri, artist="NPR", album="Up First"
).request_json
class JellyfinTrackRequest:
name = "Emotion"
artist = "Carly Rae Jepsen"
@ -136,6 +144,7 @@ class JellyfinTrackRequest:
musicbrainz_album_id = "03b864cd-7761-314c-a892-05a89ddff00d"
musicbrainz_artist_id = "95f5b748-d370-47fe-85bd-0af2dc450bc0"
status = "resumed"
client_name = "Jellyfin"
def __init__(self, **kwargs):
self.request_data = {
@ -159,6 +168,7 @@ class JellyfinTrackRequest:
"musicbrainz_artist_id", self.musicbrainz_artist_id
),
"Status": kwargs.get("status", self.status),
"ClientName": kwargs.get("client_name", self.client_name),
}
def __eq__(self, other):

View File

@ -1,4 +1,5 @@
from datetime import datetime, timedelta
from unittest.mock import patch
import pytest
import time_machine
@ -6,30 +7,59 @@ 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
@patch("music.models.get_album_metadata_with_artist", return_value={})
@patch("music.models.get_track_metadata_with_artist", return_value={})
@patch("music.models.get_recording_mbid_exact", return_value=(None, None))
@patch("music.models.lookup_artist_from_tadb", return_value={})
@patch("music.models.lookup_album_from_tadb", return_value={})
@time_machine.travel(datetime(2022, 3, 4, 1, 24))
def test_scrobble_counts_data(client, mopidy_track):
build_scrobbles(client, mopidy_track.request_json)
user = get_user_model().objects.first()
def test_scrobble_counts_data(
mock_lookup_album_tadb,
mock_lookup_artist_tadb,
mock_get_recording,
mock_get_track,
mock_get_album,
client,
mopidy_track,
valid_auth_token,
):
user = build_scrobbles(
client, mopidy_track.request_json, auth_token=valid_auth_token
)
count_dict = scrobble_counts(user)
assert count_dict == {
"alltime": 7,
@ -41,10 +71,25 @@ def test_scrobble_counts_data(client, mopidy_track):
@pytest.mark.django_db
@patch("music.models.get_album_metadata_with_artist", return_value={})
@patch("music.models.get_track_metadata_with_artist", return_value={})
@patch("music.models.get_recording_mbid_exact", return_value=(None, None))
@patch("music.models.lookup_artist_from_tadb", return_value={})
@patch("music.models.lookup_album_from_tadb", return_value={})
@time_machine.travel(datetime(2022, 3, 4, 1, 24))
def test_live_charts(client, mopidy_track):
build_scrobbles(client, mopidy_track.request_json, 7, 1)
user = get_user_model().objects.first()
def test_live_charts(
mock_lookup_album_tadb,
mock_lookup_artist_tadb,
mock_get_recording,
mock_get_track,
mock_get_album,
client,
mopidy_track,
valid_auth_token,
):
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

@ -1,6 +1,6 @@
import pytest
#from scrobbles.dataclasses import BoardGameLogData, BoardGameScoreLogData
# from scrobbles.dataclasses import BoardGameLogData, BoardGameScoreLogData
@pytest.mark.skip("Need to get local tests running working again")
@ -19,7 +19,7 @@ def test_boardgame_log_data(boardgame_scrobble):
new=None,
rank=None,
seat_order=None,
role=None
role=None,
),
BoardGameScoreLogData(
person_id=2,
@ -32,7 +32,7 @@ def test_boardgame_log_data(boardgame_scrobble):
new=None,
rank=None,
seat_order=None,
role=None
role=None,
),
],
difficulty=None,

View File

@ -0,0 +1,59 @@
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 = {
"ItemType": "Audio",
"PlaybackPosition": "00:00:00",
"NotificationType": "PlaybackProgress",
}
result = jellyfin_scrobble_media(post_data, 1)
assert result is None
def test_mopidy_scrobble_handles_missing_mopidy_uri():
with patch("scrobbles.scrobblers.Track") as mock_track_class:
with patch("scrobbles.scrobblers.parse_mopidy_uri", return_value=None):
mock_track = MagicMock()
mock_track.scrobble_for_user = MagicMock(return_value=MagicMock())
mock_track_class.find_or_create.return_value = mock_track
post_data = {
"name": "Test Song",
"artist": "Test Artist",
"album": "Test Album",
"run_time": 180000,
}
result = mopidy_scrobble_media(post_data, 1)
mock_track_class.find_or_create.assert_called_once_with(
title="Test Song",
artist_name="Test Artist",
album_name="Test Album",
run_time_seconds=180000,
)

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,13 +1,15 @@
from datetime import datetime, timedelta
from unittest.mock import patch
from django.utils import timezone
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
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
@pytest.mark.django_db
@ -18,11 +20,21 @@ def test_get_not_allowed_from_mopidy(client, valid_auth_token):
assert response.status_code == 405
@pytest.mark.django_db
def test_get_not_allowed_from_jellyfin(client, valid_auth_token):
url = reverse("scrobbles:jellyfin-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_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"]
@ -30,6 +42,345 @@ def test_bad_mopidy_request_data(client, valid_auth_token):
)
@pytest.mark.django_db
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, "not valid json", content_type="application/json", headers=headers
)
assert response.status_code == 400
assert (
response.data["detail"]
== "JSON parse error - Expecting value: line 1 column 1 (char 0)"
)
@pytest.mark.django_db
@patch("music.models.Artist.find_or_create")
@patch("music.models.Track.find_or_create")
def test_create_scrobble_from_mopidy_track_webhook(
mock_track_fc,
mock_artist_fc,
client,
valid_auth_token,
mopidy_track,
):
mock_artist = MagicMock(spec=Artist)
mock_artist.id = 1
mock_artist_fc.return_value = mock_artist
mock_track = MagicMock(spec=Track)
mock_track.id = 1
mock_track.scrobble_for_user.return_value = Scrobble(
id=1, track_id=1, user_id=1, in_progress=True
)
mock_track_fc.return_value = mock_track
url = reverse("scrobbles:mopidy-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
response = client.post(
url,
mopidy_track.request_data,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
assert response.data == {"scrobble_id": 1}
mock_track.scrobble_for_user.assert_called_once()
@pytest.mark.django_db
@patch("music.models.Artist.find_or_create")
@patch("music.models.Track.find_or_create")
def test_create_scrobble_from_jellyfin_track_webhook(
mock_track_fc,
mock_artist_fc,
client,
valid_auth_token,
jellyfin_track,
):
mock_artist = MagicMock(spec=Artist)
mock_artist.id = 1
mock_artist_fc.return_value = mock_artist
mock_track = MagicMock(spec=Track)
mock_track.id = 1
mock_track.scrobble_for_user.return_value = Scrobble(
id=1, track_id=1, user_id=1, in_progress=True
)
mock_track_fc.return_value = mock_track
url = reverse("scrobbles:jellyfin-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
with time_machine.travel(datetime(2024, 1, 14, 12, 00, 1)):
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
"%Y-%m-%d %H:%M:%S"
)
response = client.post(
url,
jellyfin_track.request_json,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
assert response.data == {"scrobble_id": 1}
mock_track.scrobble_for_user.assert_called_once()
@pytest.mark.django_db
@patch("music.models.Artist.find_or_create")
@patch("music.models.Track.find_or_create")
def test_mopidy_track_webhook_creates_track_and_scrobble(
mock_track_fc,
mock_artist_fc,
client,
valid_auth_token,
mopidy_track,
):
artist = Artist.objects.create(name="Sublime")
album = Album.objects.create(name="Sublime", album_artist=artist)
track = Track.objects.create(
title="Same in the End",
artist_fk=artist,
base_run_time_seconds=60,
)
track.artists.add(artist)
track.albums.add(album)
mock_artist_fc.return_value = artist
mock_track_fc.return_value = track
url = reverse("scrobbles:mopidy-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
response = client.post(
url,
mopidy_track.request_data,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
assert response.data == {"scrobble_id": 1}
scrobble = Scrobble.objects.get(id=1)
assert scrobble.track == track
assert scrobble.source == "Mopidy"
@pytest.mark.django_db
@patch("music.models.Artist.find_or_create")
@patch("music.models.Track.find_or_create")
def test_jellyfin_track_webhook_creates_track_and_scrobble(
mock_track_fc,
mock_artist_fc,
client,
valid_auth_token,
jellyfin_track,
):
artist = Artist.objects.create(name="Carly Rae Jepsen")
album = Album.objects.create(name="Emotion", album_artist=artist)
track = Track.objects.create(
title="Emotion",
artist_fk=artist,
base_run_time_seconds=60,
)
track.artists.add(artist)
track.albums.add(album)
mock_artist_fc.return_value = artist
mock_track_fc.return_value = track
url = reverse("scrobbles:jellyfin-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
with time_machine.travel(datetime(2024, 1, 14, 12, 00, 1)):
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
"%Y-%m-%d %H:%M:%S"
)
response = client.post(
url,
jellyfin_track.request_json,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
assert response.data == {"scrobble_id": 1}
scrobble = Scrobble.objects.get(id=1)
assert scrobble.track == track
assert scrobble.source == "Jellyfin"
assert "raw_data" in scrobble.log
@pytest.mark.django_db
@patch("music.models.Artist.find_or_create")
@patch("music.models.Track.find_or_create")
def test_mopidy_track_webhook_stores_raw_data(
mock_track_fc,
mock_artist_fc,
client,
valid_auth_token,
mopidy_track,
):
artist = Artist.objects.create(name="Sublime")
album = Album.objects.create(name="Sublime", album_artist=artist)
track = Track.objects.create(
title="Same in the End",
artist_fk=artist,
base_run_time_seconds=60,
)
track.artists.add(artist)
track.albums.add(album)
mock_artist_fc.return_value = artist
mock_track_fc.return_value = track
url = reverse("scrobbles:mopidy-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
response = client.post(
url,
mopidy_track.request_data,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
assert response.data == {"scrobble_id": 1}
scrobble = Scrobble.objects.get(id=1)
assert scrobble.track == track
assert scrobble.source == "Mopidy"
assert "raw_data" in scrobble.log
assert scrobble.log["raw_data"]["name"] == "Same in the End"
@pytest.mark.django_db
@patch("music.models.Artist.find_or_create")
@patch("music.models.Track.find_or_create")
def test_mopidy_track_webhook_stores_album_id(
mock_track_fc,
mock_artist_fc,
client,
valid_auth_token,
mopidy_track,
):
artist = Artist.objects.create(name="Sublime")
album = Album.objects.create(name="Sublime", album_artist=artist)
track = Track.objects.create(
title="Same in the End",
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
url = reverse("scrobbles:mopidy-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
response = client.post(
url,
mopidy_track.request_data,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
scrobble = Scrobble.objects.get(id=1)
assert "album_id" in scrobble.log
assert scrobble.log["album_id"] == album.id
assert "album" not in scrobble.log
@pytest.mark.django_db
@patch("music.models.Artist.find_or_create")
@patch("music.models.Track.find_or_create")
def test_jellyfin_track_webhook_stores_raw_data(
mock_track_fc,
mock_artist_fc,
client,
valid_auth_token,
jellyfin_track,
):
artist = Artist.objects.create(name="Carly Rae Jepsen")
album = Album.objects.create(name="Emotion", album_artist=artist)
track = Track.objects.create(
title="Emotion",
artist_fk=artist,
base_run_time_seconds=60,
)
track.artists.add(artist)
track.albums.add(album)
mock_artist_fc.return_value = artist
mock_track_fc.return_value = track
url = reverse("scrobbles:jellyfin-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
with time_machine.travel(datetime(2024, 1, 14, 12, 00, 1)):
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
"%Y-%m-%d %H:%M:%S"
)
response = client.post(
url,
jellyfin_track.request_json,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
scrobble = Scrobble.objects.get(id=1)
assert scrobble.track == track
assert scrobble.source == "Jellyfin"
assert "raw_data" in scrobble.log
assert scrobble.log["raw_data"]["Name"] == "Emotion"
@pytest.mark.django_db
@patch("music.models.Artist.find_or_create")
@patch("music.models.Track.find_or_create")
def test_jellyfin_track_webhook_stores_album_id(
mock_track_fc,
mock_artist_fc,
client,
valid_auth_token,
jellyfin_track,
):
artist = Artist.objects.create(name="Carly Rae Jepsen")
album = Album.objects.create(name="Emotion", album_artist=artist)
track = Track.objects.create(
title="Emotion",
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
url = reverse("scrobbles:jellyfin-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
with time_machine.travel(datetime(2024, 1, 14, 12, 00, 1)):
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
"%Y-%m-%d %H:%M:%S"
)
response = client.post(
url,
jellyfin_track.request_json,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
scrobble = Scrobble.objects.get(id=1)
assert "album_id" in scrobble.log
assert scrobble.log["album_id"] == album.id
@pytest.mark.skip("Need to refactor")
@pytest.mark.django_db
@patch("music.utils.lookup_artist_from_mb", return_value={})
@ -146,9 +497,118 @@ def test_scrobble_jellyfin_track(
assert response.status_code == 200
assert response.data == {"scrobble_id": 1}
scrobble = Scrobble.objects.get(id=1)
assert scrobble.media_obj.__class__ == Track
assert scrobble.media_obj.title == "Emotion"
scrobble = Scrobble.objects.get(id=1)
assert scrobble.media_obj.__class__ == Track
assert scrobble.media_obj.title == "Emotion"
@pytest.mark.django_db
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")
scrobble = Scrobble.objects.create(
task=task,
media_type="Task",
user=user,
log={
"notes": ["First note", "Second note"],
"description": "Test description",
},
)
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
response = client.get(url)
assert response.status_code == 200
assert "First note" in response.content.decode()
assert "Second note" in response.content.decode()
@pytest.mark.django_db
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")
scrobble = Scrobble.objects.create(
task=task,
media_type="Task",
user=user,
log={
"notes": [
{"2024-01-01 10:00:00": "Note at first timestamp"},
{"2024-01-02 11:30:00": "Note at second timestamp"},
],
"description": "Test description",
},
)
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
response = client.get(url)
assert response.status_code == 200
content = response.content.decode()
assert "2024-01-01 10:00:00" in content
assert "Note at first timestamp" in content
assert "2024-01-02 11:30:00" in content
assert "Note at second timestamp" in content
@pytest.mark.django_db
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")
scrobble = Scrobble.objects.create(
task=task,
media_type="Task",
user=user,
log={
"notes": [
{"2024-01-01 10:00:00": "Note with label"},
],
"labels": ["work", "urgent"],
"description": "Test description",
},
)
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
response = client.get(url)
assert response.status_code == 200
content = response.content.decode()
assert "work" in content
assert "urgent" in content
@pytest.mark.django_db
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")
scrobble = Scrobble.objects.create(
task=task,
media_type="Task",
user=user,
log={
"notes": ["Original note"],
"description": "Original description",
},
)
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
client.force_login(user)
response = client.post(
url,
{
"description": "Updated description",
"notes": "Updated note",
},
)
assert response.status_code == 302
scrobble.refresh_from_db()
assert scrobble.log["description"] == "Updated description"
assert isinstance(scrobble.log["notes"], dict)
assert list(scrobble.log["notes"].values()) == ["Updated note"]
@pytest.mark.skip("Need to refactor")
@ -250,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

@ -0,0 +1,161 @@
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from vrobbler.context_processors import version_info
@pytest.fixture
def mock_request():
return MagicMock()
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.subprocess.check_output"
) as mock_check_output,
):
mock_get_version.return_value = "1.0.0"
mock_check_output.return_value = b"abc1234"
# 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"
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,
):
mock_get_version.return_value = "1.0.0"
result = version_info(mock_request)
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.subprocess.check_output"
) as mock_check_output,
):
mock_get_version.side_effect = Exception("not found")
mock_check_output.return_value = b"abc1234"
result = version_info(mock_request)
assert result["app_version"] == "unknown"
def test_returns_unknown_when_git_fails(self, mock_request):
import subprocess
with (
patch("vrobbler.context_processors.get_version") as mock_get_version,
patch(
"vrobbler.context_processors.subprocess.check_output"
) as mock_check_output,
):
mock_get_version.return_value = "1.0.0"
mock_check_output.side_effect = subprocess.SubprocessError()
result = version_info(mock_request)
assert result["git_commit"] == "unknown"
def test_returns_unknown_when_git_not_found(self, mock_request):
import subprocess
with (
patch("vrobbler.context_processors.get_version") as mock_get_version,
patch(
"vrobbler.context_processors.subprocess.check_output"
) as mock_check_output,
):
mock_get_version.return_value = "1.0.0"
mock_check_output.side_effect = FileNotFoundError()
result = version_info(mock_request)
assert result["git_commit"] == "unknown"

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

@ -0,0 +1,98 @@
import pytest
from django.contrib.auth import get_user_model
from rest_framework.authtoken.models import Token
from videos.models import Channel, Series, Video
User = get_user_model()
@pytest.fixture
def auth_headers():
user = User.objects.create(email="api@test.com")
token = Token.objects.create(user=user)
return {"HTTP_AUTHORIZATION": f"Token {token.key}"}
@pytest.fixture
def channel():
return Channel.objects.create(name="Test Channel")
@pytest.fixture
def series():
return Series.objects.create(
name="Test Series",
)
@pytest.fixture
def video(channel):
return Video.objects.create(
title="Test Video",
imdb_id="tt1234567",
channel=channel,
)
@pytest.mark.django_db
class TestVideoAPI:
def test_list_videos(self, client, auth_headers, video):
response = client.get("/api/v1/videos/", **auth_headers)
assert response.status_code == 200
assert len(response.data["results"]) == 1
assert response.data["results"][0]["title"] == "Test Video"
def test_get_video(self, client, auth_headers, video):
response = client.get(f"/api/v1/videos/{video.id}/", **auth_headers)
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)
assert response.status_code == 200
assert len(response.data["results"]) == 1
assert response.data["results"][0]["channel"] == channel.id
@pytest.mark.django_db
class TestChannelAPI:
def test_list_channels(self, client, auth_headers, channel):
response = client.get("/api/v1/channels/", **auth_headers)
assert response.status_code == 200
assert len(response.data["results"]) == 1
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)
assert response.status_code == 200
assert response.data["name"] == "Test Channel"
@pytest.mark.django_db
class TestSeriesAPI:
def test_list_series(self, client, auth_headers, series):
response = client.get("/api/v1/series/", **auth_headers)
assert response.status_code == 200
assert len(response.data["results"]) == 1
assert response.data["results"][0]["name"] == "Test Series"
def test_get_series(self, client, auth_headers, series):
response = client.get(f"/api/v1/series/{series.id}/", **auth_headers)
assert response.status_code == 200
assert response.data["name"] == "Test Series"
@pytest.mark.django_db
class TestVideoAPIUnauthorized:
def test_list_videos_unauthenticated(self, client, video):
response = client.get("/api/v1/videos/")
assert response.status_code == 401
def test_list_channels_unauthenticated(self, client, channel):
response = client.get("/api/v1/channels/")
assert response.status_code == 401
def test_list_series_unauthenticated(self, client, series):
response = client.get("/api/v1/series/")
assert response.status_code == 401

View File

@ -1,6 +0,0 @@
from videos.sources.imdb import lookup_video_from_imdb
def test_lookup_imdb():
metadata = lookup_video_from_imdb("8946378")
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

View File

@ -0,0 +1,20 @@
from rest_framework import serializers
from beers.models import Beer, BeerProducer, BeerStyle
class BeerSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Beer
fields = "__all__"
class BeerProducerSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = BeerProducer
fields = "__all__"
class BeerStyleSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = BeerStyle
fields = "__all__"

View File

@ -0,0 +1,21 @@
from rest_framework import permissions, viewsets
from beers.api import serializers
from beers import models
class BeerViewSet(viewsets.ModelViewSet):
queryset = models.Beer.objects.all().order_by("-created")
serializer_class = serializers.BeerSerializer
permission_classes = [permissions.IsAuthenticated]
class BeerProducerViewSet(viewsets.ModelViewSet):
queryset = models.BeerProducer.objects.all().order_by("-created")
serializer_class = serializers.BeerProducerSerializer
permission_classes = [permissions.IsAuthenticated]
class BeerStyleViewSet(viewsets.ModelViewSet):
queryset = models.BeerStyle.objects.all().order_by("-created")
serializer_class = serializers.BeerStyleSerializer
permission_classes = [permissions.IsAuthenticated]

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.19 on 2025-10-30 01:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('beers', '0005_alter_beer_run_time_seconds'),
]
operations = [
migrations.RemoveField(
model_name='beer',
name='run_time_seconds',
),
migrations.RemoveField(
model_name='beer',
name='run_time_ticks',
),
migrations.AddField(
model_name='beer',
name='base_run_time_seconds',
field=models.IntegerField(blank=True, null=True),
),
]

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

@ -0,0 +1,26 @@
from boardgames import models
from rest_framework import serializers
class BoardGameDesignerSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.BoardGameDesigner
fields = "__all__"
class BoardGamePublisherSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.BoardGamePublisher
fields = "__all__"
class BoardGameLocationSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.BoardGameLocation
fields = "__all__"
class BoardGameSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.BoardGame
fields = "__all__"

View File

@ -0,0 +1,28 @@
from rest_framework import permissions, viewsets
from boardgames.api import serializers
from boardgames import models
class BoardGameDesignerViewSet(viewsets.ModelViewSet):
queryset = models.BoardGameDesigner.objects.all().order_by("-created")
serializer_class = serializers.BoardGameDesignerSerializer
permission_classes = [permissions.IsAuthenticated]
class BoardGamePublisherViewSet(viewsets.ModelViewSet):
queryset = models.BoardGamePublisher.objects.all().order_by("-created")
serializer_class = serializers.BoardGamePublisherSerializer
permission_classes = [permissions.IsAuthenticated]
class BoardGameLocationViewSet(viewsets.ModelViewSet):
queryset = models.BoardGameLocation.objects.all().order_by("-created")
serializer_class = serializers.BoardGameLocationSerializer
permission_classes = [permissions.IsAuthenticated]
class BoardGameViewSet(viewsets.ModelViewSet):
queryset = models.BoardGame.objects.all().order_by("-created")
serializer_class = serializers.BoardGameSerializer
permission_classes = [permissions.IsAuthenticated]

View File

@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Optional
import requests
from bs4 import BeautifulSoup
from django.contrib.auth import get_user_model
from django.conf import settings
User = get_user_model()
if TYPE_CHECKING:
@ -13,10 +14,13 @@ 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 = {
"User-Agent": "Vrobbler 31.0",
"Authorization": f"Bearer {BGG_ACCESS_TOKEN}",
}
def take_first(thing: Optional[list]) -> str:
@ -37,10 +41,9 @@ def take_first(thing: Optional[list]) -> str:
def lookup_boardgame_id_from_bgg(title: str) -> Optional[int]:
soup = None
headers = {"User-Agent": "Vrobbler 0.11.12"}
game_id = None
url = SEARCH_ID_URL.format(query=title)
r = requests.get(url, headers=headers)
r = requests.get(url, headers=BASE_HEADERS)
if r.status_code == 200:
soup = BeautifulSoup(r.text, "xml")
@ -57,7 +60,6 @@ def lookup_boardgame_id_from_bgg(title: str) -> Optional[int]:
def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
soup = None
game_dict = {}
headers = {"User-Agent": "Vrobbler 0.11.12"}
title = ""
bgg_id = None
@ -73,7 +75,7 @@ def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
bgg_id = lookup_boardgame_id_from_bgg(title)
url = GAME_ID_URL.format(id=bgg_id)
r = requests.get(url, headers=headers)
r = requests.get(url, headers=BASE_HEADERS)
if r.status_code == 200:
soup = BeautifulSoup(r.text, "xml")
@ -109,7 +111,8 @@ def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
login_payload = {
"credentials": {"username": bgg_username, "password": bgg_password}
}
headers = {"content-type": "application/json"}
headers = BASE_HEADERS
headers["content-type"] = "application/json"
# TODO Look up past plays for scrobble.media_obj.bggeek_id, and make sure we haven't scrobbled this before

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.19 on 2025-10-30 01:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boardgames', '0010_boardgame_published_year'),
]
operations = [
migrations.RemoveField(
model_name='boardgame',
name='run_time_seconds',
),
migrations.RemoveField(
model_name='boardgame',
name='run_time_ticks',
),
migrations.AddField(
model_name='boardgame',
name='base_run_time_seconds',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.19 on 2025-11-03 04:34
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boardgames', '0011_remove_boardgame_run_time_seconds_and_more'),
]
operations = [
migrations.AddField(
model_name='boardgame',
name='bgg_rank',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.19 on 2025-11-03 04:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('boardgames', '0012_boardgame_bgg_rank'),
]
operations = [
migrations.AddField(
model_name='boardgame',
name='publishers',
field=models.ManyToManyField(related_name='board_games', to='boardgames.boardgamepublisher'),
),
]

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

@ -1,13 +1,13 @@
from functools import cached_property
import logging
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from functools import cached_property
from typing import Any, Optional
from uuid import uuid4
from django import forms
import requests
from boardgames.bgg import lookup_boardgame_from_bgg
from boardgames.sources.bgg import lookup_boardgame_from_bgg
from django import forms
from django.conf import settings
from django.core.files.base import ContentFile
from django.db import models
@ -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",
@ -191,6 +211,10 @@ class BoardGame(ScrobblableMixin):
publisher = models.ForeignKey(
BoardGamePublisher, **BNULL, on_delete=models.DO_NOTHING
)
publishers = models.ManyToManyField(
BoardGamePublisher,
related_name="board_games",
)
designers = models.ManyToManyField(
BoardGameDesigner,
related_name="board_games",
@ -224,6 +248,7 @@ class BoardGame(ScrobblableMixin):
options={"quality": 75},
)
rating = models.FloatField(**BNULL)
bgg_rank = models.IntegerField(**BNULL)
max_players = models.PositiveSmallIntegerField(**BNULL)
min_players = models.PositiveSmallIntegerField(**BNULL)
published_date = models.DateField(**BNULL)
@ -245,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):
@ -272,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))
@ -301,29 +323,69 @@ class BoardGame(ScrobblableMixin):
# Go get cover image if the URL is present
if cover_url and not self.cover:
headers = {"User-Agent": "Vrobbler 0.11.12"}
r = requests.get(cover_url, headers=headers)
logger.debug(r.status_code)
if r.status_code == 200:
fname = f"{self.title}_cover_{self.uuid}.jpg"
self.cover.save(fname, ContentFile(r.content), save=True)
logger.debug("Loaded cover image from BGGeek")
self.save_image_from_url(cover_url)
def save_image_from_url(self, url):
headers = {"User-Agent": "Vrobbler 0.11.12"}
r = requests.get(url, headers=headers)
if r.status_code == 200:
fname = f"{self.title}_cover_{self.uuid}.jpg"
self.cover.save(fname, ContentFile(r.content), save=True)
@classmethod
def find_or_create(
cls, lookup_id: str, data: Optional[dict] = {}
) -> Optional["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"""
boardgame = cls.objects.filter(bggeek_id=lookup_id).first()
game = cls.objects.filter(bggeek_id=lookup_id).first()
if not game:
game = cls.objects.filter(title=lookup_id).first()
if not data or not boardgame:
data = lookup_boardgame_from_bgg(lookup_id)
if data and not boardgame:
boardgame, created = cls.objects.get_or_create(
title=data["title"], bggeek_id=lookup_id
if game:
logger.info(
"Board game exists in database.",
extra={"lookup_id": lookup_id, "data": data},
)
if created:
boardgame.fix_metadata(data=data)
return game
return boardgame
if data.get("bggId"):
bgg_data = lookup_boardgame_from_bgg(lookup_id=data.get("bggId"))
elif data.get("name"):
bgg_data = lookup_boardgame_from_bgg(title=data.get("name"))
else:
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", [])
categories = bgg_data.pop("categories", [])
publishers = bgg_data.pop("publishers", [])
publisher = bgg_data.pop("publisher", [])
cover_url = bgg_data.pop("cover_url")
game = cls.objects.create(**bgg_data)
game.save_image_from_url(cover_url)
game.cooperative = data.get("cooperative", False)
game.highest_wins = data.get("highestWins", True)
game.no_points = data.get("noPoints", False)
game.uses_teams = data.get("useTeams", False)
game.bgstats_id = data.get("uuid", None)
if publisher:
publisher, _ = BoardGamePublisher.objects.get_or_create(name=publisher)
game.publisher = publisher
game.save()
if designers:
for designer_name in designers:
designer, created = BoardGameDesigner.objects.get_or_create(
name=designer_name
)
game.designers.add(designer.id)
if publishers:
for name in publishers:
publisher, _ = BoardGamePublisher.objects.get_or_create(name=name)
game.publishers.add(publisher)
return game

View File

@ -0,0 +1,40 @@
from typing import Any, Union
from boardgamegeek import BGGClient
from django.conf import settings
def lookup_boardgame_from_bgg(
lookup_id: str | None = None, title: str | None = None
) -> dict[str, Any]:
game_dict: dict[str, Any] = {}
bgg = BGGClient(access_token=settings.BGG_ACCESS_TOKEN)
if lookup_id:
game = bgg.game(game_id=lookup_id)
else:
game = bgg.game(title)
if game:
game_dict["title"] = game.name
game_dict["description"] = game.description
game_dict["published_year"] = game.yearpublished
game_dict["cover_url"] = game.image
game_dict["min_players"] = game.minplayers
game_dict["max_players"] = game.maxplayers
game_dict["recommended_age"] = game.minage
game_dict["rating"] = game.rating_average
game_dict["bggeek_id"] = game.id
game_dict["bgg_rank"] = game.bgg_rank
game_dict["base_run_time_seconds"] = (
int(game.playingtime) * 60 if game.playingtime else None
)
game_dict["mechanics"] = game.mechanics
game_dict["categories"] = game.categories
game_dict["designers"] = game.designers
game_dict["publishers"] = game.publishers
if game.publishers:
game_dict["publisher"] = game.publishers[0]
return game_dict

View File

@ -11,16 +11,14 @@ 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")
if created:
chess.run_time_seconds = 1800
chess.base_run_time_seconds = 1800
chess.bggeek_id = 171
chess.save(update_fields=["run_time_seconds", "bggeek_id"])
chess.save(update_fields=["base_run_time_seconds", "bggeek_id"])
scrobble = Scrobble.objects.filter(
user_id=user.id,
timestamp=game_dict.get("createdAt"),
@ -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

@ -1,13 +1,70 @@
import datetime
from django.utils import timezone
from django.views import generic
from boardgames.models import BoardGame, BoardGamePublisher
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
from boardgames.models import BoardGame, BoardGameDesigner, BoardGamePublisher
from scrobbles.models import Scrobble
from scrobbles.views import (
ChartContextMixin,
ScrobbleableListView,
ScrobbleableDetailView,
)
class BoardGameListView(ScrobbleableListView):
model = BoardGame
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
user = self.request.user
now = timezone.now()
start_day_of_week = now - datetime.timedelta(days=now.weekday())
start_day_of_month = now.replace(day=1)
class BoardGameDetailView(ScrobbleableDetailView):
scrobbles_this_week = Scrobble.objects.filter(
user=user,
board_game__isnull=False,
timestamp__gte=start_day_of_week,
).select_related("board_game")
scrobbles_this_month = Scrobble.objects.filter(
user=user,
board_game__isnull=False,
timestamp__gte=start_day_of_month,
).select_related("board_game")
designers_this_week = {}
for scrobble in scrobbles_this_week:
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)
+ 1,
}
designers_this_month = {}
for scrobble in scrobbles_this_month:
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)
+ 1,
}
context_data["designers_this_week"] = sorted(
designers_this_week.values(),
key=lambda x: x["count"],
reverse=True,
)
context_data["designers_this_month"] = sorted(
designers_this_month.values(),
key=lambda x: x["count"],
reverse=True,
)
return context_data
class BoardGameDetailView(ScrobbleableDetailView, ChartContextMixin):
model = BoardGame

View File

@ -21,7 +21,8 @@ class BookAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"title",
"subtitle",
"author",
"issue_or_volume",
"isbn_13",
"first_publish_year",
"pages",
@ -32,6 +33,9 @@ class BookAdmin(admin.ModelAdmin):
ScrobbleInline,
]
def issue_or_volume(self, obj):
return obj.issue_number or obj.volume_number
@admin.register(Paper)
class BookAdmin(admin.ModelAdmin):

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,19 +1,16 @@
from rest_framework import permissions, viewsets
from books.api.serializers import (
AuthorSerializer,
BookSerializer,
)
from books.models import Author, Book
from books.api import serializers
from books import models
class AuthorViewSet(viewsets.ModelViewSet):
queryset = Author.objects.all().order_by("-created")
serializer_class = AuthorSerializer
queryset = models.Author.objects.all().order_by("-created")
serializer_class = serializers.AuthorSerializer
permission_classes = [permissions.IsAuthenticated]
class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.all().order_by("-created")
serializer_class = BookSerializer
queryset = models.Book.objects.all().order_by("-created")
serializer_class = serializers.BookSerializer
permission_classes = [permissions.IsAuthenticated]

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

@ -114,7 +114,7 @@ def create_book_from_row(row: list):
"raw_row_data": clean_row,
}
},
run_time_seconds=run_time,
base_run_time_seconds=run_time,
)
# TODO Move these to async processes after importing
# book.fix_metadata()
@ -139,7 +139,6 @@ def build_book_map(rows) -> dict:
book_id_map = {}
for book_row in rows:
if book_row[KoReaderBookColumn.TITLE.value] in BOOKS_TITLES_TO_IGNORE:
logger.info(
"[build_book_map] Ignoring book title that is likely garbage",
@ -147,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:
@ -203,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 = []
@ -241,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"
@ -252,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:
@ -286,11 +277,11 @@ def build_scrobbles_from_book_map(
)
# Adjust for Daylight Saving Time
if timestamp.dst() == timedelta(
0
) or stop_timestamp.dst() == timedelta(0):
timestamp = timestamp - timedelta(hours=1)
stop_timestamp = stop_timestamp - timedelta(hours=1)
# if timestamp.dst() == timedelta(
# 0
# ) or stop_timestamp.dst() == timedelta(0):
# timestamp = timestamp - timedelta(hours=1)
# stop_timestamp = stop_timestamp - timedelta(hours=1)
scrobble = Scrobble.objects.filter(
timestamp=timestamp,
@ -307,6 +298,10 @@ def build_scrobbles_from_book_map(
"page_data": scrobble_page_data,
"pages_read": len(scrobble_page_data.keys()),
}
if hasattr(timestamp.tzinfo, "tzname"):
tz = timestamp.tzinfo.tzname
if hasattr(timestamp.tzinfo, "name"):
tz = timestamp.tzinfo.name
scrobbles_to_create.append(
Scrobble(
book_id=book_id,
@ -320,7 +315,7 @@ def build_scrobbles_from_book_map(
in_progress=False,
played_to_completion=True,
long_play_complete=False,
timezone=timestamp.tzinfo.name,
timezone=tz,
)
)
# Then start over
@ -378,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

@ -14,14 +14,13 @@ logger = logging.getLogger(__name__)
# Grace period between page reads for it to be a new scrobble
SESSION_GAP_SECONDS = 1800 # a half hour
def update_scrobble_from_page_data(scrobble, commit=True):
page_list = list(scrobble.book_page_data.items())
first_page_start_ts = datetime.fromtimestamp(page_list[0][1]["start_ts"])
last_page_end_ts = datetime.fromtimestamp(page_list[-1][1]["end_ts"])
if (
datetime(2023, 10, 15) <= first_page_start_ts <= datetime(2023, 12, 15)
):
if datetime(2023, 10, 15) <= first_page_start_ts <= datetime(2023, 12, 15):
first_page_start_ts.replace(tzinfo=pytz.timezone("Europe/Paris"))
last_page_end_ts.replace(tzinfo=pytz.timezone("Europe/Paris"))
else:

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,11 +1,5 @@
from enum import Enum
from typing import Optional
import pendulum
YOUTUBE_VIDEO_URL = "https://www.youtube.com/watch?v="
IMDB_VIDEO_URL = "https://www.imdb.com/title/tt"
class BookType:
...

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.19 on 2025-10-22 16:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('books', '0029_book_comicvine_id_book_issue_number_and_more'),
]
operations = [
migrations.AddField(
model_name='book',
name='readcomics_url',
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.19 on 2025-10-22 17:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('books', '0030_book_readcomics_url'),
]
operations = [
migrations.AddField(
model_name='book',
name='next_readcomics_url',
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -0,0 +1,39 @@
# Generated by Django 4.2.19 on 2025-10-30 01:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('books', '0031_book_next_readcomics_url'),
]
operations = [
migrations.RemoveField(
model_name='book',
name='run_time_seconds',
),
migrations.RemoveField(
model_name='book',
name='run_time_ticks',
),
migrations.RemoveField(
model_name='paper',
name='run_time_seconds',
),
migrations.RemoveField(
model_name='paper',
name='run_time_ticks',
),
migrations.AddField(
model_name='book',
name='base_run_time_seconds',
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='paper',
name='base_run_time_seconds',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.29 on 2026-03-08 05:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("books", "0032_remove_book_run_time_seconds_and_more"),
]
operations = [
migrations.AlterField(
model_name="book",
name="issue_number",
field=models.IntegerField(blank=True, null=True),
),
migrations.AlterField(
model_name="book",
name="volume_number",
field=models.IntegerField(blank=True, null=True),
),
]

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

@ -1,15 +1,31 @@
import logging
from collections import OrderedDict
from dataclasses import dataclass
import logging
from datetime import datetime
from typing import Optional
from uuid import uuid4
import requests
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
from django.contrib.auth import get_user_model
from django.core.files.base import ContentFile
@ -18,27 +34,15 @@ from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from scrobbles.dataclasses import BaseLogData, LongPlayLogData
from scrobbles.mixins import (
LongPlayScrobblableMixin,
ObjectWithGenres,
ScrobblableConstants,
)
from scrobbles.utils import get_scrobbles_for_media
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.sources.comicvine import (
ComicVineClient,
lookup_comic_from_comicvine,
)
from vrobbler.apps.books.locg import (
lookup_comic_by_locg_slug,
lookup_comic_from_locg,
lookup_comic_writer_by_locg_slug,
)
from books.sources.google import lookup_book_from_google
from books.sources.semantic import lookup_paper_from_semantic
from scrobbles.dataclasses import BaseLogData, LongPlayLogData
COMICVINE_API_KEY = getattr(settings, "COMICVINE_API_KEY", "")
@ -62,6 +66,7 @@ class BookLogData(BaseLogData, LongPlayLogData):
pages_read: Optional[int] = None
page_start: Optional[int] = None
page_end: Optional[int] = None
resume_url: Optional[str] = None
_excluded_fields = {"koreader_hash", "page_data"}
@ -103,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)
@ -130,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)
@ -148,8 +149,10 @@ class Book(LongPlayScrobblableMixin):
first_sentence = models.TextField(**BNULL)
# ComicVine
comicvine_id = models.CharField(max_length=255, **BNULL)
issue_number = models.IntegerField(max_length=5, **BNULL)
volume_number = models.IntegerField(max_length=5, **BNULL)
readcomics_url = models.CharField(max_length=255, **BNULL)
next_readcomics_url = models.CharField(max_length=255, **BNULL)
issue_number = models.IntegerField(**BNULL)
volume_number = models.IntegerField(**BNULL)
# OpenLibrary
openlibrary_id = models.CharField(max_length=255, **BNULL)
cover = models.ImageField(upload_to="books/covers/", **BNULL)
@ -167,11 +170,22 @@ class Book(LongPlayScrobblableMixin):
)
summary = models.TextField(**BNULL)
genre = TaggableManager(through=ObjectWithGenres)
genre = TaggableManager(through=ObjectWithGenres, blank=True, verbose_name="Genre")
def __str__(self):
def __str__(self) -> str:
if self.issue_number and "Issue" not in str(self.title):
return f"{self.title} - Issue {self.issue_number}"
if self.volume_number and "Volume" not in str(self.title):
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}"
@ -194,12 +208,17 @@ 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) -> "Book":
def get_from_comicvine(
cls, title: str, overwrite: bool = False, force_new: bool = False
) -> "Book":
book, created = cls.objects.get_or_create(title=title)
if not created and not overwrite and not force_new:
book, created = cls.objects.get_or_create(original_title=title)
logger.info("Found comic by original title, use force_new=True to override")
if not created:
return book
book_dict = lookup_comic_from_comicvine(title)
@ -233,7 +252,12 @@ class Book(LongPlayScrobblableMixin):
@classmethod
def find_or_create(
cls, title: str, enrich: bool = False, commit: bool = True
cls,
title: str,
author: str | None = None,
url: str = "",
enrich: bool = True,
commit: bool = True,
):
"""Given a title, get a Book instance.
@ -244,11 +268,9 @@ class Book(LongPlayScrobblableMixin):
like to batch create, use commit=False and you'll get an unsaved but enriched
instance back which you can then save at your convenience."""
# TODO use either a Google Books id identifier or author name like for tracks
book, created = cls.objects.get_or_create(title=title)
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(
@ -257,21 +279,42 @@ class Book(LongPlayScrobblableMixin):
)
return book
book_dict = lookup_book_from_google(title)
if not book_dict or book_dict.get("isbn_10"):
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(
"No book found in any source, using data as is",
extra={"title": title},
)
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
@ -284,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
@ -305,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:
@ -358,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')}"
)
@ -370,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)
@ -385,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.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)
@ -406,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):
@ -424,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 = {}
@ -434,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(
@ -475,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)
@ -498,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:

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