Compare commits

...

1401 Commits
0.11.8 ... 44.0

Author SHA1 Message Date
7a504e45de [release] Bump to version 44.0
All checks were successful
build / test (push) Successful in 1m56s
deploy / test (push) Successful in 2m1s
deploy / build-and-deploy (push) Successful in 44s
- Add favorite feature for scrobbles
2026-06-05 11:26:39 -04:00
7618d0ba30 [tooling] Add full push back to justfile
Some checks failed
build / test (push) Has been cancelled
2026-06-05 11:25:48 -04:00
ce4dc40033 [favorites] Add ability to favorite and add to Mopidy
Some checks failed
build / test (push) Has been cancelled
2026-06-05 11:24:26 -04:00
b0b22b79dc [release] Bump to version 43.0
All checks were successful
build / test (push) Successful in 1m56s
deploy / test (push) Successful in 1m56s
deploy / build-and-deploy (push) Successful in 33s
- Can we show a graph of all past Weigh-in tasks
- When viewing scrobbles by tag, sum the total time
- Orgmode tasks are not updated if in progress
- Ignore tag 'inprogress' for Tasks
- Deploys are now throwing an unknown version error
2026-06-04 22:13:36 -04:00
6471413681 [tasks] Add weigh in graph 2026-06-04 22:13:14 -04:00
50b10689fc [scrobbles] Add total time to tag views 2026-06-04 22:01:35 -04:00
85bddb6cba [tasks] Better updating of org mode tasks 2026-06-04 15:16:44 -04:00
c285b0d3b3 [tasks] Exclude inpgrogress tag, they're always inprogress
All checks were successful
build / test (push) Successful in 2m14s
2026-06-04 14:45:10 -04:00
671fe8d86f [tooling] Fix releases once and for all 2026-06-04 14:44:19 -04:00
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
59e8339e94 [releases] Fix comic books scrobbling, mostly 2025-10-20 17:17:18 -04:00
9277db97e5 [books] Fix comic scrobbles overrwriting one another 2025-10-20 17:15:54 -04:00
e755dc6641 Fix bug where title not found 2025-10-20 17:02:52 -04:00
782f5c15d6 [books] Calc stats and dont die when title not found 2025-10-20 17:02:34 -04:00
2f4fae7d02 [books] Short circut google lookup if it fails 2025-10-20 16:12:01 -04:00
4b7c5aa58d [books] Fix bad lookups for creating books 2025-10-20 16:11:20 -04:00
d4f82f2d6f [releases] Adding comic reading 2025-10-20 15:51:07 -04:00
106d25c20f [webpages] Redirect back to the page 2025-10-20 15:46:28 -04:00
d77caa2783 [scrobblers] Allow stopping reading comics 2025-10-20 15:46:10 -04:00
b5bfad73ef [books] Allow comic scrobbling to update per page 2025-10-20 15:41:02 -04:00
274b2704ed [scrobbles] Clean up type in logs 2025-10-20 14:55:27 -04:00
80fcb6c002 [books] Clean up google searches 2025-10-20 14:54:53 -04:00
c6f3c90006 [releases] Catching up project with actual releases 2025-10-14 12:29:45 -04:00
387dee7d37 [podcasts] Hotfix looking up podcast data from feed URLS 2025-10-14 12:28:17 -04:00
188e899357 [podcasts] Fix podcast data 2025-10-14 11:55:22 -04:00
30b005fa46 [scrobbles] Stop any scrobble when stop is called 2025-10-14 11:51:33 -04:00
72f739ee5a [podcasts] Try to clean up lookups 2025-10-14 11:45:47 -04:00
56ee14512d [podcasts] Actually test new lookup method 2025-10-14 11:30:57 -04:00
8c947d35dd [release] Bump version to 27.0 2025-10-14 11:18:05 -04:00
61bab1f734 [podcasts] Clean up lookup and creation 2025-10-14 11:17:20 -04:00
42ce6df9bd [templates] Small fix to obj title missing 2025-10-14 10:58:54 -04:00
cbd46df4bc [scrobbles] Add Food and Geolocation to long play & manual comics 2025-10-14 10:58:31 -04:00
e7203cdb9b [podcasts] Add parsing of RSS feed urls 2025-10-14 10:55:09 -04:00
7246adfeb6 [project] Update and reorganize todos 2025-09-30 09:36:03 -04:00
a5606951c5 [templates] Fix missing Beer placeholder 2025-09-23 00:43:49 -04:00
0b4537b7ed [food] Add calories per day 2025-09-16 15:28:28 -04:00
6306390f82 [release] 26 2025-09-11 18:58:48 -04:00
350d3ceb14 [templates] Move moods around 2025-09-11 18:56:34 -04:00
a1ff82bfec [templates] Cleaning up templates and datalog forms 2025-09-11 18:55:45 -04:00
92c0c668b3 [locations] Add locations to dashboard 2025-09-11 18:29:28 -04:00
3b77feda45 [templates] Add links to scrobbles
Ultimately these should probably be templated
2025-09-11 18:03:35 -04:00
45c402f8c1 [music] Fix bug in generating log data form 2025-09-11 17:57:17 -04:00
90a1398438 [foods] Adjust fields on food data log 2025-09-11 10:44:48 -04:00
c7a81802ac [release] 25.0 2025-09-11 09:45:02 -04:00
a9a8678ac0 [project] Update toods 2025-09-11 09:43:23 -04:00
cbf0583871 [foods] Add calories to food model 2025-09-11 09:41:22 -04:00
5cac1fe109 [templates] Fix food templates and such 2025-09-11 09:41:12 -04:00
6782ed312d [templates] Add food to homepage 2025-09-11 09:33:34 -04:00
fda505ea4e [scrobbles] Fix calc of elapsed time 2025-09-11 09:33:29 -04:00
8db111f66f [food] Fix lookup for food object 2025-09-11 09:27:32 -04:00
ee1cae496a [music] Back and forth ... let us not use album name 2025-09-11 09:08:14 -04:00
9403c68184 [videos] Fix small bug in views 2025-09-11 09:06:07 -04:00
96030f4a99 [boardgames] Fix expansion checking 2025-09-11 09:05:50 -04:00
a8c3925af4 [project] Check off another task 2025-08-20 11:40:18 -04:00
a2f507a976 [videos] Wire up generic video list view 2025-08-20 11:39:27 -04:00
7a7edc6e47 [templates] Fix some bugs and clean up list views 2025-08-20 11:27:10 -04:00
af6c39fb85 [templates] Clean up task titles 2025-08-19 12:37:26 -04:00
36cfdd6f6c [release] 24.0 2025-08-19 00:57:52 -04:00
b11d87af75 [logdata] Janky fix to people and platform ids 2025-08-19 00:46:08 -04:00
1cf50209a4 [bricksets] Add templates 2025-08-18 20:41:16 -04:00
8a5486fb2c [templates] Remove sidebar, add dashboard links 2025-08-18 20:28:43 -04:00
135d6e65fa [templates] Make vrobbler to go home page 2025-08-17 20:23:02 -04:00
965f2dd41b [tasks] Use title, not description 2025-08-17 20:21:08 -04:00
1a1de02843 [release] 23 2025-08-17 12:41:10 -04:00
a1868e7b2c [project] Updating TODOs 2025-08-17 12:38:27 -04:00
52494651bf [scrobbles] Add dynamic forms for LogData classes 2025-08-17 12:38:11 -04:00
1093aa2376 [music] Fix getting album when duplicated name 2025-08-06 12:40:10 -04:00
d1f04c15a9 [music] Fix breaking on w. 2025-08-06 11:03:40 -04:00
fd3487c225 [tasks] A few little clean ups 2025-08-06 10:59:32 -04:00
df91526b0c [videogames] Fix showing platform in logdata 2025-08-05 10:18:10 -04:00
70f103db6f [boardgames] Remove print statements 2025-08-05 02:04:17 -04:00
b0b32821e3 [scrobbles] Clean up todoist logs too 2025-08-05 02:04:07 -04:00
278cab32ea [scrobbles] Start cleaning up logdata 2025-08-05 02:01:31 -04:00
06e075553a [scrobbles] CLean up some dataclasses 2025-08-05 01:56:20 -04:00
833368c8d7 [profiles] Clean up default task lookup 2025-08-05 01:56:03 -04:00
f70bab30d0 [scrobbles] Log errors when parsing fails 2025-08-05 00:13:44 -04:00
f230af89eb [videogames] Add scrobbles to views 2025-08-05 00:13:08 -04:00
bbc27209ab [templates] Clean up long play nonsense for video games 2025-08-05 00:12:47 -04:00
b7638c648a [templates] Fix video game detail page 2025-08-04 19:57:06 -04:00
c8926cf887 [scrobbles] Fix bug in mixin import 2025-08-03 11:33:44 -04:00
b8dd3ee258 [tests] Shim to fix broken import 2025-08-03 11:13:23 -04:00
dc965687c2 [puzzles] Add puzzles to homepage 2025-08-03 02:01:29 -04:00
ebc66bbf64 [puzzles] Add templates 2025-08-03 02:00:26 -04:00
d04db0ecb5 [scrobbles] Fix dataclass parsing and add puzzles to urls 2025-08-03 01:59:56 -04:00
fc72b23b11 [music] Fix timezones for TSV imports 2025-08-02 23:35:22 -04:00
a681b4d63b [notifications] Fix a few typos 2025-07-30 18:34:22 -04:00
c452ac24e0 [notifications] Send mood check-in 2025-07-30 18:30:18 -04:00
ae889bff7d [tasks] Fix bug in note str method 2025-07-30 17:50:59 -04:00
99dc86dc27 [moods] Fix mood list view 2025-07-30 16:05:48 -04:00
8eefcb8290 [tasks] Fix emacs metadata 2025-07-30 16:05:34 -04:00
ad0f9a54d0 [tasks] Fix dataclass models 2025-07-30 15:46:18 -04:00
1531b77b5c [tests] Fix metadata test 2025-07-30 13:59:11 -04:00
9437fdba60 [scrobbles] Fix log data parsing for tasks and boardgames
Add pagination to task and board game detail pages
2025-07-30 11:37:57 -04:00
a7551ef162 [music] Weird hack to get timezone for LFM scrobbles
Last.fm seems to send timestamps for scrobbles with a timezone of UTC
but the actual timezone is already localized. But that means we can't
extract the timezone we want, even though the timestamp is already in
the right timezone for storage.
2025-07-28 10:52:02 -04:00
c20204a6ea [music] Turns out lastfm already has our timeszone 2025-07-28 09:14:25 -04:00
685de842ea [views] Fix showing only a users scrobbles 2025-07-26 21:31:44 -04:00
7d13967708 [scrobbles] Fix admin filtering 2025-07-26 20:57:23 -04:00
109697a746 [project] Bump version 2025-07-26 10:19:34 -04:00
dde28f4aff [importers] Fix setting timezones before all imports 2025-07-26 10:18:43 -04:00
2f6ed3770f [books] Fix bad import after moving webdav to importers 2025-07-26 01:49:37 -04:00
e3d1cfb838 [books] Fix webdav importer 2025-07-25 23:40:39 -04:00
1821ac0d7b [project] Update tasks 2025-07-25 22:55:33 -04:00
4eb8289e55 [scrobbles] LastFM only creates import if there are imports 2025-07-25 22:53:31 -04:00
66e805542c [scrobbles] Add notification to board game imports 2025-07-25 21:28:08 -04:00
f91b127a2c [scrobbles] Allow skipping checks for existing scrobbles 2025-07-25 17:36:19 -04:00
b2077678e2 [music] Fix timezones on lastfm imports 2025-07-25 17:35:56 -04:00
5427198185 [profiles] Just black 2025-07-25 17:35:10 -04:00
2bdba14cd6 [boardgames] Fix import from imap for timezones 2025-07-25 17:34:57 -04:00
95d8c4e4d6 [profiles] Clean up timezone stuff 2025-07-25 17:33:59 -04:00
6ab7745151 [music] Use found name to look it up 2025-07-25 10:50:00 -04:00
8b062a6c1d [music] Add tracks migration 2025-07-25 10:44:06 -04:00
cd48e7a402 [boardgames] Fix migration path 2025-07-25 10:40:53 -04:00
22830b0cea [profiles] Fix extra newline in one off 2025-07-25 10:24:33 -04:00
fd36034f6d [templates] Fix album use and local_timestamp 2025-07-25 10:21:20 -04:00
edf9fbd9c1 [music] Reorganize importer and fix lookups 2025-07-25 10:20:49 -04:00
e8e989bb63 [music] Add albums to tracks and utility to condense tracks 2025-07-20 22:00:06 -04:00
69401d11c8 [importers] Reorganize importers a little 2025-07-20 16:43:50 -04:00
759caef45d [books] Move one off creator to profile utils 2025-07-20 16:31:20 -04:00
9514861b32 [tests] Skip failing tests 2025-07-19 21:09:51 -04:00
aa644aa9cf [project] Start adding features and update todos 2025-07-19 01:56:23 -04:00
94820b1d9c [scrobbles] Exclude geolocs from stop notifications 2025-07-19 01:56:03 -04:00
4db8793d5c [books] Allow timezone changes when importing from KOReader
Turns out you need a city-based timezone for DST stuff to work properly.
The US/Eastern timezone doesn't mess with DST because it can be so wonky
in different regions. So while we fix timezone defaulting to a
DST-friendly timezone too.
2025-07-19 01:54:27 -04:00
7c6e895ae4 [books] Start cleaning up get_from_google method 2025-07-09 13:46:06 -04:00
b1b67528bf [music] Fix find_or_create for tracks 2025-07-09 13:39:10 -04:00
dd54a33159 [profiles] Remove paswords from admin 2025-07-06 11:26:38 -04:00
92c4f91e5a [boardgames] Add check for learning plays from BG stats 2025-07-06 10:02:14 -04:00
838b19e996 [boardgames] Remove expansion_ids key if not needed 2025-07-06 00:01:01 -04:00
3808277025 [boardgames] Add comments and bgstats_id to scrobbles 2025-07-05 23:55:00 -04:00
f64863f2bc [scrobblers] Connect designers to board games 2025-07-03 22:02:27 -04:00
2c199c0e93 [boardgames] Check if scrobble exists first 2025-07-03 20:19:30 -04:00
4924ef316f [scrobbles] Add a check for Garmin emails 2025-07-03 16:26:29 -04:00
64cb17e91f [scrobbles] Add management command for imap 2025-07-03 14:59:33 -04:00
1fd325823b [boardgames] Clean up email parser to work with many plays 2025-07-03 00:34:51 -04:00
1590ce5f18 [boardgames] Adding email scrobbler for BG Stats 2025-07-02 23:01:13 -04:00
3548c29f97 [people] Add a more general people app 2025-07-02 11:00:06 -04:00
0fa831fa42 [boardgames] Start adding email scrobbling for board games 2025-07-02 10:55:24 -04:00
a2f64a98c3 [project] Update tasks 2025-07-02 10:29:13 -04:00
872ca17432 [tasks] Hackety hack 2025-07-02 09:29:15 -04:00
224c165d72 [tasks] Fix bad matching in label titles 2025-06-27 11:54:14 -04:00
bf7d2514f2 [tasks] Clean up param names and loggin 2025-06-27 11:50:30 -04:00
4e37bc5ab9 [tasks] Fix bug in looking up user profile 2025-06-27 11:42:37 -04:00
125da84f4e [tasks] Fix emacs scrobbling of tasks 2025-06-27 11:32:09 -04:00
36ceb4c7fe [release] 17.1 2025-06-27 10:52:50 -04:00
88a3831975 [tasks] Make tasks use user profile string 2025-06-27 10:48:05 -04:00
63361964ca [tasks] Add optional user context labels to get title 2025-06-27 10:37:09 -04:00
40b54b27f4 [tasks] Actually add the new utils file 2025-06-26 14:50:11 -04:00
a7eca4b9a7 [tasks] Actually get title consistently 2025-06-26 14:09:17 -04:00
d152412e99 [books] Add optional details key to log data for books 2025-06-25 10:21:26 -04:00
3ba6c6b6e4 [project] Add new bug in tasks
Though honestly, it will probably be a config setting in the Emacs hook,
but we'll see. Actually, maybe the emacs hook code should end up in this
repo somewhere 🤔
2025-06-24 14:25:20 -04:00
bffbf47c2f [release] 17.0 2025-06-24 10:56:14 -04:00
f4e81da533 [project] Completing task for label bug 2025-06-24 10:55:01 -04:00
4b7f5459be [project] Add new task for TV series bug 2025-06-24 10:53:57 -04:00
c68b0e9d7e [tasks] Fix bug in emacs labels 2025-06-24 10:52:47 -04:00
32ec65116b [tasks] Move to single context for task titles 2025-06-22 21:26:20 -04:00
da8d26fcd9 [trails] Add alltrails and gaiagps ids to model 2025-06-22 20:26:37 -04:00
d33954e494 [project] Tidy up todos 2025-06-17 10:53:26 -04:00
1b306d6493 [project] Move todos.org file to PROJECT.org 2025-06-17 10:50:30 -04:00
c881143e1b [todos] Update backlog with tasks from Todoist 2025-06-17 10:18:48 -04:00
141700fcb3 [music] Fixes RYM slug including commas 2025-06-16 09:38:19 -04:00
7357b5bfec [todos] Update todo formatting 2025-06-16 09:35:30 -04:00
99cabd0007 [todos] Finish another task! 2025-06-13 12:35:24 -04:00
cf77e12cc3 [tasks] Add description of task to the template 2025-06-13 12:34:55 -04:00
3f2cbbb34a [videos] Fix TMDB lookup for movies 2025-06-13 12:10:43 -04:00
650ecf12c6 Merge branch 'main' into develop 2025-06-13 11:55:04 -04:00
d7cc009d07 [ci] Make deploys on happen on tags 2025-06-13 11:52:14 -04:00
a872cf3611 [templates] Use TMDB rating ID if available 2025-06-13 11:47:42 -04:00
1f9713312b [videos] Fix lookup for mevie posters 2025-06-13 11:47:04 -04:00
159e555d7c [templates] Remove global counts from home page 2025-06-13 11:33:21 -04:00
981f4f9c9a [videos] Quick fix 2025-06-13 11:23:37 -04:00
ddd5ce1392 Merge branch 'develop' 2025-06-13 11:21:07 -04:00
7a75b31b56 [todos] Update task for metadata fixing 2025-06-13 11:20:34 -04:00
24ac545f55 [videos] Switch to TMDB for scraping videos 2025-06-13 11:19:15 -04:00
d5da8ae701 [todos] Update priority task 2025-06-13 09:43:08 -04:00
53a04d064d [videos] At least stop IMDB from breaking 2025-06-13 09:15:48 -04:00
c0871a3b9e [scrobbles] Fix failing tests 2025-06-12 09:57:32 -04:00
d917dd8b2c [tasks] Fix source checking for emacs/orgmode tasks when updating 2025-06-12 09:54:16 -04:00
6fc8084f2d [scrobbles] Fix log of media type 2025-06-11 12:01:02 -04:00
41d6fe8ff6 [todos] Add new task 2025-06-10 11:27:37 -04:00
73838312cd [tasks] Clean up emacs webhook flow 2025-06-10 11:06:47 -04:00
eb2dd4c839 [todos] Clean up todos and add new milestone 17 2025-06-10 10:05:40 -04:00
1a0c4f69f0 Merge branch 'develop' 2025-06-09 11:26:25 -04:00
356e579558 [brickset] Little fix to scrobble of bricksets 2025-06-09 10:40:12 -04:00
e980e3c5c9 [brickset] Fix manual scrobbling and admin 2025-06-09 10:36:13 -04:00
8773542099 [tasks] Fix source being orgmode not emacs 2025-06-08 03:05:43 -04:00
70378c9968 [tasks] Fix updating and stopping tasks from Emacs 2025-06-08 02:40:07 -04:00
c871087496 [tasks] First try at adding an emacs webhook 2025-06-08 01:09:03 -04:00
059d7780a0 [templates] Add counts and durations to new tables 2025-06-07 20:02:21 -04:00
db36329011 Merge branch 'develop' 2025-06-07 18:11:08 -04:00
69aa80e6c1 [templates] Allow going back and forward in time 2025-06-07 15:17:56 -04:00
99a6e5107b [scrobbles] Add orgparse and start of emacs webhook 2025-06-06 17:25:04 -04:00
39e2fdce27 [music] Fix allmusic lookup 2025-06-06 17:24:41 -04:00
b2f98d780b [middleware] Add auto timezone setting 2025-06-06 09:49:53 -04:00
07b5dc6a2c [templates] Clean up dashboard 2025-06-06 09:49:08 -04:00
7207ca385e [scrobbles] Add two new methods to Scrobble model 2025-06-06 09:48:15 -04:00
2a254e28d0 [templates] Start redesigning the dashboard 2025-05-18 23:20:47 -04:00
d4377e49ac [music] Fix getting album metadata 2025-05-18 23:19:17 -04:00
9dc0a818ff [templates] Fix chart loading 2025-05-18 23:19:05 -04:00
5e672fc9ed [webpages] Add title to webpage reading 2025-05-18 09:37:53 -04:00
6d194d227e Merge branch 'develop' 2025-05-11 01:43:55 -04:00
ed217cbad2 [beers] Blacken and clean up imports 2025-05-11 01:42:30 -04:00
3cca30dc70 [puzzles] Add puzzle lookup via IPDB 2025-05-11 01:41:43 -04:00
d3a15d5e7b Merge branch 'develop' 2025-05-11 00:03:48 -04:00
deeaa0af4b [puzzles] Fix misnamed ipdb field 2025-05-10 23:58:44 -04:00
159459e1b9 [puzzles] Add puzzle model and hooks 2025-05-10 23:48:40 -04:00
89b6d8de06 [importers] Fixes TSV comma missing 2025-05-10 23:48:40 -04:00
3c725de2ac [webpages] Fixes 500 errors when webpage lookup fails 2025-05-10 23:48:40 -04:00
dd71bdd38c [puzzles] Add puzzle model and hooks 2025-05-10 23:36:03 -04:00
d066a98282 [importers] Fixes TSV comma missing 2025-04-15 16:32:14 -04:00
73bc4a1cd1 [webpages] Fixes 500 errors when webpage lookup fails 2025-04-15 16:18:26 -04:00
79d58e6390 Merge branch 'develop' 2025-04-07 17:45:16 -04:00
69e9caf477 [release] Bump version to 0.16.1 2025-04-07 17:43:36 -04:00
776e839ca4 [ci] Fixes wrong public key 2025-04-07 17:42:31 -04:00
97792898df [release] Bump version to 0.16.0 2025-04-07 17:34:00 -04:00
9986163d20 [notifications] Fixes missing title 2025-04-07 17:34:00 -04:00
d1229585a1 [release] Bump version to 0.16.0 2025-04-07 17:31:50 -04:00
b8c5c3f3e9 Merge branch 'main' into develop 2025-04-07 16:57:54 -04:00
6d9e237f9b [notifications] Fixes missing title 2025-04-07 16:53:30 -04:00
e65a2d300d [ci] Fixes reference to wrong ssh key 2025-04-07 14:38:46 -04:00
f45541de6d [deps] Fixes premature bump to py312
Turns out our deployment uses python 3.11, so we need to roll back
the pillow and pendulum upgrades so they work with python 3.11 ... how
irritating to have all this version bump nonesense.
2025-04-07 14:27:28 -04:00
391f0cc335 [tests] Trying to parallel tests 2025-04-07 13:55:42 -04:00
4b5281bdd8 [cleaning] Adding migrations that are past due 2025-04-07 13:47:21 -04:00
484be0a64e [tests] Skip openlibrary lookup tests 2025-04-07 13:45:13 -04:00
257e6899d3 [tests] Clean up podcast tests 2025-04-07 13:40:32 -04:00
4767cc7e52 [podcasts] Fixes enrichment of podcasts with podcastindex 2025-04-07 13:32:03 -04:00
bcc3f46806 [tests] We dont differentiate on albums anymore 2025-04-07 12:39:57 -04:00
6c461ed55f [tests] Simplify view tests to not mess with time 2025-04-07 12:30:54 -04:00
28cf57c6dd [tests] Try fixing CI breaking with small seconds 2025-04-07 09:56:10 -04:00
0bb874f1db [scrobblers] Fixes test with wiggly second 2025-04-07 00:12:29 -04:00
27f50baf5d [music] Fixes missing jellyfin ts converter 2025-04-06 23:39:32 -04:00
499c3d6859 [music] Fix bug in scobblers for tracks 2025-04-06 22:40:33 -04:00
b0e9f13e11 [music] Attempts to fix bad lookups from LastFM and Jellyfin
Broader issue was creating tracks without albums that were duplicates of
existing tracks because sometimes Jellyfin and LastFM do not have albums
sent with them.
2025-04-06 22:28:32 -04:00
b2ee79b3ea [project] Update TODOs file 2025-04-04 11:00:28 -04:00
3ddd3b1684 [deps] Upgrades pillow to work with Python 3.11 2025-04-04 10:23:41 -04:00
4f8a359ab9 [deps] Upgrades pendulum to work with python 3.13 2025-04-02 22:14:24 -04:00
48114aee5e [scrobblers] Fix missing r in regex string 2025-04-02 22:14:11 -04:00
0cc87a2dbe [profiles] Add user profile context override 2025-04-02 22:13:43 -04:00
23d3e19db9 [scrobbles] Fixes typo in transaction import 2025-03-29 13:56:20 -04:00
29f5e2b940 [scrobbles] Add action parsing to scrobble url parsing
This also elaborates on the web scrobbler endpoint, though it does not
actually work at this time. But I'm tired of carrying this around in
stashes, so we'll push it and fix it later.
2025-03-29 13:52:46 -04:00
3208a32ffe [scrobbles] Fixes dedup utility, adding a transaction
Deleting tracks needs to be in a transaction so we don't try to delete a
track that may still have scrobbles associated with it.
2025-03-29 13:49:54 -04:00
99da9b62bf [music] Fixes bug where lastfm created new tracks
The issue here was that if we couldn't find a track from a musicbrainz
ID (which was almost never), we would fall back to just creating a new
track with a blank album. So we'd spam the track table with identical
tracks just no albums.

Now we do a quick check for the track where the title and artist match
and use that if it's found. This will result in some tracks being
associated with the wrong album, but I think that's better than the
current behavior.
2025-03-29 13:47:45 -04:00
36048d9a0a [music] Cleans up last.fm import logging
We weren't tracking import info very well in the original flow, this
should provide better insight into what happened after a run.
2025-03-29 13:44:48 -04:00
16091c9053 [tests] Skips the podcast scrobble tests, no Google Podcast 2025-03-27 16:50:42 -04:00
eec00ce658 [urls] Removes accidentaly committed URL updates
The modern_ui app is not ready yet, still sitting on a dev machine.

These url paths should not have been committed, so we'll just comment
them out for now.
2025-03-27 16:38:34 -04:00
e02010e409 [videos] Fix skip for youtube tests 2025-03-27 16:35:23 -04:00
498712e531 [drone] Update drone to install with test, rather than dev 2025-03-27 16:32:33 -04:00
14e4432495 [poetry] Update pyproject to only install test deps when needed
We'll also need to fast follow this with a new line in the drone
file otherwise CI will fail because pytest wont be installed by default
any longer.
2025-03-27 16:31:44 -04:00
676c40176c [notifications] Add a class for notifications 2025-03-25 09:50:14 -04:00
0a50bca622 [notifications] Add stop notification utility 2025-03-24 13:23:33 -04:00
ac9fc315b1 [books] Fix date lookup and literal string title search 2025-03-19 01:02:39 -04:00
50d1a4a2bd [videos] Fix youtube videos longer than an hour 2025-03-19 00:17:21 -04:00
444562235f [webpages] Handle title truncation better 2025-03-07 14:36:35 -05:00
760575e41d [webpages] Bail if we can't get text 2025-03-07 14:33:02 -05:00
42699f84d2 [tasks] Finish task based on timestamp, not now 2025-03-07 09:02:29 -05:00
b660e47bc2 [videos] Just a little type hinting 2025-02-25 21:09:02 -05:00
06b4ba8bcc [foods] Fix image bug in foods 2025-02-25 21:08:34 -05:00
1f67207f81 [books] Fix two bugs in looking up books 2025-02-25 21:07:45 -05:00
baa8dbee46 [boardgames] Modularize the Lichess imports 2025-02-25 11:09:29 -05:00
3f9cdcac65 [books] Clean up old page model and bug in page calc 2025-02-25 10:34:08 -05:00
71874510a4 [profiles] Actually make the form work 2025-02-25 01:17:12 -05:00
8c600d6b4b [books] Fix no result for detail lookup 2025-02-23 23:08:47 -05:00
e95b6f50dc [tests] Fix views and comment out youtube 2025-02-23 22:49:44 -05:00
93c16d80ec [profiles] Add settings form 2025-02-23 22:49:18 -05:00
b03da9ab37 [middleware] Add health check middleware 2025-02-21 10:40:44 -05:00
09ca05cb4f [make] Move deploys back to homelab 2025-02-20 23:27:47 -05:00
d6e02d241c [settings] Fix test settings 2025-02-20 23:27:31 -05:00
8dd94e2fc4 [books] Fix book admin 2025-02-20 23:27:22 -05:00
9e3f714c61 [books] Add papers as a data model 2025-02-20 23:07:56 -05:00
e2b0decd83 [utils] Add commit option to the dedupper 2025-02-17 17:36:43 -05:00
e08db5e3ad [github] Change when actions run 2025-02-10 09:33:38 -05:00
1460b9ba77 Fix py version for github actions 2025-02-10 09:32:07 -05:00
a41e0ffa5d Create django.yml for Github Actions 2025-02-10 09:30:34 -05:00
c51c75b6a6 [books] Handle authors from Google 2025-02-09 22:44:11 -05:00
041435bc93 [books] Use google and title to get book 2025-02-09 21:58:03 -05:00
15f27b73a5 [videos] Fix error where we add tt to the IMDB id 2025-02-03 00:32:22 -05:00
25900a9911 [boardgames] Send notifications on chess imports 2025-01-29 00:23:46 -05:00
4277e355e0 [boardgames] Few more little tweaks to imports 2025-01-29 00:19:40 -05:00
855f59b83f [boardgames] Fix small bug in black username 2025-01-29 00:08:55 -05:00
9c115c0b65 [boardgames] Add lichess importing 2025-01-29 00:04:49 -05:00
fd726a125f [books] Remove meta_yt references 2025-01-28 22:20:04 -05:00
6685669b29 [videos] Fix missing defaults for no tags 2025-01-27 14:58:47 -05:00
3fa43f02d0 [tasks] Add exercise and lifeevent to constants 2025-01-27 14:52:15 -05:00
66c34942e6 [videos] Youtube using the actual API 2025-01-27 14:51:56 -05:00
ef1fcd4026 [videos] Fix video type reference 2025-01-27 01:37:21 -05:00
a36efa3b1d [videos] Add check if youtube fails 2025-01-27 01:30:41 -05:00
300a2ae6aa [scrobblers] Add ability to stop via bookmarklet 2025-01-27 01:14:20 -05:00
1d5b91b6e2 [videos] Fix splitting youtube IDs 2025-01-27 00:41:03 -05:00
a5c67a7fe1 [videos] Clean up video tools 2025-01-26 23:41:41 -05:00
b16d0b1864 [vrobbler] Update poetry deps 2025-01-26 23:39:18 -05:00
f90a3b84a8 [books] Add google as a source and clean up data model 2025-01-26 23:39:02 -05:00
25a14ed9e7 [videos] Clean imdb getter up and fix series 2025-01-21 23:20:30 -05:00
84790c805c [migrations] Add default value to run time seconds 2025-01-21 23:20:09 -05:00
a9499f0463 [video] Fix youtube redirects 2025-01-21 22:49:15 -05:00
c109ed79eb [video] Update how we get video metadata for YT add 2025-01-20 09:13:18 -05:00
89e5455b29 [deps] Add meta-yt and updates 2024-11-25 13:55:30 -05:00
647762f201 [beers] Fix missing url path 2024-11-24 17:26:42 -05:00
b788cc65d1 [notifications] Fix finish url being on media 2024-11-24 09:59:18 -05:00
50ec82213c [foods] Fix migrations 2024-11-24 09:45:56 -05:00
51c1acd677 [notifications] Add click to finish 2024-11-24 09:39:44 -05:00
fd38046113 [foods] Add Food scrobbling 2024-11-20 14:16:44 -05:00
d294f2ecd1 [koreader] Fix list access bug 2024-11-19 20:46:25 -05:00
3c7940c6c6 [lastfm] Fix importing tracks 2024-11-18 15:12:20 -05:00
56c000154c [scrobbles] Simplify notifications and add to books 2024-11-18 14:40:05 -05:00
8157836b42 [lastfm] Fix issue with no media obj 2024-11-18 14:19:41 -05:00
af0e76b29c [utils] Add crud deduplicator 2024-11-17 22:02:19 -05:00
39004aac0c [make] Restart celery too 2024-11-17 21:01:43 -05:00
8cbd746681 [tasks] Fix import in utils 2024-11-17 21:00:27 -05:00
dee04a47cb [config] No need to load pypi pass everytime 2024-11-17 20:49:14 -05:00
1304a27408 [books] Add webdav koreader importer 2024-11-17 20:49:14 -05:00
2327b1f622 [notifications] Clean up emojis and fix priority 2024-11-08 10:51:44 -05:00
94fed8ae38 [tasks] Add research as a tag 2024-11-08 08:59:17 -05:00
042a26f148 [make] Add migrations to deploy 2024-11-06 14:50:15 -05:00
e606f0de01 [trails] Add trail and default activity type 2024-11-06 14:48:56 -05:00
ed2253cb6b [videos] Clean up admin stuff 2024-11-06 11:07:27 -05:00
9a1508b7a6 [videos] Add channel admin access 2024-11-06 11:03:16 -05:00
39f3a31847 [videos] Update youtubes stuff 2024-11-06 10:50:39 -05:00
f0b32961c1 [scrobbles] Can't just use subtitle for notifications 2024-11-06 09:00:21 -05:00
a08574b359 [videos] Add subtitle to notifications 2024-11-06 00:18:16 -05:00
26ebb4108d [videos] Try to fix auth on webhooks 2024-11-05 23:54:21 -05:00
1b95706f70 [videos] Add youtube scrobbling 2024-11-05 23:32:03 -05:00
b65ebbe397 [books] Fix koreader import with nul strings 2024-11-05 21:13:37 -05:00
0679af3029 [scrobbles] Fix if log does not exist 2024-11-05 18:47:34 -05:00
8888b42adf [scrobbles] Actually use notify str 2024-11-05 16:50:35 -05:00
b8d68739cf [scrobbles] Kidding, we use description 2024-11-05 16:46:14 -05:00
4c2b838d7b [scrobbles] Add details if we have them 2024-11-05 16:43:40 -05:00
cdb3c29844 [music] Fix timestamp setting in lastfm importer 2024-11-05 16:41:12 -05:00
22c07bdb82 [music] Fix lastfm importing 2024-11-05 16:33:28 -05:00
66513c5758 [scrobbles] Memos 2024-11-05 16:28:58 -05:00
f3c0d20268 [scrobbles] Fix ntfy formatting 2024-11-05 16:27:16 -05:00
622a30899f [music] Fix how we create tracks from LastFM 2024-11-05 14:47:30 -05:00
2c1e8c08ae [scrobbles] Add ntfy config to user profiles 2024-11-05 14:38:01 -05:00
cc52e00d15 [videos] Fix utils 2024-11-04 20:40:49 -05:00
e762658082 [deps] Update howlongtobeatpy 2024-11-04 15:30:50 -05:00
5111cee14b [videogames] Fix minor metadata look up bug 2024-11-04 15:30:00 -05:00
68a6d58339 [tsv] Try fixing our lookup when titla and MB id are none 2024-11-04 10:18:52 -05:00
b91c8b27d7 [tsv] Fix lookup to use new dict method 2024-11-04 09:55:38 -05:00
3a91aa5903 [tsv] Fix missing comma error 2024-11-04 09:45:13 -05:00
bfd6331be3 [vidoes] default run time seconds to 1800 2024-11-03 11:31:13 -05:00
38ba474c1f [beers] Fix abv lookup 2024-11-02 18:59:11 -04:00
76272b7e39 [beers] Fix rating error 2024-11-01 22:42:42 -04:00
1f0c950b17 [beers] Fix missing abv error 2024-11-01 22:39:46 -04:00
f2998205e1 [tasks] Remove data class until we fix it 2024-11-01 16:03:20 -04:00
38e108c1ae [tasks] Trying to fix logdata 2024-11-01 15:58:16 -04:00
dec100f8ff [make] Add basic makefile 2024-11-01 15:55:21 -04:00
e89eb332d3 [task] Try to fix logdata for tasks 2024-11-01 15:55:07 -04:00
cb5b279300 [templates] Fix missing logdata model 2024-11-01 15:42:47 -04:00
59d681dc00 [task] Ooops, add task id 2024-11-01 13:10:47 -04:00
82dcad569a [task] Log when we don't find a task for notes 2024-11-01 12:48:59 -04:00
d8aaf3bf55 [task] Little bug 2024-11-01 12:43:18 -04:00
29b92d89b2 [task] Try to fix scrobbling 2024-11-01 12:35:11 -04:00
86bcdef13d [task] Fix small bug 2024-11-01 12:12:30 -04:00
20e6ae7421 [tasks] Update checking for inprogress changed 2024-11-01 12:08:06 -04:00
4ed5117900 [tasks] Stop ignoring notes 2024-11-01 11:16:24 -04:00
3388471685 [tasks] Don't stop tasks when modified while in progress 2024-10-31 15:28:06 -04:00
58a957c98a [tasks] Add ability to save comments as notes on tasks 2024-10-25 15:10:35 -04:00
2ad626cd59 [beers] Add subtitles 2024-10-23 12:59:18 -04:00
6ce0257dc0 [beers] Cleaning up 2024-10-23 12:57:45 -04:00
43dbd3b28b [beers] Fix description tag typo 2024-10-23 12:55:37 -04:00
7671644e87 [beers] Allow scrobbling from URLS 2024-10-23 12:48:55 -04:00
f555a49746 [beers] Fix untappd and beeradvocate urls 2024-10-23 10:58:07 -04:00
9fe474978a [beers] Add manual scrobbling from URLs 2024-10-23 10:51:35 -04:00
9f8465d364 [beers] Fix a few more display stuffs 2024-10-22 17:58:39 -04:00
ddfddc33f5 [beers] Actually give producers names 2024-10-22 17:52:18 -04:00
0ec7ed3a18 [beers] Finish out the model 2024-10-22 17:47:38 -04:00
0bda3f6fd8 [beers] Fix error in cover reference, add adminf or beer producers 2024-10-22 17:44:46 -04:00
59765b14ca [beers] Connect beers with producers 2024-10-22 17:34:20 -04:00
08b48371bc [beers] Add beer scrobbling 2024-10-22 17:26:55 -04:00
d3d5b088cd [webdav] Add basic client 2024-10-20 16:22:56 -04:00
083e931a78 [webdav] Add crednetials to user profile 2024-10-20 16:06:24 -04:00
0f0fb7cceb [scrobbles] Add lookup of last serial scrobble 2024-10-20 14:35:42 -04:00
f2bbb7f5d0 [scrobblers] Use udpated_at key for Todoist scrobbles 2024-10-20 14:29:17 -04:00
218c68dee0 [books] Fix importing scrobbles from existing books 2024-10-18 14:50:46 -04:00
202cf24722 [books] Clean up koreader imports and data storage 2024-10-18 12:49:59 -04:00
1ddacd4454 [scrobbles] Improve webhook logging 2024-10-17 22:51:10 -04:00
2dbb091d61 [books] Fix importing to not use OL by default for now 2024-10-17 16:40:07 -04:00
dbaf189628 [scrobbles] Fix scrobbling duplicate tasks 2024-10-15 21:29:50 -04:00
4a0bac5b87 [scrobbles] Fix double scrobbling tasks 2024-10-15 19:34:27 -04:00
59b7e3dada [scrobbles] Add description to current task 2024-10-15 15:29:31 -04:00
24223ebe13 [scrobbles] Add working to status page 2024-10-15 15:27:48 -04:00
de6e9ce2d6 [tasks] We only care about in-progress for scrobbling 2024-10-15 15:23:15 -04:00
470eb0778a [tasks] Get last task, not first 2024-10-15 15:14:37 -04:00
f075492554 [tasks] Add Bug as suffix type 2024-10-15 15:12:58 -04:00
8fb2fac47f [tasks] Remove in-progress tag to stop 2024-10-15 15:11:03 -04:00
b1eac1454b [tasks] Don't create new scrobbles for in progress tasks 2024-10-15 15:07:43 -04:00
84737e0c3b [tasks] Add habit to suffix type 2024-10-15 15:02:01 -04:00
c4359a2331 [tasks] Fix catch for no scrobble available 2024-10-15 14:58:34 -04:00
8fa538dbee [tasks] Fix use of source_id 2024-10-15 14:56:26 -04:00
26d82518fa [tasks] Temporarily don't use updated_at 2024-10-15 14:52:58 -04:00
04a7ba51e4 [tasks] Add user id to Profile 2024-10-15 14:49:43 -04:00
ccf14c51bf [tasks] Fix webhook creating duplicates 2024-10-15 14:41:40 -04:00
b97aa8936e [scrobbles] Fix if past seconds is zero 2024-10-15 14:40:05 -04:00
98924e362e [tasks] Fix loading todoist data from webhook data 2024-10-15 14:37:15 -04:00
0384f72cbd [tasks] Fix webhook for Todoist 2024-10-15 14:25:15 -04:00
0c8a486b6a [tasks] Implement todoist webhooks #8479861260 2024-10-15 14:21:30 -04:00
7954765b73 [tasks] Add oauth flow for Todoist 2024-10-14 22:15:29 -04:00
7604327ca9 [oauth] Adding oauth pattern 2024-10-14 18:13:27 -04:00
20542ac7e9 [scrobbles] Fix tasks overwriting tasks 2024-10-11 18:34:13 -04:00
34137af815 [videos] Fix series lookup from IDMB too 2024-10-09 21:08:04 -04:00
0f2570e51b [videos] Fix imdb dict lookups 2024-10-09 20:31:43 -04:00
ed917e16fc [scrobbles] Fix when scrobble has not dict 2024-10-07 16:50:54 -04:00
164510b7b7 [tasks] Add media url link 2024-10-05 17:59:32 -04:00
6764023016 [tasks] Fix capitalization call 2024-10-05 16:07:19 -04:00
7c6c1cee6d [tasks] Fix scrobbling tasks 2024-10-05 15:49:22 -04:00
c251c5f413 [tasks] Allow scrobbling tasks from URLs 2024-10-05 15:08:38 -04:00
342e86d7fb [videos] Don't lookup video when we know what it is 2024-10-05 14:33:25 -04:00
176b698f6e [templates] Clean up tables 2024-10-02 17:51:02 -04:00
ddf2ca5630 [scrobbling] Actually fix typo in mopidy uri 2024-10-02 17:51:00 -04:00
d52061f6d8 [scrobbling] Actually fix typo in mopidy uri 2024-10-02 15:40:16 -04:00
3c0a75755b [tasks] Fix source url generation 2024-09-30 17:10:28 -04:00
183469ebe5 [tasks] Add tasks app 2024-09-30 15:05:07 -04:00
bbe8149e6c [scrobbles] Fix scrobbling from IMDB urls 2024-09-26 22:12:58 -04:00
f876caabe1 [scrobblers] Add mopidy source to scrobbler 2024-09-26 22:07:01 -04:00
87c078f47d [scrobbles] Allow scrobbling any content via URLs 2024-09-26 21:58:40 -04:00
5a9292e10a [videos] Fix looking up on IMDB 2024-09-26 21:58:39 -04:00
a5630022f5 [status] Add pocast playing to listening status 2024-09-16 12:42:39 -04:00
babc2aeb9d [boardgames] Fix missing default None for serial scrobble 2024-09-14 09:34:07 -04:00
875b0f98a0 [scrobblers] Fix manual vidoe lookup and simplify Sports 2024-09-12 20:16:05 -04:00
5d1edc71d7 [scrobbles] Add gpx file for trails 2024-09-11 13:31:48 -04:00
e4738e464f [trails] Finish hooking things up for trails 2024-09-09 17:20:10 -04:00
85c4963619 [templates] Fix trail template loading 2024-09-09 17:07:13 -04:00
8d6707db95 [templates] Clean up redundent templates and fix main menu 2024-09-09 16:58:33 -04:00
0df3dd728d [release] Bump to version 0.15.4 2024-09-09 13:05:17 -04:00
1fe8d8aa51 [boardgames] Add better list template and fix URLs (also for moods) 2024-09-09 12:56:42 -04:00
8f3c7beffa [trails] Make trail admin raw ID field 2024-09-09 12:12:39 -04:00
aac0efbb14 [misc] Cleaning up imports and urls for webpages 2024-09-09 12:12:08 -04:00
b0d4dd0899 [trails] Add new trails app 2024-09-09 12:11:39 -04:00
16db67ea84 [bgg] Add rudimentary bgg push scrobble script 2024-09-09 11:56:42 -04:00
7a747268a1 [lifevents] Remove redundant completion percentage 2024-09-09 11:56:20 -04:00
2037ffc67a [deps] Add webdav client library and fix boto3 2024-09-09 11:55:58 -04:00
2136c1562a [books] Fix calculation of current page 2024-09-08 14:13:40 -04:00
eae169aff7 [version] Bump to version 0.15.3 2024-09-08 00:53:19 -04:00
6c0bd2e409 [scrobbles] Clean up references to book_page_data 2024-09-08 00:52:50 -04:00
d69d7311d5 [version] Bump to 0.15.2 2024-09-08 00:45:36 -04:00
c484dab210 [books] Fix importing order of page data 2024-09-08 00:44:55 -04:00
3d62a6a227 [videos] Fix error looking up TV shows 2024-09-07 23:07:39 -04:00
1a5d4a6717 [scrobbles] Clean up dataclass inheritence 2024-09-07 11:36:36 -04:00
8f0491a90b [pyproject] Upgrade version to 0.15.0 2024-09-07 11:04:01 -04:00
7e961076b4 [bricksets] Add brick set scrobble type 2024-09-07 02:02:16 -04:00
14062f3b60 [videos] Mock in youtube lookup 2024-09-06 11:02:48 -04:00
0a8acdf33f [videos] Add skatevideo lookup properly 2024-09-06 11:01:45 -04:00
b5d194e74f [music] Fix jellyfin music scrobbling sort of 2024-09-06 10:27:04 -04:00
3a50a8b015 [videos] Fixing imdb lookups and making it more modular 2024-09-06 01:22:54 -04:00
921cf9d8b3 [videogames] Add log data to VideoGame model 2024-08-21 11:09:15 -04:00
ba0be65ed0 [scrobbles] Allow boardgame screenshots and clean up koreader fields 2024-08-20 20:10:32 -04:00
caad6329c9 [scrobbles] Fixing tests and breaking more 2024-08-19 18:59:37 -04:00
cfd6ac861e [scrobbles] Fix datalog return value and message 2024-08-19 12:27:47 -04:00
ca36e25948 [scrobbles] Fix datalog test case for board games 2024-08-19 12:12:42 -04:00
c84acf6ae7 [boardgames] Add str rep for player dataclass 2024-08-19 10:14:10 -04:00
610464e732 [scrobbles] Fix serializing logdata 2024-08-19 10:00:07 -04:00
047dd22069 [moods] Add mood fixture data 2024-08-19 09:46:32 -04:00
2c73121367 [scrobbles] Fix aggregator tests 2024-08-19 09:45:16 -04:00
9affd6e03a [scrobbles] Add some tests around jellyfin and start cleaning up 2024-08-16 12:04:09 -04:00
b414bbf59c [templates] Fix status template logdata call 2024-08-12 00:03:52 -05:00
5e22cb3106 [scrobbling] Refactor webhook and simplify 2024-08-12 00:03:30 -05:00
cc9a2a64df [settings] Fix missing moods app in testing settings 2024-08-10 23:30:38 -04:00
c2b334b926 [moods] Fix missing init file 2024-08-10 23:28:36 -04:00
ad6f0e1b62 [deps] Update poetry reqs to fix HLTB bug 2024-08-10 23:10:26 -04:00
1df8157d8d [music] Fix error when mb fails 2024-08-10 23:10:11 -04:00
7d33e6afdb [scrobbles] Fix migrations 2024-08-10 22:35:37 -04:00
40d112c58c [music] Fix failure when musicbrainz has no track 2024-08-10 22:14:07 -04:00
cdfd8af078 [views] Clean up base scrobblable views 2024-08-10 22:12:36 -04:00
0ce19527a2 [scrobbles] Fix mood starting, and clean up code 2024-08-10 22:12:36 -04:00
f3fc58e2c0 [moods] Add moods 2024-08-10 22:12:24 -04:00
b02b75fa90 [boardgames] Add BGG username to user profile 2024-08-07 14:52:26 -03:00
b626ac583a [books] Move book metadata into the log field 2024-08-07 14:51:21 -03:00
adc10ab43b [lifeevents] Fix bad metadata reference 2024-08-06 11:52:35 -03:00
5555b1b89e [lifeevents] Fix data class bug 2024-08-06 11:46:22 -03:00
7fd90f8cf0 [lifeevents] Clean up life event status with details 2024-08-06 11:35:04 -03:00
f80daba67b [boardgames] Update metadata for board game scrobbles 2024-08-05 12:27:42 -03:00
8bd4fd1d4b [webpags] Redirect to webpage url when we're done 2024-08-04 00:20:22 -04:00
f30e416e8d [webpages] Actually add the forms file 2024-08-04 00:11:48 -04:00
f29a039562 [webpages] Provide default iframe reading method 2024-08-03 23:49:31 -04:00
882367cee6 [views] Actually speed up the dashboard view 2024-07-23 16:35:48 -04:00
74c32bc4d5 [views] Move elaborate charts to the charts page 2024-07-23 16:24:59 -04:00
dcd7196010 Fix drone ntfy version 2024-07-14 01:15:27 -04:00
2e57b2ce07 Add dataclass wizard and fix dataclasses 2024-07-03 23:22:19 -04:00
9d664ba476 Update metadata to use JSONWizard 2024-07-03 15:53:27 -04:00
0fa89af1d9 [scrobbles] Fix timestamp log marker for locations 2024-06-07 01:27:58 -04:00
2c5516ae4e [scrobbles] Add life events to status page 2024-05-23 21:21:58 -04:00
0cba46b103 [scrobbles] Fix how we get our redirect url 2024-05-23 21:20:02 -04:00
5014c4428b [scrobbles] Allow junk in the scrobble log 2024-05-23 00:42:01 -04:00
1d554429f1 [scrobbles] Add metadata accessor 2024-05-23 00:37:20 -04:00
daea268465 Fix display of life events 2024-05-23 00:26:56 -04:00
1cceb2bd63 [lifeevents] Add metadata dataclasses 2024-05-23 00:16:24 -04:00
e15d253d58 [lifeevents] Add life events to scrobbles 2024-05-23 00:14:06 -04:00
8ed0bd3d21 [locations] Fix gps update log format 2024-05-05 22:13:15 -04:00
113f200eb7 [scrobbles] Move scrobble_log to log 2024-05-05 22:09:21 -04:00
1b5ffd2a3c [scrobbles] Fix pendulum bug 2024-04-24 14:06:49 -07:00
f168608cee [scrobbles] Fix scrobbling apocalypse 2024-04-22 14:27:33 -04:00
c28f93a1bb [scrobbles] Little reorg and trying to fix JF stop issue 2024-04-22 14:05:42 -04:00
99ad2f797f [scrobbles] Try to fix Jellyfin stop bug 2024-04-22 12:55:14 -04:00
1f28179ed1 [music] Fix lastfm import missing timezones 2024-04-21 22:36:21 -04:00
1648f988ff [videogames] Fix missing covers 2024-04-21 22:36:21 -04:00
dc3a77f14f [scrobbles] Fix typo in timezone saving 2024-04-21 13:29:36 -04:00
5994dccf23 [scrobbles] Clean up location provider log 2024-04-19 15:34:19 -04:00
bfadc0d87b [scrobbles] Just kidding, dt cannot be seriazlied 2024-04-19 15:28:20 -04:00
78782cc538 [scrobbles] Fix geoloc scrobble notes 2024-04-19 15:22:13 -04:00
06277f21de [scrobbles] Clean up inline admin 2024-04-19 15:13:07 -04:00
8fbe37a163 [scrobbles] Remove playback ticks 2024-04-19 15:01:15 -04:00
20dd4d217a [scrobbles] Remove source_id field 2024-04-19 14:57:27 -04:00
53657a9454 [videogames] Add template for video game list 2024-04-19 11:27:27 -04:00
42066cebb2 [scrobbles] Fix display of pages read 2024-04-19 11:27:15 -04:00
6d60a729b7 [scrobbles] Just push old data into JSON 2024-04-19 11:20:30 -04:00
89541a13f2 [webpages] Push to archivebox in a different place 2024-04-19 11:00:07 -04:00
f87bc5fd55 [scrobbles] Unscrewup scrobble migratiosn 2024-04-19 10:48:49 -04:00
0a8078dee0 [scrobbles] Allow skipping the Archivebox push 2024-04-19 10:35:53 -04:00
37da74708c [scrobbles] Fix scrobble having no user in tests 2024-04-19 10:31:33 -04:00
b470e7acea [scrobbles] Fix a migration and how we save timezones 2024-04-19 10:17:38 -04:00
5cdd12783f [scrobbles] Add timezones to scrobbles for better representation 2024-04-19 09:44:15 -04:00
8bf43f298c [books] Fix bug in koreader import using stats, not first_page 2024-04-19 08:45:37 -04:00
a269949a23 [scrobbles] Add webpages to status 2024-04-16 15:17:23 -04:00
8a5f200b44 [scrobbles] Add sports to the status 2024-04-16 15:14:52 -04:00
1c41ca3e18 [locations] Remove redundant field def 2024-04-16 15:09:21 -04:00
b69963b75f [books] Add helper method to get all page data 2024-04-16 15:08:50 -04:00
1327d5da40 [books] Fix index error issue with some imports 2024-04-16 10:47:10 -04:00
5d5b49f19b [webpages] Add bookmarklet support for URLs 2024-04-16 09:54:05 -04:00
d93c827b80 [books] Fix ordering problem with pages 2024-04-16 03:00:20 -04:00
2349c39487 [templates] Fix div mess in status page 2024-04-13 20:33:52 -04:00
ae96831fe4 [scrobbles] Fix status page container 2024-04-13 15:33:38 -04:00
706be782dc [dev] Ignore all sqlite files 2024-04-13 15:26:08 -04:00
a72e0b0fb9 [scrobbles] Add a status page 2024-04-13 15:25:13 -04:00
f5df6c97a9 [scrobbles] Wrap title and sub in span 2024-04-13 01:24:12 -04:00
e00f2de4b1 [videos] Put some views behind login 2024-04-13 01:16:37 -04:00
b4a7cafa3d [profiles] Hide archivebox password 2024-04-13 00:40:00 -04:00
aebe4f899d [webpages] Add pushing webpages to Archivebox 2024-04-13 00:39:18 -04:00
2bf0ca1a8c [books] Fix streaming SQL requets bug 2024-04-10 18:31:39 -04:00
a0c414135c [scrobbles] Fix bug in migrate script and update logging 2024-04-05 12:00:51 -04:00
1dcd151c65 [scrobbles] Migrate scrobble log to JSON field 2024-04-05 11:56:09 -04:00
2e9d92d9c7 [scrobbles] Add json log migration script 2024-04-05 11:38:52 -04:00
8776f75d12 [podcasts] Skip failing tests, bye Google Podcasts 2024-04-05 11:09:10 -04:00
c2a53875a0 [webpages] Clean up how we fetch webpage data 2024-04-05 10:39:30 -04:00
ffb0b7372d [vrobbler] Poetry update 2024-04-02 00:09:44 -04:00
d03744c240 [webpages] Add title cleaner and better url fetching 2024-04-02 00:09:26 -04:00
d9a7929cb0 [webpages] Update lock file for htmldate 2024-03-26 22:51:33 -04:00
9bc087d824 [webpages] Add annotation JS 2024-03-26 22:44:47 -04:00
e65ab4d8c2 [webpages] Break domains out to model 2024-03-26 22:33:56 -04:00
2d26a7c3bd [books] Add comicvine 2024-03-26 22:02:15 -04:00
1fd3e47656 [webpages] Clean up webpage view and add date 2024-03-26 22:01:57 -04:00
dd0ce967c8 [scrobbles] Fix bad update fields call 2024-03-25 16:45:50 -04:00
1f6a4956d2 [videos] Also fix it in series 2024-03-25 15:52:09 -04:00
22f5d288d4 [videos] Fix typo in imdb static path 2024-03-25 15:43:07 -04:00
77cf67ea09 [videoss] Finally add IMDB logo 2024-03-25 15:02:29 -04:00
d1dfc5502d [webpages] Properly use user agent header 2024-03-25 09:58:01 -04:00
0d83050956 [books] Adjust KO migration script for DST 2024-03-25 09:57:25 -04:00
134bd3f650 [books] Fix timezones and duplicated scrobbles 2024-03-23 02:11:06 -04:00
261f2eb9ce [books] Fix KoReader import at end of session 2024-03-19 00:19:40 -04:00
8f56bfba50 [videogames] Fix how we look up IGDB games 2024-03-18 14:58:46 -04:00
750fa05b15 [videogames] Order by ID desc from IGDB to get better results 2024-03-18 14:41:00 -04:00
302ddf6650 [videogames] Fix index error with IGDB lookups 2024-03-18 14:26:09 -04:00
49dd148f9f [scrobbling] Fix missing location in create 2024-03-12 23:15:48 -04:00
79d4e79f3e [scrobbling] Fix locations in proximity of named locations 2024-03-12 18:28:59 -04:00
874d9dc7d2 [locations] Dramatically simplify checking for mvement 2024-03-12 17:53:09 -04:00
0cd95036da [scrobbling] Fix creation of new location scrobbles 2024-03-12 17:12:36 -04:00
829c6b9978 [scrobbling] Fix bad call to has moved 2024-03-12 17:08:43 -04:00
df1bfd4177 [locations] Refactor has moved to not use multiple points 2024-03-12 16:57:56 -04:00
0fff2ec388 [scrobbling] Little location scrobble log clean up 2024-03-12 12:53:11 -04:00
8a83c9fdb3 [scrobbling] Don't run locations though can_be_updated 2024-03-12 12:51:18 -04:00
651fc7a745 [scrobbling] End around for locations 2024-03-12 12:41:00 -04:00
c22a306840 [scrobbling] Tryin in vain to clean up location loggin 2024-03-12 12:38:33 -04:00
11e6161502 [locations] Fix misisng return value 2024-03-12 12:25:48 -04:00
3af2ad203c [scrobbling] Still trying to understand movement 2024-03-12 11:58:44 -04:00
7697dd006d [scrobbling] Futher clean up logging for locations 2024-03-12 11:43:15 -04:00
73a6277e29 [scrobbling] Fix confusing location logging around creating locations 2024-03-12 11:36:51 -04:00
79fc407c33 [scrobbles] Fix bug in can be updated method 2024-03-08 16:53:44 -05:00
84ec84c018 [scrobbles] Try fixing mopidy within existing framework 2024-03-08 14:59:37 -05:00
beb9569663 [scrobbles] Try to fix mopidy double scrobbles 2024-03-08 14:52:01 -05:00
efcf01b49f [scrobbles] Revert previous change, silly 2024-03-08 14:33:31 -05:00
01270a9e32 [scrobbles] Try to fix mopidy double scrobble 2024-03-08 14:16:06 -05:00
c9280c2bad [scrobbles] Fix broken tests 2024-03-08 13:56:53 -05:00
416fd807f4 [scrobbles] Clean up logs and refactor can_be_updated 2024-03-08 13:51:23 -05:00
3069e93697 [scrobbles] Clean up logging in scrobblers 2024-03-08 13:24:57 -05:00
e47f4905e8 [scrobbles] Radically simplify stopping ... may need to roll this back 2024-03-08 12:45:45 -05:00
bf59fb213e [scrobbles] Add media type to our new log 2024-03-08 12:23:39 -05:00
589bf533ea [scrobbles] Add new log on update for scrobble data 2024-03-08 12:14:09 -05:00
1fb29304a3 [scrobbles] Try tweaking completion time for tracks 2024-03-06 20:30:00 -05:00
42604b9e23 [scrobbles] More robust fix for video and track overruns 2024-03-06 19:56:47 -05:00
4642063510 [scrobbles] Add check for long playing videos 2024-03-06 19:07:29 -05:00
65944eb911 [scrobbling] Add media type to filter on 2024-03-06 18:44:10 -05:00
4cf2ceb2dd [scrobbling] Add logging to scrobbler webhooks 2024-03-06 18:29:04 -05:00
78151e5070 [scrobbling] Missed cleaning one log 2024-03-06 18:07:00 -05:00
f90fa160d7 [scrobbling] Enrich some more logs 2024-03-06 18:01:52 -05:00
3b7498c419 [scrobbling] Clean up a few more logs 2024-03-06 17:53:20 -05:00
a59997dafb [scrobbles] Mostly cleaning up our logs 2024-03-06 17:46:55 -05:00
4cdb6150dc [locations] Fix proximity stuff 2024-03-06 16:14:46 -05:00
7fa21c27ea [locations] Remove RawGeoLocation model and ScrobbledPage model 2024-02-19 19:22:13 -05:00
b981c090c6 [locations] Change name of model function 2024-02-18 11:42:42 -05:00
605435b9ea [locations] Make finding proximity locations easier 2024-02-18 11:39:37 -05:00
f4d00b4a22 [locations] Fix typo in settings 2024-02-18 01:46:32 -05:00
504620fc89 [locations] I think proximity logic was reversed 2024-02-18 01:39:53 -05:00
df3424c68f [scrobbles] We don't have a user here yet 2024-02-18 01:39:40 -05:00
71b5af7615 [locations] Oops, don't want to update location scrobbles 2024-02-18 01:25:23 -05:00
7035f01441 [locations] Check for close named locations before creating new 2024-02-18 01:18:15 -05:00
525c7c4e2b [books] Clean up fragmented KoReader scrobbling 2024-02-13 14:54:29 -05:00
4e330a6f03 [webpages] Fix lookup key for scrobbles 2024-02-12 23:06:29 -05:00
ff18ad8efc [webpages] Remane foreign key to web_page 2024-02-12 22:58:07 -05:00
15c1317395 [locations] One more time, trying to fix this stuff 2024-02-12 15:12:12 -05:00
6b80d107b0 [locations] Fix scrobbling logic for locations 2024-02-12 13:47:15 -05:00
13124aca6b [locations] Need to spoof user in gps webhook 2024-02-12 12:42:27 -05:00
a685c6ff9c [locations] Fix calculating playback time 2024-02-12 12:36:38 -05:00
02200bb1f7 Forgot to return when updating! 2024-02-12 12:16:30 -05:00
0bbd488f0d [scrobbling] Sometimes we wont have timestamps 2024-02-12 12:11:07 -05:00
2c08336e33 [locations] Don't scrobble all the locations! 2024-02-12 11:59:15 -05:00
a637a59a40 [scrobbling] Fix location function name typo 2024-02-12 11:48:53 -05:00
123f7c53f9 Oops, no scrobble maybe 2024-02-12 11:38:14 -05:00
2c26cc01ba [scrobbling] Fix scrobble overwriting bug and refactor location scrobbling 2024-02-12 11:34:06 -05:00
36bc1c2e95 [locations] Tigten up how we finish locations 2024-02-11 00:38:10 -05:00
5651ccb990 [scrobbles] can be updated needs no params now 2024-02-11 00:25:12 -05:00
cd7b06eaa2 [settings] Fix typo 2024-02-11 00:16:48 -05:00
949416059d [locations] Try to fix geolocs finally 2024-02-11 00:10:50 -05:00
ca434cb08a [locations] Fix geoloc proximity comparison
Also, make it configurable
2024-02-10 18:13:39 -05:00
2661aee915 [locations] Refactor finding locations 2024-02-10 16:58:07 -05:00
74f672a2fd [locations] settings needs to be int cast 2024-02-10 15:37:31 -05:00
449b74ae3f [scrobbles] Maybe this was a good thing? 2024-02-10 15:30:23 -05:00
3d1ad8da3a [locations] Need to be specific to geolocs 2024-02-10 15:29:06 -05:00
75e374bb69 [locations] Do not mark geolocs complete automatically 2024-02-10 15:23:37 -05:00
2ba694655b [locations] Fix constant typo 2024-02-10 15:14:42 -05:00
879942d070 [locations] Geo accuracy should be int 2024-02-10 15:12:31 -05:00
bfd210d280 [locations] Turns out we were looking them up wrong 2024-02-10 15:06:16 -05:00
e5c6b5e8d9 [locations] Fix updating locations one more time 2024-02-10 14:43:46 -05:00
3cd1603b91 [locations] Try new logic on whether we've moved 2024-02-10 14:37:01 -05:00
27523bc7ff [locations] Flip the logic on whether we've moved 2024-02-10 14:30:41 -05:00
67ef1a15ec [podcasts] Fix missning enum type for podcasts 2024-02-10 14:16:13 -05:00
288027bfce [locations] Diff loc using Decimal math 2024-02-10 14:11:38 -05:00
bfdf73d4c0 [podcasts] Fix name for Episode model to PodcastEpisode 2024-02-10 13:57:42 -05:00
033e3c3b35 [locations] Try another tack for whether we've moved or not 2024-02-10 13:18:45 -05:00
40504f83e1 Add reuse-db option to pytest 2024-02-08 23:05:28 -05:00
ccca81bbab Add tests for Location models 2024-02-08 23:05:20 -05:00
84f49af163 Need to rethink this, it may be fine 2024-02-04 02:07:35 -05:00
e044c70072 Fix inversion of next and last for scrobbles 2024-02-04 01:45:54 -05:00
d3e09c25bd Fix bug in looking up book MD5 hash 2024-02-03 00:42:11 -05:00
30f78a9290 Fix bug in koreader migrator and delete scrobbles before migrating 2024-02-03 00:41:47 -05:00
a5553966de Add script for building scrobbles from past pages 2024-01-31 01:19:58 -05:00
9e2d7a6bc0 Start to add comicvine lookups and consoldiate koreader data 2024-01-29 01:48:56 -05:00
70c7eda415 [books] Don't auto update metadata on save 2024-01-27 01:16:45 -05:00
0ad7dac6cb Fix bug in end_ts calc for book scrobbles 2024-01-27 01:16:45 -05:00
209875f0e6 Import fix, and don't overwrite KoReader data when OL conflicts 2024-01-27 01:16:45 -05:00
919fa1b0b4 Add fuzzing for book titles 2024-01-27 01:16:45 -05:00
0b3bc53704 Fix openlibrary lookups 2024-01-27 01:16:45 -05:00
dfc1365fa3 Fix koreader importing 2024-01-27 01:16:45 -05:00
f22ef1a163 Set user profile TZ to UTC by default, and setup signal 2024-01-27 01:16:45 -05:00
7cb818b585 Fix issue where pages may not come back from OL 2024-01-27 01:16:45 -05:00
1a5cd5106f Update drone file with dev requirements 2024-01-25 10:30:42 -05:00
3b65144b68 Fine tuning koreader imports on real files 2024-01-25 10:15:15 -05:00
4ae13b3a1a Refactor new KoReader importer a bit 2024-01-25 02:08:03 -05:00
3de2be50cf Start refactoring the koreader importer 2024-01-22 20:26:11 -05:00
80d197bc54 Add new field to scrobbles for page data 2024-01-22 19:40:39 -05:00
a274796405 Fix bug in GeoLocation setting 2024-01-22 00:52:01 -05:00
37fd1d8458 Fix old style poetry groups 2024-01-22 00:50:30 -05:00
45ddce36aa Look in DB for book first 2024-01-21 00:31:07 -05:00
c3ddd01a6f Default to getting a boardgame in the DB 2024-01-21 00:27:31 -05:00
6920c99931 Don't relookup games if we have it already 2024-01-18 17:20:20 -05:00
d2d71b3c85 Fix typo in view for resuming scrobbles 2024-01-18 17:06:00 -05:00
6300df8e9e Update accuracy of geolocations 2024-01-02 19:09:42 -05:00
b427731bc3 Use raw IDs for scrobbler media 2023-12-28 23:18:01 -05:00
6058024434 Fix date parsing for real in podcasts 2023-12-23 16:07:38 -05:00
a4d9bd607c Remove whitespace on podcast names and episode titles 2023-12-23 15:49:45 -05:00
9349009ffa Try sorting IGDB results by ID 2023-12-23 15:36:17 -05:00
2293c839e8 Make podcast str parsing sensitive to episode number length 2023-12-23 15:25:48 -05:00
34fb2df782 Turns out we don't need the click thing 2023-12-21 13:22:03 -05:00
3b2158d85c Merge branch 'develop' of secstate/vrobbler into main 2023-12-21 13:19:12 -05:00
7daef6677d Add click to notification 2023-12-21 13:16:22 -05:00
d895cf9110 Fix bad drone file 2023-12-21 12:37:10 -05:00
dffb4f087c Try fixing drone 2023-12-21 12:36:38 -05:00
37c1aab749 Fix timeout on deploy 2023-12-21 12:31:21 -05:00
9083e744da Actually use the drone plugin 2023-12-21 12:18:59 -05:00
af1b6cb4ed Fix parsing podcast URLs 2023-12-21 12:14:33 -05:00
de8727ee05 Also hup immortal, cause sometimes it's a pain 2023-12-21 11:44:14 -05:00
fd645420b8 Escape colons in yaml files 2023-12-21 11:10:44 -05:00
1fdad9ce5b Add curl command to deploy setp 2023-12-21 11:00:57 -05:00
81551ecb41 Add ntfy to deploy pipeline 2023-12-21 11:00:05 -05:00
7a5b4c1b08 Update drone with ntfy curl 2023-12-21 10:59:02 -05:00
c144f07f78 Fix parsing of podcast URIs 2023-12-21 10:52:51 -05:00
b44af1f79d Fix geolocation centers 2023-12-15 21:18:18 -05:00
22f3b94448 Add webpage pages and redirect 2023-12-12 00:35:01 +01:00
36adf5a904 Add detail page for webpages 2023-12-12 00:35:01 +01:00
c2f43861fd Fix scrobble counts on webpage list view 2023-12-12 00:35:01 +01:00
356579f78a Fix bug with stopping wrong scrobble 2023-12-09 23:14:50 +01:00
e5ed265e8f Fix None title bug 2023-12-09 22:34:49 +01:00
97d5478792 Better logging 2023-12-09 22:22:56 +01:00
96ca2f5602 Stop location scrobbles properly 2023-12-09 21:32:13 +01:00
478780e9b3 Better instrumentation 2023-12-08 15:52:43 +01:00
9aaed4f332 Fix looking up past locations 2023-12-08 15:36:31 +01:00
4327ff499c Check for similar title location if we've gotten that far 2023-12-08 15:02:48 +01:00
b53017d226 Update cover images too 2023-12-05 16:56:21 +01:00
4e694bd1b3 Fix top chart view 2023-12-05 16:39:34 +01:00
7a9f4a0876 Get the old homepage back! 2023-12-05 16:10:27 +01:00
431976296f Fix typo 2023-12-03 00:48:02 +01:00
1e84497c85 Actually save run time seconds 2023-12-01 14:00:24 +01:00
05458b1fb9 Add subtitle and update play time for webpages 2023-12-01 01:00:58 +01:00
93a514fc37 Down size long play images 2023-12-01 00:47:26 +01:00
c4c810e23f Catch if video game cover is missing 2023-12-01 00:42:53 +01:00
d1de4103e1 Fix early access of url that may not exist 2023-12-01 00:20:55 +01:00
d0b298e4cc Fix resizing function for music 2023-12-01 00:19:53 +01:00
91ed364747 Return 200 if resource already exists (no scrobble created) 2023-12-01 00:17:10 +01:00
e6f78f4b1d Add downsampling for screenshots 2023-12-01 00:08:22 +01:00
f452ed748e Fix video cover image dealy 2023-11-30 23:56:08 +01:00
63ddb51e89 Add even more imagekit gloriousness 2023-11-30 23:37:42 +01:00
61c10db0dd Try adding imagekit just once 2023-11-30 22:52:38 +01:00
6696d38638 Fix bad import of scrobbler methods 2023-11-30 21:37:01 +01:00
84351f8bcc Add time to read calc 2023-11-30 18:38:46 +01:00
eb1a22e69f Add webpage urls 2023-11-30 18:21:16 +01:00
34ecb71c44 Add text extraction to webpages 2023-11-30 18:19:30 +01:00
33dfbc2c2e Add default readtime to webpages, and fix reverse 2023-11-30 18:09:25 +01:00
8eb2526404 Fix no webpage media obj 2023-11-30 18:01:12 +01:00
8e99918813 Add webpage scrobbling modifier 2023-11-30 17:56:45 +01:00
ea894e1ebf Fix scrobble admin and add webpage views 2023-11-30 17:54:28 +01:00
edcfbd829a Fix import in views 2023-11-30 17:50:53 +01:00
4e1db4aa6f Add webpage scrobbling 2023-11-30 17:49:53 +01:00
f303115ca0 Ooops, use bggeek ID 2023-11-27 20:56:56 +01:00
5928a07ef0 Fix missing import 2023-11-27 20:53:26 +01:00
dea34c926b Allow playing board games again 2023-11-27 20:46:48 +01:00
7564292f5b Order locations by scrobble timestmap 2023-11-27 18:49:24 +01:00
5aa155094f Fix bug where geo locs were not getitng scrobbled 2023-11-27 18:25:42 +01:00
635e8053cd Fix bug in notes for geo scrobbles 2023-11-26 21:36:45 +01:00
0d3da63aaf Fix bug in geo scrobbler 2023-11-26 21:32:26 +01:00
1670a7cd7a Try to ignore duplicate geo scrobbles 2023-11-26 21:20:49 +01:00
0505d01b71 Use timestamp to get latest scrobble for media type 2023-11-26 20:53:31 +01:00
aae2dd7567 Exclude geo locs from now playing 2023-11-26 11:12:48 +01:00
88c252eb45 Fix wrong ordering for long plays on completion 2023-11-26 11:09:09 +01:00
abd07f3113 Clean up location templates 2023-11-25 15:56:05 +01:00
9ba9319885 Add scrobble to locations list 2023-11-25 15:39:27 +01:00
22ad340d43 Filter locations by user 2023-11-25 15:36:33 +01:00
b0574ecf80 Update admin for geolocations 2023-11-25 00:38:08 +01:00
e9db212121 Fix dupped lat code 2023-11-25 00:36:07 +01:00
d5d0325420 And again 2023-11-25 00:31:44 +01:00
1bc5cf31e0 That's why we should have tests 2023-11-25 00:27:12 +01:00
2e2d491e2e Truncate better 2023-11-25 00:22:34 +01:00
c7d1272e99 Remove funky truncation end around 2023-11-24 19:51:37 +01:00
7a6bd169bf Add truncated lat lon to model 2023-11-24 19:17:43 +01:00
bbaa8fa078 Fix issue with GPS scrobbles being always in progress 2023-11-24 15:52:36 +01:00
21995c62fb No need to save raw anymore 2023-11-24 15:41:34 +01:00
880f810aa1 Fix scrobbling of locations 2023-11-24 15:41:10 +01:00
49e4e9b69a Remove custom repo for pypi 2023-11-24 15:18:11 +01:00
b50778b43f Add locations to test settings 2023-11-24 15:15:20 +01:00
da944f9ef1 Try and fix things 2023-11-24 14:33:09 +01:00
f6509bbaa8 Update migration path 2023-11-24 13:45:40 +01:00
f9aab6296b Gah, maybe still truncate a little 2023-11-24 02:06:26 +01:00
899129dfd9 Don't dumb down geolocations 2023-11-24 02:04:24 +01:00
3eb51a871e Fix migrations and add location tab 2023-11-24 01:55:53 +01:00
ca3e495467 Clean up location detail 2023-11-24 01:35:32 +01:00
85a91b340a Add location templates 2023-11-24 01:33:33 +01:00
e04be4fbdd Fix Last.FM imports coming in as videos 2023-11-24 00:26:35 +01:00
f9a98e9de9 Truncate altitude to two places 2023-11-24 00:26:02 +01:00
f18d81296b Use constant for location providers 2023-11-23 23:27:16 +01:00
e86d8cc9fe Fix silly bug in sportsdb settings 2023-11-23 23:26:03 +01:00
4ab796fe12 Add pendulum 2023-11-23 09:39:03 +01:00
fc64dfadba Add basic location tracking 2023-11-23 00:58:11 +01:00
a5d729d26a Add stub for future board game resume function 2023-09-14 00:43:05 -04:00
a661135394 Slim down the homepage 2023-09-14 00:23:12 -04:00
f1b1989424 Add book lookup via locg 2023-08-31 01:27:37 -04:00
145212fe46 Fix first sentence length 2023-08-18 22:48:02 -04:00
607c2522f3 Add ipython 2023-08-15 14:57:30 -04:00
8646fd6881 Add ScrobbledPage model to start book transition 2023-07-25 15:22:23 -04:00
63b783229c Simplify scrapper test 2023-07-25 14:59:54 -04:00
b7e15da87a Add genres to books 2023-07-25 14:55:50 -04:00
8a597ef1b0 Reorganize tadb lookup to actually work 2023-06-21 14:36:01 -04:00
ca65ce11f2 Still need to bail on bad responses 2023-06-21 11:35:43 -04:00
ad2f28c214 Allow forcing updates from TADB 2023-06-21 11:18:06 -04:00
e517327ce3 Clean up video detail pages 2023-06-15 17:20:03 -04:00
ff5a88cb17 Add screenshots and saves to VG details 2023-06-15 11:20:10 -04:00
1fedf77b87 Fix error getting process log lines 2023-06-15 11:13:53 -04:00
837aa53c9b Add duration to video game and board game lists 2023-06-15 11:13:38 -04:00
9e38798f44 Add board and video games to index 2023-06-06 11:59:51 -04:00
6d841167ea Clean up tick logic 2023-06-05 22:21:15 -04:00
6e97298949 Fix timestamp updated with every tick 2023-06-05 22:20:14 -04:00
40fc4d21d3 Try ignoring jellyfin video progress 2023-06-05 14:12:49 -04:00
9eeb708a51 Trying again with Jellyfin 2023-06-05 10:57:03 -04:00
35214de127 Fix jellyfin overwriting scrobbles 2023-06-05 10:20:08 -04:00
0adc97f440 Add video game screenshots to scrobbles 2023-06-03 14:19:06 -04:00
0140126ed8 Condense aggergator tests 2023-06-01 19:57:39 -04:00
f7b8bc3bfc Remove reuse-db flag 2023-06-01 19:51:40 -04:00
7e16388f81 Add tests for BGG 2023-06-01 19:47:32 -04:00
6dc09e723d Fix tests 2023-06-01 19:28:33 -04:00
1d50c32ba9 Set timezone properly for vg scrobbles 2023-06-01 16:18:25 -04:00
0bf4d28482 Actually fix the TSV timezone issue 2023-05-31 23:08:03 -04:00
bcf2b9d1ea I don't think TSV files are in UTC after all 2023-05-31 22:27:09 -04:00
a3bf9b0081 Fix title of retroarch import page 2023-05-31 20:19:09 -04:00
1e35f10945 Add retroarch imports to list view 2023-05-31 20:18:29 -04:00
955b6f028a Clean up retroarch scrobbling 2023-05-29 23:05:58 -04:00
cc751d0953 Duplicate long play complete from last scrobble 2023-05-28 10:25:34 -04:00
a7e8e4f1dc Fix importing from AGB 2023-05-28 10:11:24 -04:00
f55aed7b3d Fix case where cover url is missing 2023-05-27 23:11:50 -04:00
79265feb39 Need to actually used a timezone instance 2023-05-27 23:09:54 -04:00
1ba5b4cf4b Fix retroarch defaulting to utc 2023-05-27 23:01:29 -04:00
39add993e0 Fix looking up games by hltb only 2023-05-27 22:21:20 -04:00
4fd8a5b9a5 Catch json decoding errors 2023-05-27 00:19:50 -04:00
dad936cd6b Fix tests 2023-05-26 23:12:55 -04:00
704409cf6e Fix retroarch times not in UTC 2023-05-26 23:12:43 -04:00
6ee57cb7cd Fix minor issues with retroarch scrobbling 2023-05-26 22:55:09 -04:00
95da4c063e Fix scraping of MAME games 2023-05-25 01:06:20 -04:00
93de6d1556 Add retroarch import tasks and models 2023-05-24 22:38:20 -04:00
dbbb2b43a8 Add scrobble list to import detail view 2023-05-24 19:31:16 -04:00
7758c56d10 Bump python drone version 2023-05-24 17:16:42 -04:00
6c56cfab85 Initial retroarch import code 2023-05-24 17:12:28 -04:00
6753c3717f Fix book lookup if OL ID exists 2023-05-23 16:33:22 -04:00
085c666c19 AudioScrobbler imports only tracks, set media type 2023-05-22 11:36:47 -04:00
dca66003fd Limit imports to last 10 2023-05-04 09:56:05 -04:00
dc55c538ab Try release group ID 2023-04-30 10:25:19 -04:00
bb69be4817 Rather change names than fuck up MB IDs 2023-04-30 10:25:19 -04:00
af0223ab4c Put import records in rows 2023-04-26 19:35:56 -04:00
9ed5d5dc1a Add columsn to list import 2023-04-26 14:03:14 -04:00
dedf28c6de Update import listings 2023-04-26 12:01:50 -04:00
3607d4d4a4 Fix build badge 2023-04-22 20:54:29 -04:00
6bdb7b6e1f Fix scrobbling movies 2023-04-22 20:38:03 -04:00
c864f408a1 Ooops. Set artist image on actual model 2023-04-19 00:25:25 -04:00
f1c22bfbc0 Disable lastfm import button if auto imports on 2023-04-19 00:20:45 -04:00
88bd049d95 Set media type on KOReader import to book 2023-04-19 00:20:45 -04:00
8f4ce4441b Default to album art if artist not found 2023-04-18 13:29:43 -04:00
bee9ac8d25 Clean up board game scrobbling 2023-04-18 11:37:47 -04:00
61c9e362a8 Save playback position on manual scrobbles 2023-04-18 11:15:56 -04:00
efde57078e Add scrobbling of board games 2023-04-18 11:15:02 -04:00
fc927c72d1 Add board game urls and views 2023-04-18 11:04:02 -04:00
5c3a554010 Make genre optional 2023-04-17 22:33:36 -04:00
f21650505c Add board game media type 2023-04-17 18:46:32 -04:00
0217c96faf Add boardgames as scrobblable 2023-04-17 18:29:39 -04:00
4654902adc Simplify, simplify, simplify
The way we calculate past seconds for long plays should be much less
error prone and uses our built-in previous scrobble accessor for media types.
2023-04-15 13:12:21 -04:00
954b35b1d0 Set stop_timestamp when stopping scrobbles 2023-04-15 13:02:07 -04:00
620f52f9ef Fix manual scrobble of long plays 2023-04-15 09:43:53 -04:00
e495a08d1f Clean up sports lookuops a little 2023-04-15 09:13:32 -04:00
74ce5ec9ab Fix force finishing scrobbles not marked completed 2023-04-15 09:13:12 -04:00
fb107c083f Greatly simplify scrobbling books 2023-04-15 09:03:06 -04:00
f9dcc0d341 Fix a handful of little bugs
Refactor of manual scrobbling missed an import
KoReader book removed in sqlite fails import
Looking up album by name and artists screws up on MBid unique
2023-04-13 10:20:21 -04:00
9eef5d721b Image changed on tests 2023-04-11 22:48:50 -04:00
6d613028fc Fix looking up book using ISBN 2023-04-11 22:47:10 -04:00
7db98f0979 Clean up code for manaul scrobblign 2023-04-11 18:08:43 -04:00
4e38605008 Fix wrong model type for podcast media type 2023-04-09 01:11:25 -04:00
7106a0840d Add media type to scrobbles 2023-04-09 01:05:09 -04:00
1f7846f096 Add media type to scrobble admin filter 2023-04-08 23:02:26 -04:00
010565efb7 Update todos 2023-04-07 12:07:10 -04:00
9baf1069b6 Fix it so lastfm imports dont barf 2023-04-06 15:39:13 -04:00
2896225826 Fix bad lookup of artists 2023-04-06 14:09:00 -04:00
be6b3c5e2e Add ability to restart lastfm imports 2023-04-06 14:00:48 -04:00
e487f50683 Add a command to remove zombie scrobbles 2023-04-06 13:53:22 -04:00
8042c726b0 Allow profile disable of lastfm imports 2023-04-06 13:37:10 -04:00
59e29d858a Add command for running lastfm imports 2023-04-06 13:36:02 -04:00
a59bcf054a Bump version to 0.13.2 2023-04-02 23:58:16 -04:00
fe4faee7aa Add an end timestamp 2023-04-02 23:57:55 -04:00
5db8bf0329 Move check for finish up a level 2023-04-02 23:44:07 -04:00
d085bf2153 Don't bother with authors in book metadata 2023-04-02 23:21:34 -04:00
a133e7a30c Someone fixed an NPR typo 2023-04-02 23:01:00 -04:00
ca59605afc Dont bail on stop if not in progress 2023-04-02 22:46:58 -04:00
597ac2c7b8 Add save game data for video game scrobbles 2023-04-02 22:43:58 -04:00
f04f8b04c0 Ooops, need to pop after updating 2023-04-02 22:37:56 -04:00
2215976571 Fix jellyfin duping scrobbles after complete 2023-04-02 18:48:55 -04:00
e6bb52702c Fix migration with timezone issue 2023-03-30 02:00:14 -04:00
0dc0102bb6 Refactor long play finishing 2023-03-30 01:55:36 -04:00
7d1e070ee6 Fix scrobbles same song over and over error 2023-03-30 01:48:20 -04:00
6c060f24ec Default timezone to one that respects DST 2023-03-30 00:34:17 -04:00
b6a0f0d3fb Fix koreader importing 2023-03-30 00:34:09 -04:00
1c6f28bae3 Fix bad lookup of covers in long play template 2023-03-29 15:40:44 -04:00
845ee7d4e9 Revert "First run at adding thumbnailing to images"
This reverts commit c00343abfe.
2023-03-28 15:33:53 -04:00
dadc5db0f9 Revert "Fix bug in thumbnail tag"
This reverts commit c39430e987.
2023-03-28 15:33:47 -04:00
c4ddb4b51c Revert "Let's also thumbnail the now playing widget"
This reverts commit 76cc1f7b1c.
2023-03-28 15:33:37 -04:00
76cc1f7b1c Let's also thumbnail the now playing widget 2023-03-28 15:24:04 -04:00
c39430e987 Fix bug in thumbnail tag 2023-03-28 14:45:23 -04:00
c00343abfe First run at adding thumbnailing to images 2023-03-28 14:22:38 -04:00
84070d2806 Fix wrong page table name for KOReader 2023-03-28 01:29:59 -04:00
4a929956a7 Fix bug in importing TSV files 2023-03-28 00:04:08 -04:00
a7bf405af2 Bit of a hack to fix artist lookups 2023-03-27 23:49:00 -04:00
09f97c6eed Move bug fixing to lower priority 2023-03-27 23:45:54 -04:00
1ee8fc589a Actually fix the VA bug 2023-03-27 20:19:03 -04:00
3e2a9d2183 Fix koreader book ID bug 2023-03-27 01:38:04 -04:00
cb0c00a695 Strip initials from koreader authors 2023-03-27 01:35:22 -04:00
ee01ffa4ad Fix scrobbing pages 2023-03-27 01:31:05 -04:00
d19838a26f Add proper author lookups and fix bad OL fixes 2023-03-27 01:10:28 -04:00
c571043788 Fix aggregator being blank on Sundays and BS4 warnings 2023-03-26 13:52:17 -04:00
f082bea571 Fix KOReader imports to use pages for scrobbles 2023-03-26 12:43:54 -04:00
bcd35842cd Add new fields to page to use it better 2023-03-26 12:30:06 -04:00
5c9a877a9a Add stream sqlite for S3 file parsing 2023-03-26 12:27:12 -04:00
9a2ba1fd07 Fix importing TSV files with S3 2023-03-25 11:10:33 -04:00
f3e90e4ad4 Clean up some settings in S3 uploads 2023-03-24 18:21:38 -04:00
a7605d9cc5 Fix small bug in getting album cover images 2023-03-24 15:18:59 -04:00
2c946c1071 Need custom storage to have two different paths 2023-03-24 15:17:55 -04:00
1e17a679d3 Allow S3 usage 2023-03-24 14:47:37 -04:00
e6914ed079 Fix vrobbler boolean settings bug 2023-03-24 10:47:12 -04:00
bf2d1f0c0a Update todos 2023-03-24 10:45:45 -04:00
951554b6fc Fix charts to do rolling day counts 2023-03-24 00:58:27 -04:00
662578e941 Update deps and use a local caching repo for poetry 2023-03-23 15:45:39 -04:00
524e6b0027 Only deploy from main branch 2023-03-23 10:28:33 -04:00
ede4767a39 Need to use a tag, not div 2023-03-23 00:26:54 -04:00
d2d81f7119 Dont reset immortaldir on deploy 2023-03-23 00:16:27 -04:00
1d95d59d8d Add podcast pages 2023-03-23 00:16:20 -04:00
70118e2e62 Add podcast field for google url 2023-03-23 00:06:14 -04:00
04a48af4c9 Fix scraper tests for podcasts 2023-03-22 23:56:02 -04:00
59d652a9c4 Fix bug in adding producers 2023-03-22 23:49:40 -04:00
945776b885 Oops, url is loaded via JS :( 2023-03-22 23:47:57 -04:00
98f9c4bc04 Add url to scraped data for podcasts 2023-03-22 23:38:48 -04:00
36eda9f258 Fix primary image for podcast episodes 2023-03-22 23:31:09 -04:00
efd3acbc70 Fix saving producers 2023-03-22 23:30:26 -04:00
1ac0fc5b23 Oops, let drone run longer than 2 minutes 2023-03-22 23:29:33 -04:00
fd23928922 Fix bug in scraping podcasts 2023-03-22 23:22:23 -04:00
2d50964971 Couple tweeks to drone deploys 2023-03-22 23:20:32 -04:00
8b21861867 Add proper deploy step to CI 2023-03-22 23:13:20 -04:00
a0d67cbcd2 Test deploy keys 2023-03-22 23:03:45 -04:00
9f60411c5e Add scaper to podcast model 2023-03-22 22:57:18 -04:00
dd66774bda Add podcast scraper using Google 2023-03-22 22:50:44 -04:00
15be4e0068 Add generalized cover field for scobblable things 2023-03-22 18:44:06 -04:00
bc59ff66eb Update todos 2023-03-22 17:13:35 -04:00
b5d6bea0d1 Fix bug in looking up albums by artists 2023-03-20 16:35:01 -04:00
bbf3819e08 Fix TSV imports of incomplete files 2023-03-17 10:27:02 -04:00
1b56933969 Fix missing isbn error 2023-03-16 17:40:15 -04:00
f8628f7826 Fix adding up of total play back time 2023-03-16 17:37:37 -04:00
20b11359e0 Add index to rank field 2023-03-15 17:55:23 -04:00
ecc26138a7 Add source id message for manuals 2023-03-15 14:43:47 -04:00
5ce48277ed Add default series source 2023-03-15 14:42:05 -04:00
447a4e830e Allow starting next in series 2023-03-15 14:38:59 -04:00
e8f1bcbe31 Little tweaks to album art 2023-03-15 13:11:21 -04:00
131fc379c3 Keep fixing primary artists issues 2023-03-15 12:51:33 -04:00
dd34e34970 Replace primary artist in allmusic 2023-03-15 12:48:26 -04:00
23f1cb749e One more try 2023-03-15 12:47:42 -04:00
95507f640e Fix creating new tracks 2023-03-15 12:40:34 -04:00
db32777f28 Add album_artist idea 2023-03-15 12:34:42 -04:00
a2135b5d55 Okay, we want to lookup albums by name and artist 2023-03-15 01:29:48 -04:00
87fcfbb7d9 Fix duplicate albums created via TSV imports 2023-03-15 01:24:25 -04:00
92b4caa32f Fix TSV imports with new run time seconds 2023-03-15 00:13:50 -04:00
31f490a32b Scrape all the things 2023-03-14 23:40:54 -04:00
5b5b67d42a Add genre fetching from IGDB 2023-03-14 18:44:28 -04:00
c9874b3fda Add genres! 2023-03-14 18:36:44 -04:00
c9b04772a0 Fix video game scraping when igdb id is there 2023-03-14 18:20:10 -04:00
f9420c7a41 Cleaning up video and series pages 2023-03-14 18:19:14 -04:00
fbe35a02a1 Fix display of sports 2023-03-14 17:22:57 -04:00
8122179c7a Clean up rounds in sports 2023-03-14 17:00:47 -04:00
f7a757c485 Add new field for run time to sports 2023-03-14 16:45:36 -04:00
62850dd4f1 Fix bug in run time for sports 2023-03-14 16:43:54 -04:00
7d7ec4b676 Get title from MB album lookup 2023-03-14 12:57:20 -04:00
6dee409a55 Fix imports from LastFM without albums 2023-03-14 12:32:48 -04:00
1242740258 Reverse! 2023-03-13 18:51:21 -04:00
6fb6093fe1 Few quick fixes 2023-03-13 18:48:06 -04:00
5fcc314fd0 Split long plays up a bit 2023-03-13 18:46:26 -04:00
856546b633 Fix space in icons 2023-03-13 18:22:44 -04:00
b96683b3ad Add metadata fixing to video games 2023-03-13 18:20:23 -04:00
aab403a782 Avoid recussion limit on saving video games 2023-03-13 18:19:30 -04:00
8e3a2d251a Order long plays 2023-03-13 17:38:08 -04:00
e386d160e2 Fix bad video game templates 2023-03-13 15:49:20 -04:00
4867acb30b Fix display of covers 2023-03-13 14:54:11 -04:00
d071319df4 Add video and series detail pages 2023-03-13 14:43:08 -04:00
c494779c82 Fix errant tick code 2023-03-13 03:23:12 -04:00
52fc67803a Clean up how we scrobble videos 2023-03-13 03:22:44 -04:00
f504d9f2a1 Fix none from KoReader imports 2023-03-12 17:13:56 -04:00
34a6ac192d Fix progress for books with no scrobbles 2023-03-12 16:15:20 -04:00
69bdc60087 Fix scrobbling videos 2023-03-12 14:06:39 -04:00
9ac5ef8f59 Fix metadata scrapper for books 2023-03-12 13:31:28 -04:00
95b625cec2 Fix redundant tick field 2023-03-12 10:54:09 -04:00
f6c1a459d4 Add resume URLs to list views 2023-03-11 19:59:15 -05:00
e5acedbb01 More url cleanup 2023-03-11 19:59:07 -05:00
b01ceebbf3 No need for scrobble logs 2023-03-11 19:58:54 -05:00
43dc625e4a Clean up scrobble urls 2023-03-11 19:58:43 -05:00
38f40e014a Add notes to scrobble model 2023-03-11 19:58:34 -05:00
bb2a80e2aa Add ability to manage long plays 2023-03-11 14:11:31 -05:00
6e03cf5075 Fix author imports for books 2023-03-11 12:57:23 -05:00
fadc281fe8 Fix bad import on books 2023-03-11 11:58:55 -05:00
d79159670e Add chart view when not auth'd 2023-03-10 11:30:23 -05:00
489d8b9152 Fix lookup of long play media types 2023-03-10 09:32:57 -05:00
638a5d05bd Fix updating long play seconds 2023-03-09 21:55:36 -05:00
6c880b3030 Fix calculating pages read 2023-03-08 13:44:05 -05:00
76b1816452 Incorrect way to get long play 2023-03-08 13:30:36 -05:00
323e9ec8bf Fix display of progress in long play 2023-03-08 13:30:05 -05:00
6ffc77a9d5 Move grid buttons to base list 2023-03-08 13:16:21 -05:00
e42ee0e03a Fix padding on long play media 2023-03-08 12:56:22 -05:00
bb9259a82a Fix splitting scrobble key 2023-03-08 12:51:59 -05:00
6b02930a1a Update todos! 2023-03-08 12:47:36 -05:00
aefdc507d8 Fix comma if only hours 2023-03-08 12:47:29 -05:00
b307054453 Update long play templates, remove chart 2023-03-08 12:45:30 -05:00
960fe3e8d1 Add long play infra 2023-03-08 12:11:58 -05:00
788e1ab9e9 Fix book importing 2023-03-06 14:05:33 -05:00
73c72ef465 Update long play to use seconds 2023-03-06 10:21:27 -05:00
551e6f4f7e Allow forcing book updats 2023-03-06 01:17:32 -05:00
a5f24cd5ec Fix minor issue in saving book scrobbles 2023-03-06 01:06:02 -05:00
4d5e979a1a Fix bug in finishing scrobbles 2023-03-06 01:03:40 -05:00
3fc716420c Add basic views for books and games 2023-03-06 00:54:08 -05:00
9bcd9d8bb7 Fixing long play scrobbles 2023-03-06 00:53:50 -05:00
a4537879f9 Allow scrobbling video games 2023-03-05 18:05:10 -05:00
df62865eea Fix completion for video games 2023-03-05 17:27:39 -05:00
c757e743ac Boom. Video game metadata 2023-03-05 16:36:36 -05:00
a0e852775c Add video games to scrobbles 2023-03-05 02:31:13 -05:00
353fb8d655 Few tweaks to utils 2023-03-05 02:31:00 -05:00
d8edad98b2 Hide sensitive data in admin 2023-03-05 02:30:42 -05:00
9848d311c4 Update music admin with search 2023-03-05 02:30:23 -05:00
22c33d24c3 Move utils to openlibrary module 2023-03-05 02:30:12 -05:00
0a6774c284 Reorganize books 2023-03-05 02:30:01 -05:00
25c00d7f1b First pass at adding videogames 2023-03-05 02:29:20 -05:00
7d7123498b Skip crappy tests 2023-03-04 17:58:48 -05:00
d0bb07df29 Fix flake8 issues 2023-03-04 17:33:41 -05:00
94f1396f2e Blacken quotes 2023-03-04 17:29:25 -05:00
3d7528030a Clean up imports 2023-03-04 17:28:42 -05:00
9c881d3bd9 If no artist, no image 2023-03-04 17:27:48 -05:00
5c135b2d2e Change placeholder photo 2023-03-04 17:19:28 -05:00
4c945932f9 Update not found image 2023-03-04 17:08:21 -05:00
90b7be286c Move lookup modules to approp app 2023-03-04 16:04:52 -05:00
00aa2e892f Fix bug in artist and album lookup 2023-03-04 15:46:34 -05:00
2811146656 Add icons for music services 2023-03-04 15:46:26 -05:00
34a2339b3b Bump version to 0.11.12 2023-03-03 21:56:32 -05:00
34abbe753b Fix a few display issues with charts 2023-03-03 21:55:36 -05:00
0fe00c3dd8 Fix bug in album creation 2023-03-03 21:55:26 -05:00
5a3eb7a8c8 Bump version to 0.11.11 2023-03-03 16:14:11 -05:00
e63ca13d57 Small tweaks to scorbble view 2023-03-03 16:13:06 -05:00
b3d3098fe0 Fix importing albums 2023-03-03 16:12:38 -05:00
8f5a200526 Bump version to 0.11.10 2023-03-03 12:12:19 -05:00
411d2b42b0 Add better titles to artists too 2023-03-03 11:44:36 -05:00
bce1322289 Fix images and default to Maloja 2023-03-03 11:42:56 -05:00
908819d24e Damn capital letter 2023-03-03 11:02:22 -05:00
6d21bb2e85 Bump version to 0.11.9 2023-03-03 02:41:47 -05:00
7df3fedc64 Fix bad image templates 2023-03-03 02:41:27 -05:00
815 changed files with 66404 additions and 6208 deletions

View File

@ -4,25 +4,79 @@
################
kind: pipeline
name: run_tests
name: build & deploy
steps:
# Run tests against Python/Flask engine backend (with pytest)
- name: pytest with coverage
image: python:3.10.4
image: python:3.11.1
commands:
# Install dependencies
- cp vrobbler.conf.test vrobbler.conf
- pip install poetry
- poetry install
- poetry install --with test
# Start with a fresh database (which is already running as a service from Drone)
- poetry run pytest --cov-report term:skip-covered --cov=vrobbler tests
- poetry run pytest -n 5 --cov-report term:skip-covered --cov=vrobbler tests
environment:
VROBBLER_DATABASE_URL: sqlite:///test.db
volumes:
# Mount pip cache from host
- name: pip_cache
path: /root/.cache/pip
- name: deploy
image: appleboy/drone-ssh
settings:
host:
- vrobbler.service
username: root
ssh_key:
from_secret: jail_key
command_timeout: 2m
script:
- pip uninstall -y vrobbler
- pip install git+https://code.lab.unbl.ink/secstate/vrobbler.git@main
- vrobbler migrate
- vrobbler collectstatic --noinput
- immortalctl restart celery && immortalctl restart vrobbler
when:
ref:
- refs/tags/*
- name: build success notification
image: parrazam/drone-ntfy:0.3-linux-amd64
when:
status: [success]
settings:
url: https://ntfy.unbl.ink
topic: drone
priority: low
tags:
- 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:
status: [failure]
settings:
url: https://ntfy.unbl.ink
topic: drone
priority: high
tags:
- 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

31
.github/workflows/django.yml vendored Normal file
View File

@ -0,0 +1,31 @@
name: Django CI
on:
push:
branches: [ "develop" ]
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
python-version: [3.9, 3.11, 3.12]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
python-version: ${{ matrix.python-version }}
- name: Install Dependencies
run: |
python -m pip install --upgrade pip
python -m pip install poetry
poetry install
- name: Run Tests
run: |
pytest

4
.gitignore vendored
View File

@ -1,5 +1,7 @@
db.sqlite3
db.sqlite3*
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.

6
Makefile Normal file
View File

@ -0,0 +1,6 @@
deploy:
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:
pytest vrobbler

1895
PROJECT.org Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
Vrobbler
========
[![Build Status](https://ci.unbl.ink/api/badges/secstate/vrobbler/status.svg?ref=refs/heads/main)](https://ci.unbl.ink/secstate/vrobbler)
[![Build Status](https://ci.lab.unbl.ink/api/badges/secstate/vrobbler/status.svg?ref=refs/heads/main)](https://ci.lab.unbl.ink/secstate/vrobbler)
Vrobbler is a pretty simple Django-powered web app for scrobbling video plays from you favorite Jellyfin installation.
@ -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.

1
data/moods.json Normal file

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

View File

@ -1,4 +1,4 @@
export ENV_PATH=$(poetry env info --path)
source "${ENV_PATH}/bin/activate"
export PYPI_PASSWORD="$(pass personal/apikey/pypi)"
#export PYPI_PASSWORD="$(pass personal/apikey/pypi)"

23
justfile Normal file
View File

@ -0,0 +1,23 @@
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 && git push gitea
git push --tags && git push --tags gitea

View File

@ -6,7 +6,7 @@ import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'vrobbler.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "vrobbler.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
@ -18,5 +18,5 @@ def main():
execute_from_command_line(sys.argv)
if __name__ == '__main__':
if __name__ == "__main__":
main()

8261
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,22 +1,23 @@
[tool.poetry]
name = "vrobbler"
version = "0.11.8"
version = "44.0"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]
[tool.poetry.dependencies]
python = "^3.8"
python = ">=3.11,<3.15"
Django = "^4.0.3"
django-extensions = "^3.1.5"
python-dateutil = "^2.8.2"
python-dotenv = "^0.20.0"
python-json-logger = "^2.0.2"
colorlog = "^6.6.0"
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"
@ -26,9 +27,7 @@ django-taggit = "^2.1.0"
django-markdownify = "^0.9.1"
gunicorn = "^20.1.0"
django-simple-history = "^3.1.1"
whitenoise = "^6.3.0"
musicbrainzngs = "^0.7.1"
cinemagoer = "^2022.12.27"
pysportsdb = "^0.1.0"
pytz = "^2022.7.1"
django-redis = "^5.2.0"
@ -36,8 +35,38 @@ pylast = "^5.1.0"
django-encrypted-field = "^1.0.5"
celery = "^5.2.7"
honcho = "^1.1.0"
howlongtobeatpy = "^1.0.5"
beautifulsoup4 = "^4.11.2"
django-storages = "^1.13.2"
stream-sqlite = "^0.0.41"
ipython = "^8.14.0"
pendulum = "^3"
trafilatura = "^1.6.3"
django-imagekit = "^5.0.0"
thefuzz = "^0.22.1"
dataclass-wizard = "^0.35.0"
webdavclient3 = "^3.14.6"
boto3 = "^1.35.37"
urllib3 = "<2"
django-oauth-toolkit = "^3.0.1"
meta-yt = "^0.1.9"
berserk = "^0.13.2"
poetry-bumpversion = "^0.3.3"
orgparse = "^0.4.20250520"
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.dev-dependencies]
[tool.poetry.group.test]
optional = true
[tool.poetry.group.test.dependencies]
Werkzeug = "2.0.3"
black = "^22.3"
coverage = "^7.0.5"
@ -46,24 +75,22 @@ pytest = "^7.1"
pytest-black = "^0.3.12"
pytest-cov = "^3.0"
pytest-django = "^4.5.2"
pytest-xdist= "^1.0.0"
pytest-flake8 = "^1.1"
pytest-isort = "^3.0"
pytest-runner = "^6.0"
pytest-selenium = "^2.0.1"
time-machine = "^2.9.0"
types-pytz = "^2022.1"
types-requests = "^2.27"
bandit = "^1.7.4"
[tool.pytest.ini_options]
minversion = "6.0"
addopts = "-ra -q"
addopts = "-ra -q --reuse-db --no-migrations"
testpaths = ["tests"]
DJANGO_SETTINGS_MODULE='vrobbler.settings'
DJANGO_SETTINGS_MODULE='vrobbler.settings-testing'
[tool.black]
line-length = 79
skip-string-normalization = true
line-length = 88
target-version = ["py39", "py310"]
include = ".py$"
exclude = "migrations"
@ -80,6 +107,8 @@ exclude_dirs = ["*/tests/*", "*/migrations/*"]
[tool.poetry.scripts]
vrobbler = "vrobbler.cli:main"
[tool.poetry_bumpversion.file."vrobbler/__init__.py"]
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

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

@ -0,0 +1,31 @@
import pytest
from boardgames.bgg import (
take_first,
lookup_boardgame_id_from_bgg,
lookup_boardgame_from_bgg,
)
@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"
bgg_id = lookup_boardgame_id_from_bgg("Comedy Encounter")
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
bgg_result = lookup_boardgame_from_bgg("Cosmic Encounter")
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

@ -0,0 +1,19 @@
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_img_url = "https://encrypted-tbn2.gstatic.com/images?q=tbn:ANd9GcR1F0CfR24RR6sme531yIkCrnK4zzmo97jeualO5drVPKG6oCk"
expected_google_url = "https://podcasts.google.com/feed/aHR0cHM6Ly9mZWVkcy5ucHIub3JnLzUxMDMxOC9wb2RjYXN0LnhtbA"
@pytest.mark.skip("Google Podcasts is gone")
def test_get_not_allowed_from_mopidy():
query = "Up First"
result_dict = scrape_data_from_google_podcasts(query)
assert result_dict["title"] == query
assert expected_desc_snippet in result_dict["description"]
assert result_dict["image_url"] == expected_img_url
assert result_dict["producer"] == "NPR"
assert result_dict["google_url"] == expected_google_url

View File

@ -1,36 +1,74 @@
import json
import pytest
from scrobbles.models import Scrobble
from rest_framework.authtoken.models import Token
import pytest
from django.contrib.auth import get_user_model
from rest_framework.authtoken.models import Token
from boardgames.models import BoardGame
from music.models import Track, Artist
from scrobbles.models import Scrobble
from people.models import Person
User = get_user_model()
@pytest.fixture
def boardgame_scrobble():
first = Person.objects.create(name="First Player")
second = Person.objects.create(name="Second Player")
return Scrobble.objects.create(
board_game=BoardGame.objects.create(title="Test Board Game"),
media_type="BoardGame",
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",
},
],
},
)
@pytest.fixture
def test_track():
Track.objects.create(
title="Emotion",
artist=Artist.objects.create(name="Carly Rae Jepsen"),
base_run_time_seconds=60,
)
class MopidyRequest:
name = "Same in the End"
artist = "Sublime"
album = "Sublime"
track_number = 4
run_time_ticks = 156604
run_time = "156"
run_time = 60
playback_time_ticks = 15045
musicbrainz_track_id = "54214d63-5adf-4909-87cd-c65c37a6d558"
musicbrainz_album_id = "03b864cd-7761-314c-a892-05a89ddff00d"
musicbrainz_artist_id = "95f5b748-d370-47fe-85bd-0af2dc450bc0"
mopidy_uri = "local:track:Sublime%20-%20Sublime/Disc%201%20-%2004%20-%20Same%20in%20the%20End.mp3"
mopidy_uri = "local:track:Sublime%20-%20Sublime/Disc%201%20-%2004%20-%20Same%20in%20the%20End.mp3" # noqa
status = "resumed"
def __init__(self, **kwargs):
self.request_data = {
"name": kwargs.get('name', self.name),
"name": kwargs.get("name", self.name),
"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)
@ -61,24 +99,89 @@ class MopidyRequest:
@pytest.fixture
def valid_auth_token():
user = User.objects.create(email='test@exmaple.com')
user = User.objects.create(email="test@exmaple.com")
return Token.objects.create(user=user).key
@pytest.fixture
def mopidy_track_request_data():
return MopidyRequest().request_json
def mopidy_track():
return MopidyRequest()
@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
def mopidy_podcast_request_data():
mopidy_uri = "local:podcast:Up%20First/2022-01-01%20Up%20First.mp3"
return MopidyRequest(mopidy_uri=mopidy_uri).request_json
return MopidyRequest(
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"
return MopidyRequest(
mopidy_uri=mopidy_uri, artist="NPR", album="Up First"
).request_json
class JellyfinTrackRequest:
name = "Emotion"
artist = "Carly Rae Jepsen"
album = "Emotion"
track_number = 1
item_type = "Audio"
timestamp = "2024-01-14 12:00:19"
run_time_ticks = 156604
run_time = "00:00:60"
playback_time_ticks = 15045
musicbrainz_track_id = "54214d63-5adf-4909-87cd-c65c37a6d558"
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 = {
"Name": kwargs.get("name", self.name),
"Artist": kwargs.get("artist", self.artist),
"Album": kwargs.get("album", self.album),
"TrackNumber": int(kwargs.get("track_number", self.track_number)),
"RunTime": kwargs.get("run_time", self.run_time),
"ItemType": kwargs.get("item_type", self.item_type),
"UtcTimestamp": kwargs.get("timestamp", self.timestamp),
"PlaybackPositionTicks": int(
kwargs.get("playback_time_ticks", self.playback_time_ticks)
),
"Provider_musicbrainztrack": kwargs.get(
"musicbrainz_track_id", self.musicbrainz_track_id
),
"Provider_musicbrainzalbum": kwargs.get(
"musicbrainz_album_id", self.musicbrainz_album_id
),
"Provider_musicbrainzartist": kwargs.get(
"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):
for key in self.request_data.keys():
if self.request_data[key] != getattr(self, key):
return False
return True
@property
def request_json(self):
return json.dumps(self.request_data)
@pytest.fixture
def jellyfin_track():
return JellyfinTrackRequest()

View File

@ -1,4 +1,5 @@
from datetime import datetime, timedelta
from unittest.mock import patch
import pytest
import time_machine
@ -6,97 +7,110 @@ 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 profiles.models import UserProfile
from scrobbles.models import Scrobble
def build_scrobbles(client, request_data, num=7, spacing=2):
url = reverse('scrobbles:mopidy-webhook')
user = get_user_model().objects.create(username='Test User')
UserProfile.objects.create(user=user, timezone='US/Eastern')
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_data, 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_request_data):
build_scrobbles(client, mopidy_track_request_data)
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,
'month': 2,
'today': 1,
'week': 3,
'year': 7,
"alltime": 7,
"month": 2,
"today": 1,
"week": 3,
"year": 7,
}
@pytest.mark.django_db
def test_week_of_scrobbles_data(client, mopidy_track_request_data):
build_scrobbles(client, mopidy_track_request_data, 7, 1)
user = get_user_model().objects.first()
@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(
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]
@pytest.mark.django_db
def test_top_tracks_by_day(client, mopidy_track_request_data):
build_scrobbles(client, mopidy_track_request_data, 7, 1)
user = get_user_model().objects.first()
tops = live_charts(user)
assert tops[0].title == "Same in the End"
@pytest.mark.django_db
def test_top_tracks_by_week(client, mopidy_track_request_data):
build_scrobbles(client, mopidy_track_request_data, 7, 1)
user = get_user_model().objects.first()
tops = live_charts(user, chart_period='week')
tops = live_charts(user, chart_period="week")
assert tops[0].title == "Same in the End"
@pytest.mark.django_db
def test_top_tracks_by_month(client, mopidy_track_request_data):
build_scrobbles(client, mopidy_track_request_data, 7, 1)
user = get_user_model().objects.first()
tops = live_charts(user, chart_period='month')
tops = live_charts(user, chart_period="month")
assert tops[0].title == "Same in the End"
@pytest.mark.django_db
def test_top_tracks_by_year(client, mopidy_track_request_data):
build_scrobbles(client, mopidy_track_request_data, 7, 1)
user = get_user_model().objects.first()
tops = live_charts(user, chart_period='year')
tops = live_charts(user, chart_period="year")
assert tops[0].title == "Same in the End"
@pytest.mark.django_db
def test_top__artists_by_week(client, mopidy_track_request_data):
build_scrobbles(client, mopidy_track_request_data, 7, 1)
user = get_user_model().objects.first()
tops = live_charts(user, chart_period='week', media_type="Artist")
tops = live_charts(user, chart_period="week", media_type="Artist")
assert tops[0].name == "Sublime"
@pytest.mark.django_db
def test_top__artists_by_month(client, mopidy_track_request_data):
build_scrobbles(client, mopidy_track_request_data, 7, 1)
user = get_user_model().objects.first()
tops = live_charts(user, chart_period='month', media_type="Artist")
tops = live_charts(user, chart_period="month", media_type="Artist")
assert tops[0].name == "Sublime"
@pytest.mark.django_db
def test_top__artists_by_year(client, mopidy_track_request_data):
build_scrobbles(client, mopidy_track_request_data, 7, 1)
user = get_user_model().objects.first()
tops = live_charts(user, chart_period='year', media_type="Artist")
tops = live_charts(user, chart_period="year", media_type="Artist")
assert tops[0].name == "Sublime"

View File

@ -1,11 +0,0 @@
import pytest
from vrobbler.apps.scrobbles.imdb import lookup_video_from_imdb
@pytest.mark.skip(reason="Need to sort out third party API testing")
def test_lookup_imdb_bad_id(caplog):
data = lookup_video_from_imdb('3409324')
assert data is None
assert caplog.records[0].levelname == "WARNING"
assert caplog.records[0].msg == "IMDB ID should begin with 'tt' 3409324"

View File

@ -0,0 +1,44 @@
import pytest
# from scrobbles.dataclasses import BoardGameLogData, BoardGameScoreLogData
@pytest.mark.skip("Need to get local tests running working again")
@pytest.mark.django_db
def test_boardgame_log_data(boardgame_scrobble):
assert boardgame_scrobble.logdata == BoardGameLogData(
players=[
BoardGameScoreLogData(
person_id=1,
bgg_username="",
color="Blue",
character=None,
team=None,
score=30,
win=True,
new=None,
rank=None,
seat_order=None,
role=None,
),
BoardGameScoreLogData(
person_id=2,
bgg_username="",
color="Red",
character=None,
team=None,
score=28,
win=False,
new=None,
rank=None,
seat_order=None,
role=None,
),
],
difficulty=None,
solo=None,
two_handed=None,
)
assert len(boardgame_scrobble.logdata.players) == 1
assert boardgame_scrobble.logdata.players[0].user.id == 1
assert boardgame_scrobble.logdata.players[0].name == "Test"

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

@ -0,0 +1,10 @@
from datetime import datetime
import pytz
from django.contrib.auth import get_user_model
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"))
assert timestamp == datetime(2023, 5, 31, 23, 24, 42, tzinfo=pytz.utc)

View File

@ -1,100 +1,741 @@
import json
from datetime import datetime, timedelta
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 django.utils import timezone
from music.models import Album, Artist, Track
from podcasts.models import PodcastEpisode
from scrobbles.models import Scrobble
from music.models import Track
from podcasts.models import Episode
from tasks.models import Task
@pytest.mark.django_db
def test_get_not_allowed_from_mopidy(client, valid_auth_token):
url = reverse('scrobbles:mopidy-webhook')
headers = {'Authorization': f'Token {valid_auth_token}'}
url = reverse("scrobbles:mopidy-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_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)
url = reverse("scrobbles:mopidy-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)'
response.data["detail"]
== "JSON parse error - Expecting value: line 1 column 1 (char 0)"
)
@pytest.mark.django_db
def test_scrobble_mopidy_track(
client, mopidy_track_request_data, valid_auth_token
def test_bad_jellyfin_request_data(client, valid_auth_token):
url = reverse("scrobbles:jellyfin-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
response = client.post(
url, "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,
):
url = reverse('scrobbles:mopidy-webhook')
headers = {'Authorization': f'Token {valid_auth_token}'}
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',
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.media_obj.__class__ == Track
assert scrobble.media_obj.title == "Same in the End"
assert response.data == {"scrobble_id": 1}
mock_track.scrobble_for_user.assert_called_once()
@pytest.mark.django_db
def test_scrobble_mopidy_same_track_different_album(
@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,
mopidy_track_request_data,
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={})
@patch(
"music.utils.lookup_album_dict_from_mb",
return_value={"year": "1999", "mb_group_id": 1},
)
@patch("music.utils.lookup_track_from_mb", return_value={})
@patch("music.models.lookup_artist_from_tadb", return_value={})
@patch("music.models.lookup_album_from_tadb", return_value={"year": "1999"})
@patch("music.models.Album.fetch_artwork", return_value=None)
@patch("music.models.Album.scrape_allmusic", return_value=None)
def test_scrobble_mopidy_same_track_different_album(
mock_lookup_artist,
mock_lookup_album,
mock_lookup_track,
mock_lookup_artist_tadb,
mock_lookup_album_tadb,
mock_fetch_artwork,
mock_scrape_allmusic,
client,
mopidy_track,
mopidy_track_diff_album_request_data,
valid_auth_token,
):
url = reverse('scrobbles:mopidy-webhook')
headers = {'Authorization': f'Token {valid_auth_token}'}
url = reverse("scrobbles:mopidy-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
response = client.post(
url,
mopidy_track_request_data,
content_type='application/json',
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 response.data == {"scrobble_id": 1}
scrobble = Scrobble.objects.last()
assert scrobble.media_obj.album.name == "Sublime"
response = client.post(
url,
mopidy_track_diff_album_request_data,
content_type='application/json',
content_type="application/json",
)
scrobble = Scrobble.objects.get(id=2)
assert response.status_code == 200
assert response.data == {"scrobble_id": 2}
scrobble = Scrobble.objects.last()
assert scrobble.media_obj.__class__ == Track
assert scrobble.media_obj.album.name == "Gold"
assert scrobble.media_obj.album.name == "Sublime"
assert scrobble.media_obj.title == "Same in the End"
@pytest.mark.django_db
@patch(
"podcasts.sources.podcastindex.lookup_podcast_from_podcastindex",
return_value={},
)
def test_scrobble_mopidy_podcast(
client, mopidy_podcast_request_data, valid_auth_token
mock_lookup_podcast, client, mopidy_podcast_request_data, valid_auth_token
):
url = reverse('scrobbles:mopidy-webhook')
headers = {'Authorization': f'Token {valid_auth_token}'}
url = reverse("scrobbles:mopidy-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
response = client.post(
url,
mopidy_podcast_request_data,
content_type='application/json',
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
assert response.data == {'scrobble_id': 1}
assert response.data == {"scrobble_id": 1}
scrobble = Scrobble.objects.get(id=1)
assert scrobble.media_obj.__class__ == Episode
assert scrobble.media_obj.__class__ == PodcastEpisode
assert scrobble.media_obj.title == "Up First"
@pytest.mark.skip("Need to refactor")
@pytest.mark.django_db
@patch("music.utils.lookup_artist_from_mb", return_value={})
@patch(
"music.utils.lookup_album_dict_from_mb",
return_value={"year": "1999", "mb_group_id": 1},
)
@patch("music.utils.lookup_track_from_mb", return_value={})
@patch("music.models.lookup_artist_from_tadb", return_value={})
@patch("music.models.lookup_album_from_tadb", return_value={"year": "1999"})
@patch("music.models.Album.fetch_artwork", return_value=None)
@patch("music.models.Album.scrape_allmusic", return_value=None)
def test_scrobble_jellyfin_track(
mock_lookup_artist,
mock_lookup_album,
mock_lookup_track,
mock_lookup_artist_tadb,
mock_lookup_album_tadb,
mock_fetch_artwork,
mock_scrape_allmusic,
client,
jellyfin_track,
valid_auth_token,
):
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.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")
@pytest.mark.django_db
@patch("music.utils.lookup_artist_from_mb", return_value={})
@patch(
"music.utils.lookup_album_dict_from_mb",
return_value={"year": "1999", "mb_group_id": 1},
)
@patch("music.utils.lookup_track_from_mb", return_value={})
@patch("music.models.lookup_artist_from_tadb", return_value={})
@patch("music.models.lookup_album_from_tadb", return_value={"year": "1999"})
@patch("music.models.Album.fetch_artwork", return_value=None)
@patch("music.models.Album.scrape_allmusic", return_value=None)
def test_scrobble_jellyfin_track_update(
mock_lookup_artist,
mock_lookup_album,
mock_lookup_track,
mock_lookup_artist_tadb,
mock_lookup_album_tadb,
mock_fetch_artwork,
mock_scrape_allmusic,
test_track,
client,
jellyfin_track,
valid_auth_token,
):
Scrobble.objects.create(
timestamp=timezone.now() - timedelta(minutes=0.5),
track=Track.objects.first(),
user_id=1,
)
url = reverse("scrobbles:jellyfin-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
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.media_obj.__class__ == Track
assert scrobble.media_obj.title == "Emotion"
@pytest.mark.skip("Need to refactor")
@pytest.mark.django_db
@patch("music.utils.lookup_artist_from_mb", return_value={})
@patch(
"music.utils.lookup_album_dict_from_mb",
return_value={"year": "1999", "mb_group_id": 1},
)
@patch("music.utils.lookup_track_from_mb", return_value={})
@patch("music.models.lookup_artist_from_tadb", return_value={})
@patch("music.models.lookup_album_from_tadb", return_value={"year": "1999"})
@patch("music.models.Album.fetch_artwork", return_value=None)
@patch("music.models.Album.scrape_allmusic", return_value=None)
def test_scrobble_jellyfin_track_create_new(
mock_lookup_artist,
mock_lookup_album,
mock_lookup_track,
mock_lookup_artist_tadb,
mock_lookup_album_tadb,
mock_fetch_artwork,
mock_scrape_allmusic,
test_track,
client,
jellyfin_track,
valid_auth_token,
):
url = reverse("scrobbles:jellyfin-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
Scrobble.objects.create(
timestamp=timezone.now() - timedelta(minutes=1),
track=Track.objects.first(),
user_id=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": 2}
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

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

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

View File

@ -0,0 +1,9 @@
import pytest
from videos.sources.youtube import lookup_video_from_youtube
@pytest.mark.skip(reason="Need to configure Youtube API stuffs in CI")
@pytest.mark.django_db
def test_lookup_youtube_id():
metadata = lookup_video_from_youtube("RZxs9pAv99Y")
assert metadata.title == "No Pun Included's Board Game of the Year 2024"

382
todos.org
View File

@ -1,382 +0,0 @@
#+title: TODOs
A fun way to keep track of things in the project to fix or improve.
* DONE [#A] Fix fetching artwork without release group :bug:
CLOSED: [2023-01-29 Sun 14:27]
When we get artwork from Musicbrianz, and it's not found, we should check for
release groups as well. This will stop issues with missing artwork because of
obscure MB release matches.
* DONE [#A] Fix Jellyfin music scrobbling N+1 past 90 completion percent :bug:
CLOSED: [2023-01-30 Mon 18:31]
:LOGBOOK:
CLOCK: [2023-01-30 Mon 18:00]--[2023-01-30 Mon 18:31] => 0:31
:END:
If we play music from Jellyfin and the track reaches 90% completion, the
scrobbling goes crazy and starts creating new scrobbles with every update.
The cause is pretty simple, but the solution is hard. We want to mark a scrobble
as complete for the following conditions:
- Play stopped and percent played beyond 90%
- Play completely finished
But if we keep listening beyond 90, we should basically ignore updates (or just
update the existing scrobble)
* DONE [#A] Add support for Audioscrobbler tab-separated file uploads :improvement:
CLOSED: [2023-02-03 Fri 16:52]
An example of the format:
#+begin_src csv
,
#AUDIOSCROBBLER/1.1
#TZ/UNKNOWN
#CLIENT/Rockbox sansaclipplus $Revision$
75 Dollar Bill I Was Real I Was Real 4 1015 S 1740494944 64ff5f53-d187-4512-827e-7606c69e66ff
75 Dollar Bill I Was Real I Was Real 4 1015 S 1740494990 64ff5f53-d187-4512-827e-7606c69e66ff
311 311 Down 1 173 S 1740495003 00476c23-fd9e-464b-9b27-a62d69f3d4f4
311 311 Down 1 173 L 1740495049 00476c23-fd9e-464b-9b27-a62d69f3d4f4
311 311 Down 1 173 L 1740495113 00476c23-fd9e-464b-9b27-a62d69f3d4f4
311 311 Random 2 187 S 1740495190 530c09f3-46fe-4d90-b11f-7b63bcb4b373
311 311 Random 2 187 L 1740495194 530c09f3-46fe-4d90-b11f-7b63bcb4b373
311 311 Jackolanterns Weather 3 204 L 1740495382 cc3b2dec-5d99-47ea-8930-20bf258be4ea
311 311 All Mixed Up 4 182 L 1740495586 980a78b5-5bdd-4f50-9e3a-e13261e2817b
311 311 Hive 5 179 L 1740495768 18f6dc98-d3a2-4f81-b967-97359d14c68c
311 311 Guns (Are for Pussies) 6 137 L 1740495948 5e97ed9f-c8cc-4282-9cbe-f8e17aee5128
311 311 Misdirected Hostility 7 179 S 1740496085 61ff2c1a-fc9c-44c3-8da1-5e50a44245af
,
#+end_src
* DONE [#B] Allow scrobbling music without MB IDs by grabbing them before scrobble :improvement:
CLOSED: [2023-02-17 Fri 00:10]
This would allow a few nice flows. One, you'd be able to record the play of an
entire album by just dropping the muscibrainz_id in. This could be helpful for
offline listening. It would also mean bad metadata from mopidy would not break
scrobbling.
* DONE When updating musicbrainz IDs, clear and run fetch artwrok :improvement:
CLOSED: [2023-02-17 Fri 00:11]
* TODO [#A] Add ability to manually scrobble albums or tracks from MB :improvement:
Given a UUID from musicbrainz, we should be able to scrobble an album or
individual track.
* TODO [#A] Add django-storage to store files on S3 :improvement:
* TODO [#B] Adjust cancel/finish task to use javascript to submit :improvement:
* TODO [#B] Implement a detail view for TV shows :improvement:
* TODO [#B] Implement a detail view for Moviews :improvement:
* TODO [#C] Implement keeping track of week/month/year chart-toppers :improvement:
:LOGBOOK:
CLOCK: [2023-01-30 Mon 16:30]--[2023-01-30 Mon 18:00] => 1:30
:END:
Maloja does this cool thing where artists and tracks get recorded as the top
track of a given week, month or year. They get gold, silver or bronze stars for
their place in the time period.
I could see this being implemented as a separate Chart table which gets
populated at the end of a time period and has a start and end date that defines
a period, along with a one, two, three instance.
Of course, it could also be a data model without a table, where it runs some fun
calculations, stores it's values in Redis as a long-term lookup table and just
has to re-populate when the server restarts.
* TODO [#C] Move to using more robust mopidy-webhooks pacakge form pypi :improvement:
** Example payloads from mopidy-webhooks
*** Podcast playback ended
#+begin_src json
{
"type": "event",
"event": "track_playback_ended",
"data": {
"tl_track": {
"__model__": "TlTrack",
"tlid": 13,
"track": {
"__model__": "Track",
"uri": "file:///var/lib/mopidy/media/podcasts/The%20Prince/2022-09-28-Wolf-warriors.mp3",
"name": "Wolf warriors",
"artists": [
{
"__model__": "Artist",
"name": "The Economist"
}
],
"album": {
"__model__": "Album",
"name": "The Prince",
"date": "2022"
},
"genre": "Blues",
"date": "2022",
"length": 2437778,
"bitrate": 127988
}
},
"time_position": 3290
}
}
#+end_src
*** Podcast playback state changes
#+begin_src json
{
"type": "event",
"event": "playback_state_changed",
"data": {
"old_state": "paused",
"new_state": "playing"
}
}
#+end_src
#+begin_src json
{
"type": "event",
"event": "playback_state_changed",
"data": {
"old_state": "stopped",
"new_state": "playing"
}
}
#+end_src
*** Podcast playback started
#+begin_src json
{
"type": "event",
"event": "track_playback_started",
"data": {
"tl_track": {
"__model__": "TlTrack",
"tlid": 13,
"track": {
"__model__": "Track",
"uri": "file:///var/lib/mopidy/media/podcasts/The%20Prince/2022-09-28-Wolf-warriors.mp3",
"name": "Wolf warriors",
"artists": [
{
"__model__": "Artist",
"name": "The Economist"
}
],
"album": {
"__model__": "Album",
"name": "The Prince",
"date": "2022"
},
"genre": "Blues",
"date": "2022",
"length": 2437778,
"bitrate": 127988
}
}
}
}
#+end_src
*** Podcast playback paused
#+begin_src json
{
"type": "status",
"data": {
"state": "paused",
"current_track": {
"__model__": "Track",
"uri": "file:///var/lib/mopidy/media/podcasts/The%20Prince/2022-09-28-Wolf-warriors.mp3",
"name": "Wolf warriors",
"artists": [
{
"__model__": "Artist",
"name": "The Economist"
}
],
"album": {
"__model__": "Album",
"name": "The Prince",
"date": "2022"
},
"genre": "Blues",
"date": "2022",
"length": 2437778,
"bitrate": 127988
},
"time_position": 2350
}
}
#+end_src
*** Track playback started
#+begin_src json
{
"type": "event",
"event": "track_playback_started",
"data": {
"tl_track": {
"__model__": "TlTrack",
"tlid": 14,
"track": {
"__model__": "Track",
"uri": "local:track:Various%20Artists%20-%202008%20-%20Twilight%20OST/01-muse-supermassive_black_hole.mp3",
"name": "Supermassive Black Hole",
"artists": [
{
"__model__": "Artist",
"uri": "local:artist:md5:250dd6551b66a58a6b4897aa697f200c",
"name": "Muse",
"musicbrainz_id": "9c9f1380-2516-4fc9-a3e6-f9f61941d090"
}
],
"album": {
"__model__": "Album",
"uri": "local:album:md5:455343d54cdd89cb5a3b5ad537ea99d0",
"name": "Twilight: Original Motion Picture Soundtrack",
"artists": [
{
"__model__": "Artist",
"uri": "local:artist:md5:54e4db2d5624f80b0cc290346e696756",
"name": "Various Artists",
"musicbrainz_id": "89ad4ac3-39f7-470e-963a-56509c546377"
}
],
"num_tracks": 12,
"num_discs": 1,
"date": "2008-11-04",
"musicbrainz_id": "b4889eaf-d9f4-434c-a68d-69227b12b6a4"
},
"composers": [
{
"__model__": "Artist",
"uri": "local:artist:md5:4d49cbca0b347e0a89047bb019d2779d",
"name": "Matt Bellamy"
}
],
"genre": "Rock",
"track_no": 1,
"disc_no": 1,
"date": "2008-11-04",
"length": 211121,
"musicbrainz_id": "ff1e3e1a-f6e8-4692-b426-355880383bb6",
"last_modified": 1672712949510
}
}
}
}
#+end_src
*** Track playback in progress
#+begin_src json
{
"type": "status",
"data": {
"state": "playing",
"current_track": {
"__model__": "Track",
"uri": "local:track:Various%20Artists%20-%202008%20-%20Twilight%20OST/01-muse-supermassive_black_hole.mp3",
"name": "Supermassive Black Hole",
"artists": [
{
"__model__": "Artist",
"uri": "local:artist:md5:250dd6551b66a58a6b4897aa697f200c",
"name": "Muse",
"musicbrainz_id": "9c9f1380-2516-4fc9-a3e6-f9f61941d090"
}
],
"album": {
"__model__": "Album",
"uri": "local:album:md5:455343d54cdd89cb5a3b5ad537ea99d0",
"name": "Twilight: Original Motion Picture Soundtrack",
"artists": [
{
"__model__": "Artist",
"uri": "local:artist:md5:54e4db2d5624f80b0cc290346e696756",
"name": "Various Artists",
"musicbrainz_id": "89ad4ac3-39f7-470e-963a-56509c546377"
}
],
"num_tracks": 12,
"num_discs": 1,
"date": "2008-11-04",
"musicbrainz_id": "b4889eaf-d9f4-434c-a68d-69227b12b6a4"
},
"composers": [
{
"__model__": "Artist",
"uri": "local:artist:md5:4d49cbca0b347e0a89047bb019d2779d",
"name": "Matt Bellamy"
}
],
"genre": "Rock",
"track_no": 1,
"disc_no": 1,
"date": "2008-11-04",
"length": 211121,
"musicbrainz_id": "ff1e3e1a-f6e8-4692-b426-355880383bb6",
"last_modified": 1672712949510
},
"time_position": 17031
}
}
#+end_src
*** Track event playback paused
#+begin_src json
{
"type": "event",
"event": "track_playback_paused",
"data": {
"tl_track": {
"__model__": "TlTrack",
"tlid": 14,
"track": {
"__model__": "Track",
"uri": "local:track:Various%20Artists%20-%202008%20-%20Twilight%20OST/01-muse-supermassive_black_hole.mp3",
"name": "Supermassive Black Hole",
"artists": [
{
"__model__": "Artist",
"uri": "local:artist:md5:250dd6551b66a58a6b4897aa697f200c",
"name": "Muse",
"musicbrainz_id": "9c9f1380-2516-4fc9-a3e6-f9f61941d090"
}
],
"album": {
"__model__": "Album",
"uri": "local:album:md5:455343d54cdd89cb5a3b5ad537ea99d0",
"name": "Twilight: Original Motion Picture Soundtrack",
"artists": [
{
"__model__": "Artist",
"uri": "local:artist:md5:54e4db2d5624f80b0cc290346e696756",
"name": "Various Artists",
"musicbrainz_id": "89ad4ac3-39f7-470e-963a-56509c546377"
}
],
"num_tracks": 12,
"num_discs": 1,
"date": "2008-11-04",
"musicbrainz_id": "b4889eaf-d9f4-434c-a68d-69227b12b6a4"
},
"composers": [
{
"__model__": "Artist",
"uri": "local:artist:md5:4d49cbca0b347e0a89047bb019d2779d",
"name": "Matt Bellamy"
}
],
"genre": "Rock",
"track_no": 1,
"disc_no": 1,
"date": "2008-11-04",
"length": 211121,
"musicbrainz_id": "ff1e3e1a-f6e8-4692-b426-355880383bb6",
"last_modified": 1672712949510
}
},
"time_position": 67578
}
}
#+end_src
* TODO [#C] Consider a purge command for duplicated and stuck in-progress scrobbles :improvement:
* TODO [#C] Figure out how to add to web-scrobbler :imropvement:
An example:
https://github.com/web-scrobbler/web-scrobbler/blob/master/src/core/background/scrobbler/maloja-scrobbler.js

View File

@ -1,11 +1,31 @@
# You can use this file to set environment variables for your local setup
#
VROBBLER_DEBUG=True
VROBBLER_JSON_LOGGING=True
VROBBLER_LOG_LEVEL="DEBUG"
VROBBLER_JSON_LOGGING=True
VROBBLER_MEDIA_ROOT = "/media/"
VROBBLER_TMDB_API_KEY = "KEY"
VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS=True
VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS=False
VROBBLER_DATABASE_URL="postgres://USER:PASSWORD@HOST:PORT/NAME"
VROBBLER_REDIS_URL="redis://:PASS@HOST:6379/0"
VROBBLER_USE_S3=False
# You may also need to set these in your environment
AWS_S3_ACCESS_KEY_ID=""
AWS_S3_SECRET_ACCESS_KEY=""
AWS_S3_CUSTOM_DOMAIN="https://minio.dev/"
# API keys
VROBBLER_TMDB_API_KEY = "<key>"
VROBBLER_LASTFM_API_KEY = "<key>"
VROBBLER_LASTFM_SECRET_KEY = "<key>"
VROBBLER_THESPORTSDB_API_KEY="<key>"
VROBBLER_THEAUDIODB_API_KEY="<key>"
VROBBLER_IGDB_CLIENT_ID="<id>"
VROBBLER_IGDB_CLIENT_SECRET="<key>"
VROBBLER_COMICVINE_API_KEY="<key>"
VROBBLER_TODOIST_CLIENT_ID="<id>"
VROBBLER_TODOIST_CLIENT_SECRET="<key>"
VROBBLER_GOOGLE_API_KEY="<key>"
VROBBLER_LICHESS_API_KEY = "<key>"
# Storages
# VROBBLER_DATABASE_URL="postgres://USER:PASSWORD@HOST:PORT/NAME"
# VROBBLER_REDIS_URL="redis://:PASS@HOST:6379/0"

View File

@ -1,8 +1,11 @@
# Local configuration for Emus
VROBBLER_DUMP_REQUEST_DATA=True
VROBBLER_LOG_TO_CONSOLE=True
VROBBLER_DEBUG=True
VROBBLER_DUMP_REQUEST_DATA=False
VROBBLER_LOG_TO_CONSOLE=False
VROBBLER_DEBUG=False
VROBBLER_LOG_LEVEL="DEBUG"
VROBBLER_MEDIA_ROOT = "/tmp/media/"
VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS=True
VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS=False
VROBBLER_USE_S3="False"
VROBBLER_DATABASE_URL="sqlite:///testdb.sqlite3"

View File

@ -2,4 +2,5 @@
# Django starts so that shared_task will use this app.
from .celery import app as celery_app
__all__ = ('celery_app',)
__version__ = "42.0"
__all__ = ("celery_app", "__version__")

1
vrobbler/_commit.py Normal file
View File

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

View File

@ -0,0 +1,34 @@
from beers.models import Beer, BeerProducer, BeerStyle
from django.contrib import admin
from scrobbles.admin import ScrobbleInline
class BeerInline(admin.TabularInline):
model = Beer
extra = 0
@admin.register(BeerStyle)
class BeerStyle(admin.ModelAdmin):
date_hierarchy = "created"
search_fields = ("name",)
@admin.register(BeerProducer)
class BeerProducer(admin.ModelAdmin):
date_hierarchy = "created"
search_fields = ("name",)
@admin.register(Beer)
class BeerAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"uuid",
"title",
)
ordering = ("-created",)
search_fields = ("title",)
inlines = [
ScrobbleInline,
]

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,5 @@
from django.apps import AppConfig
class BeersConfig(AppConfig):
name = "beers"

View File

@ -0,0 +1,133 @@
# Generated by Django 4.2.16 on 2024-10-22 21:26
from django.db import migrations, models
import django_extensions.db.fields
import taggit.managers
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
("scrobbles", "0065_alter_scrobble_log"),
]
operations = [
migrations.CreateModel(
name="BeerProducer",
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"
),
),
("description", models.TextField(blank=True, null=True)),
(
"location",
models.CharField(blank=True, max_length=255, null=True),
),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
migrations.CreateModel(
name="Beer",
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),
),
(
"run_time_seconds",
models.IntegerField(blank=True, null=True),
),
(
"run_time_ticks",
models.PositiveBigIntegerField(blank=True, null=True),
),
("description", models.TextField(blank=True, null=True)),
("ibu", models.SmallIntegerField(blank=True, null=True)),
("abv", models.FloatField(blank=True, null=True)),
(
"style",
models.CharField(blank=True, max_length=100, null=True),
),
("non_alcoholic", models.BooleanField(default=False)),
(
"beeradvocate_id",
models.CharField(blank=True, max_length=255, null=True),
),
(
"beeradvocate_score",
models.SmallIntegerField(blank=True, null=True),
),
(
"untappd_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="Tags",
),
),
],
options={
"abstract": False,
},
),
]

View File

@ -0,0 +1,36 @@
# Generated by Django 4.2.16 on 2024-10-22 21:34
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("beers", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="beer",
name="beeradvocate_image",
field=models.ImageField(
blank=True, null=True, upload_to="beers/beeradvcoate/"
),
),
migrations.AddField(
model_name="beer",
name="producer",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="beers.beerproducer",
),
),
migrations.AddField(
model_name="beerproducer",
name="beeradvocate_id",
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -0,0 +1,75 @@
# Generated by Django 4.2.16 on 2024-10-22 21:47
from django.db import migrations, models
import django_extensions.db.fields
class Migration(migrations.Migration):
dependencies = [
("beers", "0002_beer_beeradvocate_image_beer_producer_and_more"),
]
operations = [
migrations.CreateModel(
name="BeerStyle",
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"
),
),
("description", models.TextField(blank=True, null=True)),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
migrations.RemoveField(
model_name="beer",
name="beeradvocate_image",
),
migrations.RemoveField(
model_name="beer",
name="style",
),
migrations.AddField(
model_name="beer",
name="untappd_image",
field=models.ImageField(
blank=True, null=True, upload_to="beers/untappd/"
),
),
migrations.AddField(
model_name="beer",
name="untappd_rating",
field=models.FloatField(blank=True, null=True),
),
migrations.AddField(
model_name="beerproducer",
name="untappd_id",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="beer",
name="styles",
field=models.ManyToManyField(to="beers.beerstyle"),
),
]

View File

@ -0,0 +1,47 @@
# Generated by Django 4.2.16 on 2024-10-22 21:52
from django.db import migrations, models
import uuid
class Migration(migrations.Migration):
dependencies = [
("beers", "0003_beerstyle_remove_beer_beeradvocate_image_and_more"),
]
operations = [
migrations.AddField(
model_name="beerproducer",
name="name",
field=models.CharField(default="Untitled", max_length=255),
preserve_default=False,
),
migrations.AddField(
model_name="beerproducer",
name="uuid",
field=models.UUIDField(
blank=True, default=uuid.uuid4, editable=False, null=True
),
),
migrations.AddField(
model_name="beerstyle",
name="name",
field=models.CharField(default="Untitled", max_length=255),
preserve_default=False,
),
migrations.AddField(
model_name="beerstyle",
name="uuid",
field=models.UUIDField(
blank=True, default=uuid.uuid4, editable=False, null=True
),
),
migrations.AlterField(
model_name="beer",
name="styles",
field=models.ManyToManyField(
related_name="styles", to="beers.beerstyle"
),
),
]

View File

@ -0,0 +1,21 @@
# Generated by Django 4.2.16 on 2025-01-22 03:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"beers",
"0004_beerproducer_name_beerproducer_uuid_beerstyle_name_and_more",
),
]
operations = [
migrations.AlterField(
model_name="beer",
name="run_time_seconds",
field=models.IntegerField(default=900),
),
]

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

@ -0,0 +1,143 @@
from dataclasses import dataclass
from typing import Optional
from uuid import uuid4
from beers.untappd import get_beer_from_untappd_id
from django.apps import apps
from django.db import models
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
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
BNULL = {"blank": True, "null": True}
@dataclass
class BeerLogData(BaseLogData):
rating: Optional[str] = None
class BeerStyle(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255)
description = models.TextField(**BNULL)
def __str__(self):
return self.name
class BeerProducer(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255)
description = models.TextField(**BNULL)
location = models.CharField(max_length=255, **BNULL)
beeradvocate_id = models.CharField(max_length=255, **BNULL)
untappd_id = models.CharField(max_length=255, **BNULL)
def find_or_create(cls, title: str) -> "BeerProducer":
return cls.objects.filter(title=title).first()
def __str__(self):
return self.name
class Beer(ScrobblableMixin):
description = models.TextField(**BNULL)
ibu = models.SmallIntegerField(**BNULL)
abv = models.FloatField(**BNULL)
styles = models.ManyToManyField(BeerStyle, related_name="styles")
non_alcoholic = models.BooleanField(default=False)
beeradvocate_id = models.CharField(max_length=255, **BNULL)
beeradvocate_score = models.SmallIntegerField(**BNULL)
untappd_image = models.ImageField(upload_to="beers/untappd/", **BNULL)
untappd_image_small = ImageSpecField(
source="untappd_image",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
untappd_image_medium = ImageSpecField(
source="untappd_image",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
)
untappd_id = models.CharField(max_length=255, **BNULL)
untappd_rating = models.FloatField(**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})
def __str__(self):
return f"{self.title} by {self.producer}"
@property
def subtitle(self):
return self.producer.name
@property
def strings(self) -> ScrobblableConstants:
return ScrobblableConstants(verb="Drinking", tags="beer")
@property
def beeradvocate_link(self) -> str:
link = ""
if self.producer and self.beeradvocate_id:
if self.beeradvocate_id:
link = f"https://www.beeradvocate.com/beer/profile/{self.producer.beeradvocate_id}/{self.beeradvocate_id}/"
return link
@property
def untappd_link(self) -> str:
link = ""
if self.untappd_id:
link = f"https://www.untappd.com/beer/{self.untappd_id}/"
return link
@property
def primary_image_url(self) -> str:
url = ""
if self.untappd_image:
url = self.untappd_image.url
return url
@property
def logdata_cls(self):
return BeerLogData
@classmethod
def find_or_create(cls, untappd_id: str) -> "Beer":
beer = cls.objects.filter(untappd_id=untappd_id).first()
if not beer:
beer_dict = get_beer_from_untappd_id(untappd_id)
producer_dict = {}
style_ids = []
for key in list(beer_dict.keys()):
if "producer__" in key:
pkey = key.replace("producer__", "")
producer_dict[pkey] = beer_dict.pop(key)
if "styles" in key:
for style in beer_dict.pop("styles"):
style_inst, created = BeerStyle.objects.get_or_create(
name=style
)
style_ids.append(style_inst.id)
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:
beer.styles.add(style_id)
return beer
def scrobbles(self, user_id):
Scrobble = apps.get_model("scrobbles", "Scrobble")
return Scrobble.objects.filter(user_id=user_id, beer=self).order_by(
"-timestamp"
)

View File

@ -0,0 +1,138 @@
import logging
from typing import Optional
import requests
from bs4 import BeautifulSoup
logger = logging.getLogger(__name__)
UNTAPPD_URL = "https://untappd.com/beer/{id}"
def get_first(key: str, result: dict) -> str:
obj = ""
if obj_list := result.get(key):
obj = obj_list[0]
return obj
def get_title_from_soup(soup) -> str:
title = ""
try:
title = soup.find("h1").get_text()
except AttributeError:
pass
except ValueError:
pass
return title
def get_description_from_soup(soup) -> str:
desc = ""
try:
desc = (
soup.find(class_="beer-descrption-read-less")
.get_text()
.replace("Show Less", "")
.strip()
)
except AttributeError:
pass
except ValueError:
pass
return desc
def get_styles_from_soup(soup) -> list[str]:
styles = []
try:
styles = soup.find("p", class_="style").get_text().split(" - ")
except AttributeError:
pass
except ValueError:
pass
return styles
def get_abv_from_soup(soup) -> Optional[float]:
abv = None
try:
abv = soup.find(class_="abv").get_text()
if abv:
abv = float(abv.strip("\n").strip("% ABV").strip())
except AttributeError:
pass
except ValueError:
pass
except TypeError:
pass
return abv
def get_ibu_from_soup(soup) -> Optional[int]:
ibu = None
try:
ibu = soup.find(class_="ibu").get_text()
if ibu:
ibu = int(ibu.strip("\n").strip(" IBU").strip())
except AttributeError:
pass
except ValueError:
ibu = None
return ibu
def get_rating_from_soup(soup) -> str:
rating = ""
try:
rating = float(soup.find(class_="num").get_text().strip("(").strip(")"))
except AttributeError:
rating = None
except ValueError:
rating = None
return rating
def get_producer_id_from_soup(soup) -> str:
id = ""
try:
id = soup.find(class_="brewery").find("a")["href"].strip("/")
except ValueError:
pass
except IndexError:
pass
return id
def get_producer_name_from_soup(soup) -> str:
name = ""
try:
name = soup.find(class_="brewery").find("a").get_text()
except AttributeError:
pass
except ValueError:
pass
return name
def get_beer_from_untappd_id(untappd_id: str) -> dict:
beer_url = UNTAPPD_URL.format(id=untappd_id)
headers = {"User-Agent": "Vrobbler 0.11.12"}
response = requests.get(beer_url, headers=headers)
beer_dict = {"untappd_id": untappd_id}
if response.status_code != 200:
logger.warn("Bad response from untappd.com", extra={"response": response})
return beer_dict
soup = BeautifulSoup(response.text, "html.parser")
beer_dict["title"] = get_title_from_soup(soup)
beer_dict["description"] = get_description_from_soup(soup)
beer_dict["styles"] = get_styles_from_soup(soup)
beer_dict["abv"] = get_abv_from_soup(soup)
beer_dict["ibu"] = get_ibu_from_soup(soup)
beer_dict["untappd_rating"] = get_rating_from_soup(soup)
beer_dict["producer__untappd_id"] = get_producer_id_from_soup(soup)
beer_dict["producer__name"] = get_producer_name_from_soup(soup)
return beer_dict

View File

@ -0,0 +1,14 @@
from django.urls import path
from beers import views
app_name = "beers"
urlpatterns = [
path("beers/", views.BeerListView.as_view(), name="beer_list"),
path(
"beers/<slug:slug>/",
views.BeerDetailView.as_view(),
name="beer_detail",
),
]

View File

@ -0,0 +1,11 @@
from beers.models import Beer
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
class BeerListView(ScrobbleableListView):
model = Beer
class BeerDetailView(ScrobbleableDetailView):
model = Beer

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,56 @@
from django.contrib import admin
from boardgames.models import (
BoardGame,
BoardGameLocation,
BoardGamePublisher,
BoardGameDesigner,
)
from scrobbles.admin import ScrobbleInline
@admin.register(BoardGamePublisher)
class BoardGamePublisherAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"name",
"uuid",
)
ordering = ("-created",)
@admin.register(BoardGameDesigner)
class BoardGameDesignerAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"name",
"uuid",
)
ordering = ("-created",)
@admin.register(BoardGameLocation)
class BoardGameLocationAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"name",
"uuid",
"geo_location",
)
ordering = ("-created",)
@admin.register(BoardGame)
class BoardGameAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"bggeek_id",
"title",
"published_year",
)
search_fields = ("title",)
ordering = ("-created",)
inlines = [
ScrobbleInline,
]

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

@ -0,0 +1,156 @@
import csv
import json
import logging
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:
from scrobbles.models import Scrobble
logger = logging.getLogger(__name__)
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:
first = ""
try:
first = thing[0]
except IndexError:
pass
if first:
try:
first = first.get_text()
except:
pass
return first
def lookup_boardgame_id_from_bgg(title: str) -> Optional[int]:
soup = None
game_id = None
url = SEARCH_ID_URL.format(query=title)
r = requests.get(url, headers=BASE_HEADERS)
if r.status_code == 200:
soup = BeautifulSoup(r.text, "xml")
if soup:
result = soup.findAll("boardgame")
if not result:
return game_id
game_id = result[0].get("objectid", None)
return game_id
def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
soup = None
game_dict = {}
title = ""
bgg_id = None
try:
bgg_id = int(lookup_id)
logger.debug(f"Using BGG ID {bgg_id} to find board game")
except ValueError:
title = lookup_id
logger.debug(f"Using title {title} to find board game")
if not bgg_id:
bgg_id = lookup_boardgame_id_from_bgg(title)
url = GAME_ID_URL.format(id=bgg_id)
r = requests.get(url, headers=BASE_HEADERS)
if r.status_code == 200:
soup = BeautifulSoup(r.text, "xml")
if soup:
seconds_to_play = None
minutes = take_first(soup.findAll("playingtime"))
if minutes:
seconds_to_play = int(minutes) * 60
game_dict = {
"bggeek_id": bgg_id,
"title": take_first(soup.findAll("name", primary="true")),
"description": take_first(soup.findAll("description")),
"year_published": take_first(soup.findAll("yearpublished")),
"publisher_name": take_first(soup.findAll("boardgamepublisher")),
"cover_url": take_first(soup.findAll("image")),
"min_players": take_first(soup.findAll("minplayers")),
"max_players": take_first(soup.findAll("maxplayers")),
"recommended_age": take_first(soup.findAll("age")),
"run_time_seconds": seconds_to_play,
}
return game_dict
def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
bgg_username = "secstate" # user.profile.bgg_username
bgg_password = "yYFCKnfo8AK89lc68q0S"
if not bgg_username or bgg_password:
return
login_payload = {
"credentials": {"username": bgg_username, "password": bgg_password}
}
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
with requests.Session() as s:
p = s.post(
"https://boardgamegeek.com/login/api/v1",
data=json.dumps(login_payload),
headers=headers,
)
players = []
if scrobble.log:
for player in scrobble.log.get("players"):
player_person = Person.objects.filter(
id=player.get("person_id")
).first()
if player_person.get("bgg_username"):
player["username"] = player_person.get("bgg_username")
player["name"] = player_person.get("name")
player["win"] = player.get("win")
# player["role"] = player.get("role")
player["new"] = player.get("new")
player["score"] = player.get("score")
players.append(player)
play_payload = {
"playdate": scrobble.timestamp.date.strftime("%Y-%m-%d"),
"length": scrobble.playback_position_seconds / 60,
"comments": "Uploaded from Vrobbler",
"location": scrobble.log.location or None,
"objectid": scrobble.media_obj.bggeek_id,
"quantity": "1",
"action": "save",
"players": players,
"objecttype": "thing",
"ajax": 1,
}
r = s.post(
"https://boardgamegeek.com/geekplay.php",
data=json.dumps(play_payload),
headers=headers,
)

View File

@ -0,0 +1,168 @@
# Generated by Django 4.1.7 on 2023-04-17 22:11
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 = [
("scrobbles", "0038_alter_objectwithgenres_tag"),
]
operations = [
migrations.CreateModel(
name="BoardGamePublisher",
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"
),
),
("name", models.CharField(max_length=255)),
(
"uuid",
models.UUIDField(
blank=True,
default=uuid.uuid4,
editable=False,
null=True,
),
),
(
"logo",
models.ImageField(
blank=True,
null=True,
upload_to="games/platform-logos/",
),
),
("igdb_id", models.IntegerField(blank=True, null=True)),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
migrations.CreateModel(
name="BoardGame",
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"
),
),
(
"run_time_seconds",
models.IntegerField(blank=True, null=True),
),
(
"run_time_ticks",
models.PositiveBigIntegerField(blank=True, null=True),
),
("title", models.CharField(max_length=255)),
(
"uuid",
models.UUIDField(
blank=True,
default=uuid.uuid4,
editable=False,
null=True,
),
),
(
"cover",
models.ImageField(
blank=True, null=True, upload_to="boardgames/covers/"
),
),
(
"layout_image",
models.ImageField(
blank=True, null=True, upload_to="boardgames/layouts/"
),
),
("summary", models.TextField(blank=True, null=True)),
("rating", models.FloatField(blank=True, null=True)),
(
"max_players",
models.PositiveSmallIntegerField(blank=True, null=True),
),
(
"min_players",
models.PositiveSmallIntegerField(blank=True, null=True),
),
("published_date", models.DateField(blank=True, null=True)),
(
"recommened_age",
models.PositiveSmallIntegerField(blank=True, null=True),
),
(
"seconds_to_play",
models.IntegerField(blank=True, null=True),
),
(
"bggeek_id",
models.CharField(blank=True, max_length=255, null=True),
),
(
"genre",
taggit.managers.TaggableManager(
help_text="A comma-separated list of tags.",
through="scrobbles.ObjectWithGenres",
to="scrobbles.Genre",
verbose_name="Tags",
),
),
(
"publisher",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="boardgames.boardgamepublisher",
),
),
],
options={
"abstract": False,
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.7 on 2023-04-17 22:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("boardgames", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="boardgame",
name="description",
field=models.TextField(blank=True, null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.7 on 2023-04-17 22:17
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("boardgames", "0002_boardgame_description"),
]
operations = [
migrations.RenameField(
model_name="boardgame",
old_name="recommened_age",
new_name="recommended_age",
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 4.1.7 on 2023-04-17 22:25
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("boardgames", "0003_rename_recommened_age_boardgame_recommended_age"),
]
operations = [
migrations.RemoveField(
model_name="boardgame",
name="seconds_to_play",
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 4.1.7 on 2023-04-17 22:29
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("boardgames", "0004_remove_boardgame_seconds_to_play"),
]
operations = [
migrations.RemoveField(
model_name="boardgame",
name="summary",
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 4.1.7 on 2023-04-18 02:33
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0040_alter_scrobble_media_type"),
("boardgames", "0005_remove_boardgame_summary"),
]
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="Tags",
),
),
]

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