Compare commits

...

327 Commits

Author SHA1 Message Date
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
277 changed files with 16799 additions and 5189 deletions

View File

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

View File

@ -0,0 +1,114 @@
name: build & deploy
on:
push:
branches: ["**"]
tags: ["*"]
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"
# Cache pip + Poetry caches (rough equivalent to your mounted pip_cache)
- 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
# Notifications (success/failure) for the test job
- 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
deploy:
# Only deploy on tags (equivalent to Drone when: ref: refs/tags/*)
# if: startsWith(gitea.ref, 'refs/tags/')
needs: [test]
runs-on: ubuntu-latest
steps:
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: vrobbler.service
username: root
key: ${{ secrets.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
- name: Notify deploy success (ntfy)
if: success()
run: |
curl -fsS \
-H "Title: vrobbler deploy success" \
-H "Priority: low" \
-H "Tags: success,vrobbler,deploy" \
-H "Actions: view, View changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, View build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
-d "🚀 Deploy succeeded: ${{ gitea.ref_name }} (${{ gitea.sha }})" \
https://ntfy.unbl.ink/drone
- name: Notify deploy failure (ntfy)
if: failure()
run: |
curl -fsS \
-H "Title: vrobbler deploy failure" \
-H "Priority: high" \
-H "Tags: failure,vrobbler,deploy" \
-H "Actions: view, View changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, View build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
-d "💥 Deploy failed: ${{ gitea.ref_name }} (${{ gitea.sha }})" \
https://ntfy.unbl.ink/drone

2
.gitignore vendored
View File

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

View File

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

1244
PROJECT.org Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

10
justfile Normal file
View File

@ -0,0 +1,10 @@
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

5701
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@ description = ""
authors = ["Colin Powell <colin@unbl.ink>"]
[tool.poetry.dependencies]
python = ">=3.9,<3.12"
python = ">=3.11,<3.14"
Django = "^4.0.3"
django-extensions = "^3.1.5"
python-dateutil = "^2.8.2"
@ -16,8 +16,8 @@ httpx = "<=0.27.2"
djangorestframework = "^3.13.1"
Markdown = "^3.3.6"
django-filter = "^21.1"
Pillow = "^9.0.1"
psycopg2 = "^2.9.3"
Pillow = "^10.0.0"
psycopg2 = "2.9.10"
dj-database-url = "^0.5.0"
django-mathfilters = "^1.0.0"
django-allauth = "^0.50.0"
@ -28,7 +28,7 @@ django-markdownify = "^0.9.1"
gunicorn = "^20.1.0"
django-simple-history = "^3.1.1"
musicbrainzngs = "^0.7.1"
cinemagoer = "^2022.12.27"
cinemagoerng = {git = "https://github.com/cinemagoer/cinemagoerng"}
pysportsdb = "^0.1.0"
pytz = "^2022.7.1"
django-redis = "^5.2.0"
@ -41,11 +41,11 @@ beautifulsoup4 = "^4.11.2"
django-storages = "^1.13.2"
stream-sqlite = "^0.0.41"
ipython = "^8.14.0"
pendulum = "^2.1.2"
pendulum = "^3"
trafilatura = "^1.6.3"
django-imagekit = "^5.0.0"
thefuzz = "^0.22.1"
dataclass-wizard = "0.22.0"
dataclass-wizard = "^0.35.0"
webdavclient3 = "^3.14.6"
boto3 = "^1.35.37"
urllib3 = "<2"
@ -54,6 +54,12 @@ 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"
[tool.poetry.group.test]
optional = true
@ -77,8 +83,7 @@ types-requests = "^2.27"
bandit = "^1.7.4"
[tool.pytest.ini_options]
minversion = "6.0"
addopts = "-ra -q --reuse-db"
addopts = "-ra -q --reuse-db --no-migrations"
testpaths = ["tests"]
DJANGO_SETTINGS_MODULE='vrobbler.settings-testing'

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.

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

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

View File

View File

@ -0,0 +1,198 @@
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,135 @@
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

@ -7,23 +7,34 @@ 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():
user = User.objects.create(
email="test@exmaple.com", first_name="Test", last_name="User"
)
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": [
{"user_id": user.id, "win": True, "score": 30, "color": "Blue"}
]
{
"person_id": first.id,
"win": True,
"score": 30,
"color": "Blue",
},
{
"person_id": second.id,
"win": False,
"score": 28,
"color": "Red",
},
],
},
)
@ -33,7 +44,7 @@ def test_track():
Track.objects.create(
title="Emotion",
artist=Artist.objects.create(name="Carly Rae Jepsen"),
run_time_seconds=60,
base_run_time_seconds=60,
)
@ -115,6 +126,14 @@ def mopidy_podcast_request_data():
).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"
@ -129,6 +148,7 @@ class JellyfinTrackRequest:
musicbrainz_album_id = "03b864cd-7761-314c-a892-05a89ddff00d"
musicbrainz_artist_id = "95f5b748-d370-47fe-85bd-0af2dc450bc0"
status = "resumed"
client_name = "Jellyfin"
def __init__(self, **kwargs):
self.request_data = {
@ -152,6 +172,7 @@ class JellyfinTrackRequest:
"musicbrainz_artist_id", self.musicbrainz_artist_id
),
"Status": kwargs.get("status", self.status),
"ClientName": kwargs.get("client_name", self.client_name),
}
def __eq__(self, other):

View File

@ -1,4 +1,5 @@
from datetime import datetime, timedelta
from unittest.mock import patch
import pytest
import time_machine
@ -26,8 +27,21 @@ def build_scrobbles(client, request_json, num=7, spacing=2):
@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):
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,
):
build_scrobbles(client, mopidy_track.request_json)
user = get_user_model().objects.first()
count_dict = scrobble_counts(user)
@ -41,8 +55,21 @@ def test_scrobble_counts_data(client, mopidy_track):
@pytest.mark.django_db
@patch("music.models.get_album_metadata_with_artist", return_value={})
@patch("music.models.get_track_metadata_with_artist", return_value={})
@patch("music.models.get_recording_mbid_exact", return_value=(None, None))
@patch("music.models.lookup_artist_from_tadb", return_value={})
@patch("music.models.lookup_album_from_tadb", return_value={})
@time_machine.travel(datetime(2022, 3, 4, 1, 24))
def test_live_charts(client, mopidy_track):
def test_live_charts(
mock_lookup_album_tadb,
mock_lookup_artist_tadb,
mock_get_recording,
mock_get_track,
mock_get_album,
client,
mopidy_track,
):
build_scrobbles(client, mopidy_track.request_json, 7, 1)
user = get_user_model().objects.first()

View File

@ -1,16 +1,15 @@
import pytest
from scrobbles.dataclasses import BoardGameLogData, BoardGameScoreLogData
# from scrobbles.dataclasses import BoardGameLogData, BoardGameScoreLogData
@pytest.mark.skip("Need to get local tests running working again")
@pytest.mark.django_db
def test_boardgame_log_data(boardgame_scrobble):
assert not boardgame_scrobble.geo_location
assert boardgame_scrobble.logdata == BoardGameLogData(
players=[
BoardGameScoreLogData(
user_id=1,
name_str="",
person_id=1,
bgg_username="",
color="Blue",
character=None,
@ -18,10 +17,24 @@ def test_boardgame_log_data(boardgame_scrobble):
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,
),
],
location=None,
geo_location_id=None,
difficulty=None,
solo=None,
two_handed=None,

View File

@ -0,0 +1,41 @@
from unittest.mock import MagicMock, patch
from scrobbles.scrobblers import jellyfin_scrobble_media, mopidy_scrobble_media
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

@ -1,13 +1,15 @@
from datetime import datetime, timedelta
from unittest.mock import patch
from unittest.mock import patch, MagicMock
from django.utils import timezone
from django.contrib.auth import get_user_model
import pytest
import time_machine
from django.urls import reverse
from music.models import Track
from music.models import Track, Artist, Album
from podcasts.models import PodcastEpisode
from scrobbles.models import Scrobble
from tasks.models import Task
@pytest.mark.django_db
@ -18,6 +20,14 @@ def test_get_not_allowed_from_mopidy(client, valid_auth_token):
assert response.status_code == 405
@pytest.mark.django_db
def test_get_not_allowed_from_jellyfin(client, valid_auth_token):
url = reverse("scrobbles:jellyfin-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
response = client.get(url, headers=headers)
assert response.status_code == 405
@pytest.mark.django_db
def test_bad_mopidy_request_data(client, valid_auth_token):
url = reverse("scrobbles:mopidy-webhook")
@ -30,6 +40,339 @@ def test_bad_mopidy_request_data(client, valid_auth_token):
)
@pytest.mark.django_db
def test_bad_jellyfin_request_data(client, valid_auth_token):
url = reverse("scrobbles:jellyfin-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
response = client.post(url, headers)
assert response.status_code == 400
assert (
response.data["detail"]
== "JSON parse error - Expecting value: line 1 column 1 (char 0)"
)
@pytest.mark.django_db
@patch("music.models.Artist.find_or_create")
@patch("music.models.Track.find_or_create")
def test_create_scrobble_from_mopidy_track_webhook(
mock_track_fc,
mock_artist_fc,
client,
valid_auth_token,
mopidy_track,
):
mock_artist = MagicMock(spec=Artist)
mock_artist.id = 1
mock_artist_fc.return_value = mock_artist
mock_track = MagicMock(spec=Track)
mock_track.id = 1
mock_track.scrobble_for_user.return_value = Scrobble(
id=1, track_id=1, user_id=1, in_progress=True
)
mock_track_fc.return_value = mock_track
url = reverse("scrobbles:mopidy-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
response = client.post(
url,
mopidy_track.request_data,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
assert response.data == {"scrobble_id": 1}
mock_track.scrobble_for_user.assert_called_once()
@pytest.mark.django_db
@patch("music.models.Artist.find_or_create")
@patch("music.models.Track.find_or_create")
def test_create_scrobble_from_jellyfin_track_webhook(
mock_track_fc,
mock_artist_fc,
client,
valid_auth_token,
jellyfin_track,
):
mock_artist = MagicMock(spec=Artist)
mock_artist.id = 1
mock_artist_fc.return_value = mock_artist
mock_track = MagicMock(spec=Track)
mock_track.id = 1
mock_track.scrobble_for_user.return_value = Scrobble(
id=1, track_id=1, user_id=1, in_progress=True
)
mock_track_fc.return_value = mock_track
url = reverse("scrobbles:jellyfin-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
with time_machine.travel(datetime(2024, 1, 14, 12, 00, 1)):
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
"%Y-%m-%d %H:%M:%S"
)
response = client.post(
url,
jellyfin_track.request_json,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
assert response.data == {"scrobble_id": 1}
mock_track.scrobble_for_user.assert_called_once()
@pytest.mark.django_db
@patch("music.models.Artist.find_or_create")
@patch("music.models.Track.find_or_create")
def test_mopidy_track_webhook_creates_track_and_scrobble(
mock_track_fc,
mock_artist_fc,
client,
valid_auth_token,
mopidy_track,
):
artist = Artist.objects.create(name="Sublime")
album = Album.objects.create(name="Sublime", album_artist=artist)
track = Track.objects.create(
title="Same in the End",
artist=artist,
base_run_time_seconds=60,
)
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=artist,
base_run_time_seconds=60,
)
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=artist,
base_run_time_seconds=60,
)
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=artist,
album=album,
base_run_time_seconds=60,
)
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=artist,
base_run_time_seconds=60,
)
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=artist,
album=album,
base_run_time_seconds=60,
)
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 "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(
@ -105,6 +448,7 @@ def test_scrobble_mopidy_podcast(
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(
@ -144,11 +488,128 @@ def test_scrobble_jellyfin_track(
assert response.status_code == 200
assert response.data == {"scrobble_id": 1}
scrobble = Scrobble.objects.get(id=1)
assert scrobble.media_obj.__class__ == Track
assert scrobble.media_obj.title == "Emotion"
scrobble = Scrobble.objects.get(id=1)
assert scrobble.media_obj.__class__ == Track
assert scrobble.media_obj.title == "Emotion"
@pytest.mark.django_db
def test_scrobble_detail_view_with_notes_as_flat_list(client):
user = get_user_model().objects.create_user(
username="testuser", email="test@example.com", password="testpass"
)
task = Task.objects.create(
title="Test Task", description="Test description"
)
scrobble = Scrobble.objects.create(
task=task,
media_type="Task",
user=user,
log={
"notes": ["First note", "Second note"],
"description": "Test description",
},
)
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
response = client.get(url)
assert response.status_code == 200
assert "First note" in response.content.decode()
assert "Second note" in response.content.decode()
@pytest.mark.django_db
def test_scrobble_detail_view_with_notes_as_dict_timestamps(client):
user = get_user_model().objects.create_user(
username="testuser", email="test@example.com", password="testpass"
)
task = Task.objects.create(
title="Test Task", description="Test description"
)
scrobble = Scrobble.objects.create(
task=task,
media_type="Task",
user=user,
log={
"notes": [
{"2024-01-01 10:00:00": "Note at first timestamp"},
{"2024-01-02 11:30:00": "Note at second timestamp"},
],
"description": "Test description",
},
)
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
response = client.get(url)
assert response.status_code == 200
content = response.content.decode()
assert "2024-01-01 10:00:00" in content
assert "Note at first timestamp" in content
assert "2024-01-02 11:30:00" in content
assert "Note at second timestamp" in content
@pytest.mark.django_db
def test_scrobble_detail_view_with_notes_and_labels(client):
user = get_user_model().objects.create_user(
username="testuser", email="test@example.com", password="testpass"
)
task = Task.objects.create(
title="Test Task", description="Test description"
)
scrobble = Scrobble.objects.create(
task=task,
media_type="Task",
user=user,
log={
"notes": [
{"2024-01-01 10:00:00": "Note with label"},
],
"labels": ["work", "urgent"],
"description": "Test description",
},
)
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
response = client.get(url)
assert response.status_code == 200
content = response.content.decode()
assert "work" in content
assert "urgent" in content
@pytest.mark.django_db
def test_scrobble_detail_view_post_updates_log(client):
user = get_user_model().objects.create_user(
username="testuser", email="test@example.com", password="testpass"
)
task = Task.objects.create(
title="Test Task", description="Test description"
)
scrobble = Scrobble.objects.create(
task=task,
media_type="Task",
user=user,
log={
"notes": ["Original note"],
"description": "Original description",
},
)
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
client.force_login(user)
response = client.post(
url,
{
"description": "Updated description",
"notes": "Updated note",
},
)
assert response.status_code == 302
scrobble.refresh_from_db()
assert scrobble.log["description"] == "Updated description"
assert scrobble.log["notes"] == ["Updated note"]
@pytest.mark.skip("Need to refactor")
@pytest.mark.django_db
@patch("music.utils.lookup_artist_from_mb", return_value={})
@patch(
@ -199,6 +660,7 @@ def test_scrobble_jellyfin_track_update(
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(

View File

@ -0,0 +1,95 @@
import os
from unittest.mock import patch, MagicMock
from pathlib import Path
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):
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"
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_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

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

View File

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

484
todos.org
View File

@ -1,484 +0,0 @@
#+title: TODOs
A fun way to keep track of things in the project to fix or improve.
* Version 0.16.0
** TODO [#A] Jellyfin, bandcamp tracks from Mopidy create duplicate music tracks :bug:scrobbling:music:
Effectively, any track that comes in without a MusicBrainz ID does some funky
lookup where it doesn't find a track without an MB id and the track title /
artist combination and creates a new track everytime. This has to be cleaend up
by condensing the duplicated tracks into the original proper track.
But it opens a bigger quesiton about how much MB id should the drive the app
lookup. If it can't be depended on to exist from all sources, it really can't be
canonical. Instead, the combination of track title / artist is really the best
we can do. Last.fm also has this problem, where it doesn't know about albums and
definitely does not know or care about MB ids.
** DONE Add a user profile page with ability to change settings :profiles:improvement:
- Note taken on [2025-04-04 Fri 10:51]
[[orgit-rev:~/src/code.unbl.ink/secstate/vrobbler/::93c16d80ecff4cd1663cf9ec40fbe6d8f58c3e44][~/src/code.unbl.ink/secstate/vrobbler/ (magit-rev 93c16d8)]]
https://code.unbl.ink/secstate/vrobbler/commit/93c16d80ecff4cd1663cf9ec40fbe6d8f58c3e44
** DONE What to do with Youtube videos from LastFM and web-scrobbler :bug:source:lastfm:
- Note taken on [2025-04-04 Fri 10:46]
Nothing. Over the last few months I built out a youtube model in videos and
use a bookmarklet scrobbling pattern. Now web-scrobbler is just disabled for
Youtube.
May want to revisit this at some point and only scrobble tracks from Youtube,
because many people use YT for music listening.
** DONE [#C] Consider a purge command for duplicated and stuck in-progress scrobbles :utililty:improvement:
CLOSED: [2023-04-06 Thu 14:09]
** DONE Add a "stop_timestamp" so we don't rely on content length :improvement:scrobbling:
CLOSED: [2023-04-02 Sun 23:58]
Essentially, we currently have the timestamp as when the content began
scrobbling and then calculate the finish time from the length of the content.
This works pretty well because we know how long most things are.
But in some cases, sports events or long podcasts, we may start mid-way through
an event or finish halfway through but still want to mark it as done. In these
cases, knowing the finish time could be useful, especially when interfacing with
other scrobblers which may have different definitions of when a scrobble
finishes or started.
** DONE Fix bug with Various Artist albums being labeled with first artist as album artist :scrobbling:bug:music:
CLOSED: [2023-03-27 Mon 20:18]
:LOGBOOK:
CLOCK: [2023-03-26 Sun 22:01]--[2023-03-27 Mon 01:07] => 3:06
:END:
** DONE Fix bug with weekly aggregator being blank on Sundays :aggregators:music:bug:
CLOSED: [2023-03-26 Sun 13:52]
** DONE Fix KoReader scrobbling to use pages rather than time of last read :scrobbling:books:improvement:
CLOSED: [2023-03-26 Sun 13:51]
:LOGBOOK:
CLOCK: [2023-03-26 Sun 13:11]--[2023-03-26 Sun 13:51] => 0:40
:END:
** DONE [#A] Add django-storage to store files on S3 :settings:improvement:
CLOSED: [2023-03-24 Fri 14:46]
:LOGBOOK:
CLOCK: [2023-03-24 Fri 10:47]--[2023-03-24 Fri 14:46] => 3:59
CLOCK: [2023-03-24 Fri 10:36]--[2023-03-24 Fri 10:40] => 0:04
:END:
** DONE Fix vrobbler settings not using booleans :settings:bug:
CLOSED: [2023-03-24 Fri 10:45]
:LOGBOOK:
CLOCK: [2023-03-24 Fri 10:40]--[2023-03-24 Fri 10:46] => 0:06
:END:
** DONE Update weekly live chart to be 7-day continuous rather than weekly :views:bug:
CLOSED: [2023-03-24 Fri 00:31]
The live view will be blank every Monday, no reason to tie it to a day of the
week. It should be "the last 7 days"
** DONE [#B] Implement a detail view for TV shows :improvement:views:
CLOSED: [2023-03-22 Wed 17:05]
** DONE [#B] Implement a detail view for Movies :improvement:views:
CLOSED: [2023-03-22 Wed 17:05]
** DONE Add "service provider" to TV Series, and use that for source when available :bug:scrobbling:
CLOSED: [2023-03-22 Wed 17:04]
** DONE Add view for long-play content (books, video games) to restart them :views:improvement:
CLOSED: [2023-03-22 Wed 17:01]
** DONE Add live chart view like Maloja :improvement:views:
CLOSED: [2023-03-07 Tue 11:13]
** DONE [#C] Figure out how to add to web-scrobbler :improvement:scrobbling:
CLOSED: [2023-03-22 Wed 17:06]
An example:
https://github.com/web-scrobbler/web-scrobbler/blob/master/src/core/background/scrobbler/maloja-scrobbler.js
This is actually going to be moot because we can import from LastFM, and
web-scrobbler integrates well with LastFM. The only thing to think through here
now is what to do with all the garbage web-scrobbler sometimes pushes to LastFM
from Youtube (all videos get pushed, sigh).
** DONE Add Amazon scraper to look up books when OL fails :books:improvement:
This turned out to be a non-starter ... Amazon is aggressive at disallowing
scraping quality. And all the OSS tools out there are stuck in an arms race
trying to keep them from breaking.
That said, Google Books actually has a decent API (for now), and I've built this
out using that.
** DONE Fix bug in Jellyfin scrobbles that spam more scrobbles after completion :scrobbling:videos:bug:
This was fixed a while ago, but there's a new manifested bug. Going to create a
separate bug tracking ticket for that.
* Version 0.11.4
** DONE Add rudimentary video game scrobbling :improvement:content:videogames:
CLOSED: [2023-03-07 Tue 11:11]
** DONE Add ability to scrobble from KOReader statistics files :improvement:books:content:
CLOSED: [2023-03-07 Tue 11:11]
** 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]
** DONE [#A] Add ability to manually scrobble albums or tracks from MB :improvement:
CLOSED: [2023-03-07 Tue 11:09]
Given a UUID from musicbrainz, we should be able to scrobble an album or
individual track.
** DONE [#C] Implement keeping track of week/month/year chart-toppers :improvement:
CLOSED: [2023-03-07 Tue 11:10]
: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.
* Backlog
** TODO [#C] Move to using more robust mopidy-webhooks pacakge form pypi :utility: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 Fix bug in podcast scrobbling where a second scrobble is created after completion :scrobbling:podcasts:bug:

View File

View File

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

View File

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

View File

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

View File

@ -1,18 +1,25 @@
from dataclasses import dataclass
from typing import Optional
from uuid import uuid4
from beers.untappd import get_beer_from_untappd_id, get_rating_from_soup
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 BeerLogData
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)

View File

@ -1,6 +1,11 @@
from django.contrib import admin
from boardgames.models import BoardGame, BoardGamePublisher
from boardgames.models import (
BoardGame,
BoardGameLocation,
BoardGamePublisher,
BoardGameDesigner,
)
from scrobbles.admin import ScrobbleInline
@ -15,13 +20,34 @@ class BoardGamePublisherAdmin(admin.ModelAdmin):
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 GameAdmin(admin.ModelAdmin):
class BoardGameAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"bggeek_id",
"title",
"published_date",
"published_year",
)
search_fields = ("title",)
ordering = ("-created",)

View File

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

View File

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

View File

@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Optional
import requests
from bs4 import BeautifulSoup
from django.contrib.auth import get_user_model
from django.conf import settings
User = get_user_model()
if TYPE_CHECKING:
@ -17,6 +18,11 @@ SEARCH_ID_URL = (
"https://boardgamegeek.com/xmlapi/search?search={query}&exact=1"
)
GAME_ID_URL = "https://boardgamegeek.com/xmlapi/boardgame/{id}"
BGG_ACCESS_TOKEN = getattr(settings, "BGG_ACCESS_TOKEN", "")
BASE_HEADERS = {
"User-Agent": "Vrobbler 31.0",
"Authorization": f"Bearer {BGG_ACCESS_TOKEN}",
}
def take_first(thing: Optional[list]) -> str:
@ -37,10 +43,9 @@ def take_first(thing: Optional[list]) -> str:
def lookup_boardgame_id_from_bgg(title: str) -> Optional[int]:
soup = None
headers = {"User-Agent": "Vrobbler 0.11.12"}
game_id = None
url = SEARCH_ID_URL.format(query=title)
r = requests.get(url, headers=headers)
r = requests.get(url, headers=BASE_HEADERS)
if r.status_code == 200:
soup = BeautifulSoup(r.text, "xml")
@ -57,7 +62,6 @@ def lookup_boardgame_id_from_bgg(title: str) -> Optional[int]:
def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
soup = None
game_dict = {}
headers = {"User-Agent": "Vrobbler 0.11.12"}
title = ""
bgg_id = None
@ -73,7 +77,7 @@ def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
bgg_id = lookup_boardgame_id_from_bgg(title)
url = GAME_ID_URL.format(id=bgg_id)
r = requests.get(url, headers=headers)
r = requests.get(url, headers=BASE_HEADERS)
if r.status_code == 200:
soup = BeautifulSoup(r.text, "xml")
@ -100,8 +104,8 @@ def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
bgg_username = user.profile.bgg_username
bgg_password = user.profile.bgg_password
bgg_username = "secstate" # user.profile.bgg_username
bgg_password = "yYFCKnfo8AK89lc68q0S"
if not bgg_username or bgg_password:
return
@ -109,7 +113,8 @@ def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
login_payload = {
"credentials": {"username": bgg_username, "password": bgg_password}
}
headers = {"content-type": "application/json"}
headers = BASE_HEADERS
headers["content-type"] = "application/json"
# TODO Look up past plays for scrobble.media_obj.bggeek_id, and make sure we haven't scrobbled this before
@ -119,24 +124,20 @@ def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
data=json.dumps(login_payload),
headers=headers,
)
players = []
if scrobble.metadata:
for player in scrobble.metadata.players:
if player["user_id"]:
player_user = User.objects.filter(
id=player["user_id"]
).first()
if player_user:
if player_user.bgg_username:
player["username"] = player_user.bgg_username
else:
player["name"] = player_user.username
player["win"] = player.get("win")
player["color"] = player.get("color")
player["new"] = player.get("new")
player["score"] = player.get("score")
players.append(player)
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"),
@ -150,3 +151,8 @@ def push_scrobble_to_bgg(scrobble: "Scrobble", user: User) -> Optional[bool]:
"objecttype": "thing",
"ajax": 1,
}
r = s.post(
"https://boardgamegeek.com/geekplay.php",
data=json.dumps(play_payload),
headers=headers,
)

View File

@ -0,0 +1,167 @@
# Generated by Django 4.2.19 on 2025-07-03 01:57
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
import uuid
class Migration(migrations.Migration):
dependencies = [
("locations", "0007_alter_geolocation_run_time_seconds"),
("boardgames", "0007_alter_boardgame_run_time_seconds"),
]
operations = [
migrations.CreateModel(
name="BoardGameDesigner",
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,
),
),
("bgg_id", models.IntegerField(blank=True, null=True)),
("bio", models.TextField(blank=True, null=True)),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
migrations.RenameField(
model_name="boardgamepublisher",
old_name="igdb_id",
new_name="bgg_id",
),
migrations.AddField(
model_name="boardgame",
name="bgstats_id",
field=models.UUIDField(blank=True, null=True),
),
migrations.AddField(
model_name="boardgame",
name="cooperative",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="boardgame",
name="expansion_for_boardgame",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="boardgames.boardgame",
),
),
migrations.AddField(
model_name="boardgame",
name="highest_wins",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="boardgame",
name="max_play_time",
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="boardgame",
name="min_play_time",
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="boardgame",
name="no_points",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="boardgame",
name="uses_teams",
field=models.BooleanField(default=False),
),
migrations.CreateModel(
name="BoardGameLocation",
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,
),
),
("bgstats_id", models.UUIDField(blank=True, null=True)),
("description", models.TextField(blank=True, null=True)),
(
"geo_location",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="locations.geolocation",
),
),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
migrations.AddField(
model_name="boardgame",
name="designers",
field=models.ManyToManyField(
related_name="board_games", to="boardgames.boardgamedesigner"
),
),
]

View File

@ -0,0 +1,33 @@
# Generated by Django 4.2.19 on 2025-07-03 02:21
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("boardgames", "0008_boardgamedesigner_and_more"),
]
operations = [
migrations.AlterField(
model_name="boardgame",
name="cooperative",
field=models.BooleanField(blank=True, default=False, null=True),
),
migrations.AlterField(
model_name="boardgame",
name="highest_wins",
field=models.BooleanField(blank=True, default=True, null=True),
),
migrations.AlterField(
model_name="boardgame",
name="no_points",
field=models.BooleanField(blank=True, default=False, null=True),
),
migrations.AlterField(
model_name="boardgame",
name="uses_teams",
field=models.BooleanField(blank=True, default=False, null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.19 on 2025-07-03 04:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("boardgames", "0009_alter_boardgame_cooperative_and_more"),
]
operations = [
migrations.AddField(
model_name="boardgame",
name="published_year",
field=models.IntegerField(blank=True, null=True),
),
]

View File

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

View File

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

View File

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

View File

@ -1,10 +1,13 @@
import logging
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from functools import cached_property
from typing import Any, Optional
from uuid import uuid4
import requests
from boardgames.bgg import lookup_boardgame_from_bgg
from boardgames.sources.bgg import lookup_boardgame_from_bgg
from django import forms
from django.conf import settings
from django.core.files.base import ContentFile
from django.db import models
@ -12,18 +15,120 @@ 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 BoardGameLogData
from locations.models import GeoLocation
from people.models import Person
from scrobbles.dataclasses import BaseLogData, LongPlayLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
@dataclass
class BoardGameScoreLogData(BaseLogData):
person_id: Optional[int] = None
bgg_username: Optional[str] = None
color: Optional[str] = None
character: Optional[str] = None
team: Optional[str] = None
score: Optional[int] = None
win: Optional[bool] = None
new: Optional[bool] = None
rank: Optional[int] = None
seat_order: Optional[int] = None
role: Optional[str] = None
rank: Optional[int] = None
seat_order: Optional[int] = None
role: Optional[str] = None
lichess_username: Optional[str] = None
@property
def person(self) -> Optional[Person]:
return Person.objects.filter(id=self.person_id).first()
@property
def name(self) -> str:
name = ""
if self.person:
name = self.person.name
return name
def __str__(self) -> str:
out = self.name
if self.score:
out += f" {self.score}"
if self.color:
out += f" ({self.color})"
if self.win:
out += f" [W]"
return out
@dataclass
class BoardGameLogData(BaseLogData, LongPlayLogData):
players: Optional[list[BoardGameScoreLogData]] = None
location_id: Optional[int] = None
difficulty: Optional[int] = None
solo: Optional[bool] = None
two_handed: Optional[bool] = None
expansion_ids: Optional[int] = None
moves: Optional[list] = None
rated: Optional[str] = None
speed: Optional[str] = None
variant: Optional[str] = None
lichess_id: Optional[int] = None
board: Optional[str] = None
rounds: Optional[int] = None
details: Optional[str] = None
_excluded_fields = {
"lichess_id",
"speed",
"rated",
"moves",
"variant",
}
@cached_property
def location(self):
if not self.location_id:
return
return BoardGameLocation.objects.filter(id=self.location_id).first()
@cached_property
def player_log(self) -> str:
if self.players:
return ", ".join(
[
BoardGameScoreLogData(**player).__str__()
for player in self.players
]
)
return ""
@classmethod
def override_fields(cls) -> dict:
fields = {}
for base in cls.mro()[1:]:
if hasattr(base, "override_fields"):
base_fields = base.override_fields()
fields.update(base_fields)
custom_fields = {
"location_id": forms.ModelChoiceField(
queryset=BoardGameLocation.objects.all(),
required=False,
widget=forms.Select(),
)
}
fields.update(custom_fields)
return fields
class BoardGamePublisher(TimeStampedModel):
name = models.CharField(max_length=255)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
logo = models.ImageField(upload_to="games/platform-logos/", **BNULL)
igdb_id = models.IntegerField(**BNULL)
bgg_id = models.IntegerField(**BNULL)
def __str__(self):
return self.name
@ -34,6 +139,39 @@ class BoardGamePublisher(TimeStampedModel):
)
class BoardGameDesigner(TimeStampedModel):
name = models.CharField(max_length=255)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
bgg_id = models.IntegerField(**BNULL)
bio = models.TextField(**BNULL)
def __str__(self) -> str:
return str(self.name)
def get_absolute_url(self):
return reverse(
"boardgames:designer_detail", kwargs={"slug": self.uuid}
)
class BoardGameLocation(TimeStampedModel):
name = models.CharField(max_length=255)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
bgstats_id = models.UUIDField(**BNULL)
description = models.TextField(**BNULL)
geo_location = models.ForeignKey(
GeoLocation, **BNULL, on_delete=models.DO_NOTHING
)
def __str__(self) -> str:
return str(self.name)
def get_absolute_url(self):
return reverse(
"boardgames:location_detail", kwargs={"slug": self.uuid}
)
class BoardGame(ScrobblableMixin):
COMPLETION_PERCENT = getattr(
settings, "BOARD_GAME_COMPLETION_PERCENT", 100
@ -53,6 +191,14 @@ class BoardGame(ScrobblableMixin):
publisher = models.ForeignKey(
BoardGamePublisher, **BNULL, on_delete=models.DO_NOTHING
)
publishers = models.ManyToManyField(
BoardGamePublisher,
related_name="board_games",
)
designers = models.ManyToManyField(
BoardGameDesigner,
related_name="board_games",
)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
description = models.TextField(**BNULL)
cover = models.ImageField(upload_to="boardgames/covers/", **BNULL)
@ -82,11 +228,23 @@ class BoardGame(ScrobblableMixin):
options={"quality": 75},
)
rating = models.FloatField(**BNULL)
bgg_rank = models.IntegerField(**BNULL)
max_players = models.PositiveSmallIntegerField(**BNULL)
min_players = models.PositiveSmallIntegerField(**BNULL)
published_date = models.DateField(**BNULL)
published_year = models.IntegerField(**BNULL)
recommended_age = models.PositiveSmallIntegerField(**BNULL)
bggeek_id = models.CharField(max_length=255, **BNULL)
bgstats_id = models.UUIDField(**BNULL)
uses_teams = models.BooleanField(default=False, **BNULL)
cooperative = models.BooleanField(default=False, **BNULL)
highest_wins = models.BooleanField(default=True, **BNULL)
no_points = models.BooleanField(default=False, **BNULL)
min_play_time = models.IntegerField(**BNULL)
max_play_time = models.IntegerField(**BNULL)
expansion_for_boardgame = models.ForeignKey(
"self", **BNULL, on_delete=models.DO_NOTHING
)
def __str__(self):
return self.title
@ -128,7 +286,7 @@ class BoardGame(ScrobblableMixin):
publisher_name = data.pop("publisher_name")
if year:
data["published_date"] = datetime(int(year), 1, 1)
data["published_year"] = int(year)
if not data["min_players"]:
data.pop("min_players")
@ -148,29 +306,72 @@ class BoardGame(ScrobblableMixin):
# Go get cover image if the URL is present
if cover_url and not self.cover:
headers = {"User-Agent": "Vrobbler 0.11.12"}
r = requests.get(cover_url, headers=headers)
logger.debug(r.status_code)
if r.status_code == 200:
fname = f"{self.title}_cover_{self.uuid}.jpg"
self.cover.save(fname, ContentFile(r.content), save=True)
logger.debug("Loaded cover image from BGGeek")
self.save_image_from_url(cover_url)
def save_image_from_url(self, url):
headers = {"User-Agent": "Vrobbler 0.11.12"}
r = requests.get(url, headers=headers)
if r.status_code == 200:
fname = f"{self.title}_cover_{self.uuid}.jpg"
self.cover.save(fname, ContentFile(r.content), save=True)
@classmethod
def find_or_create(
cls, lookup_id: str, data: Optional[dict] = {}
) -> Optional["BoardGame"]:
cls, lookup_id: str, data: dict[str, Any] = {}
) -> "BoardGame":
"""Given a Lookup ID (either BGG or BGA ID), return a board game object"""
boardgame = cls.objects.filter(bggeek_id=lookup_id).first()
game = cls.objects.filter(bggeek_id=lookup_id).first()
if not game:
game = cls.objects.filter(title=lookup_id).first()
if not data or not boardgame:
data = lookup_boardgame_from_bgg(lookup_id)
if data and not boardgame:
boardgame, created = cls.objects.get_or_create(
title=data["title"], bggeek_id=lookup_id
if game:
logger.info(
"Board game exists in database.",
extra={"lookup_id": lookup_id, "data": data},
)
if created:
boardgame.fix_metadata(data=data)
return game
return boardgame
if data.get("bggId"):
bgg_data = lookup_boardgame_from_bgg(lookup_id=data.get("bggId"))
elif data.get("name"):
bgg_data = lookup_boardgame_from_bgg(title=data.get("name"))
else:
bgg_data = lookup_boardgame_from_bgg(title=lookup_id)
mechanics = bgg_data.pop("mechanics", [])
designers = bgg_data.pop("designers", [])
categories = bgg_data.pop("categories", [])
publishers = bgg_data.pop("publishers", [])
publisher = bgg_data.pop("publisher", [])
cover_url = bgg_data.pop("cover_url")
game = cls.objects.create(**bgg_data)
game.save_image_from_url(cover_url)
game.cooperative = data.get("cooperative", False)
game.highest_wins = data.get("highestWins", True)
game.no_points = data.get("noPoints", False)
game.uses_teams = data.get("useTeams", False)
game.bgstats_id = data.get("uuid", None)
if publisher:
publisher, _ = BoardGamePublisher.objects.get_or_create(
name=publisher
)
game.publisher = publisher
game.save()
if designers:
for designer_name in designers:
designer, created = BoardGameDesigner.objects.get_or_create(
name=designer_name
)
game.designers.add(designer.id)
if publishers:
for name in publishers:
publisher, _ = BoardGamePublisher.objects.get_or_create(
name=name
)
game.publishers.add(publisher)
return game

View File

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

View File

@ -3,7 +3,7 @@ from boardgames.models import BoardGame
from django.conf import settings
from django.contrib.auth import get_user_model
from scrobbles.models import Scrobble
from scrobbles.notifications import NtfyNotification
from scrobbles.notifications import ScrobbleNtfyNotification
User = get_user_model()
@ -18,9 +18,9 @@ def import_chess_games_for_user_id(user_id: int, commit: bool = False) -> dict:
for game_dict in games:
chess, created = BoardGame.objects.get_or_create(title="Chess")
if created:
chess.run_time_seconds = 1800
chess.base_run_time_seconds = 1800
chess.bggeek_id = 171
chess.save(update_fields=["run_time_seconds", "bggeek_id"])
chess.save(update_fields=["base_run_time_seconds", "bggeek_id"])
scrobble = Scrobble.objects.filter(
user_id=user.id,
timestamp=game_dict.get("createdAt"),
@ -124,5 +124,5 @@ def import_chess_games_for_all_users():
if scrobbles_to_create:
created = Scrobble.objects.bulk_create(scrobbles_to_create)
for scrobble in created:
NtfyNotification(scrobble).send()
ScrobbleNtfyNotification(scrobble).send()
return scrobbles_to_create

View File

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

View File

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

View File

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

View File

@ -5,3 +5,5 @@ BOOKS_TITLES_TO_IGNORE = [
"zb2rhkSwygt9vjkAEBj7tP5KVgFqejJqsJ2W3bYsrgiiKK8XL",
"zb2rhchGpo7P27mofV9hYjT63d9ZaQnbQ6LSfzmkvsYzvARif",
]
READCOMICSONLINE_URL = "https://readcomicsonline.ru"

View File

@ -3,14 +3,14 @@ import re
import sqlite3
from datetime import datetime, timedelta
from enum import Enum
from zoneinfo import ZoneInfo
import pytz
import requests
from books.constants import BOOKS_TITLES_TO_IGNORE
from django.apps import apps
from django.contrib.auth import get_user_model
from scrobbles.notifications import ScrobbleNtfyNotification
from stream_sqlite import stream_sqlite
from scrobbles.notifications import NtfyNotification
from webdav.client import get_webdav_client
logger = logging.getLogger(__name__)
@ -114,7 +114,7 @@ def create_book_from_row(row: list):
"raw_row_data": clean_row,
}
},
run_time_seconds=run_time,
base_run_time_seconds=run_time,
)
# TODO Move these to async processes after importing
# book.fix_metadata()
@ -139,7 +139,6 @@ def build_book_map(rows) -> dict:
book_id_map = {}
for book_row in rows:
if book_row[KoReaderBookColumn.TITLE.value] in BOOKS_TITLES_TO_IGNORE:
logger.info(
"[build_book_map] Ignoring book title that is likely garbage",
@ -278,55 +277,19 @@ def build_scrobbles_from_book_map(
)
continue
timezone = user.profile.timezone
timestamp = user.profile.get_timestamp_with_tz(
datetime.fromtimestamp(int(first_page.get("start_ts")))
)
stop_timestamp = user.profile.get_timestamp_with_tz(
datetime.fromtimestamp(int(last_page.get("end_ts")))
)
timestamp = datetime.fromtimestamp(
int(first_page.get("start_ts"))
).replace(tzinfo=pytz.timezone(timezone))
# Add a shim here temporarily to fix imports while we were in France
# if date is between 10/15 and 12/15, cast it to Europe/Central
if (
datetime(2023, 10, 15).replace(
tzinfo=pytz.timezone("Europe/Paris")
)
<= timestamp
<= datetime(2023, 12, 15).replace(
tzinfo=pytz.timezone("Europe/Paris")
)
):
timezone = "Europe/Paris"
if (
datetime(2024, 4, 28).replace(
tzinfo=pytz.timezone("US/Pacific")
)
<= timestamp
<= datetime(2024, 5, 4).replace(
tzinfo=pytz.timezone("US/Pacific")
)
):
timezone = "US/Pacific"
if (
datetime(2024, 8, 4).replace(
tzinfo=pytz.timezone("Canada/Atlantic")
)
<= timestamp
<= datetime(2024, 8, 10).replace(
tzinfo=pytz.timezone("Canada/Atlantic")
)
):
timezone = "Canada/Atlantic"
stop_timestamp = datetime.fromtimestamp(
int(last_page.get("end_ts"))
).replace(tzinfo=pytz.timezone(timezone))
if (
timestamp.tzinfo._dst.seconds == 0
or stop_timestamp.tzinfo._dst.seconds == 0
):
timestamp = timestamp - timedelta(hours=1)
stop_timestamp = stop_timestamp - timedelta(hours=1)
# Adjust for Daylight Saving Time
# if timestamp.dst() == timedelta(
# 0
# ) or stop_timestamp.dst() == timedelta(0):
# timestamp = timestamp - timedelta(hours=1)
# stop_timestamp = stop_timestamp - timedelta(hours=1)
scrobble = Scrobble.objects.filter(
timestamp=timestamp,
@ -343,6 +306,10 @@ def build_scrobbles_from_book_map(
"page_data": scrobble_page_data,
"pages_read": len(scrobble_page_data.keys()),
}
if hasattr(timestamp.tzinfo, "tzname"):
tz = timestamp.tzinfo.tzname
if hasattr(timestamp.tzinfo, "name"):
tz = timestamp.tzinfo.name
scrobbles_to_create.append(
Scrobble(
book_id=book_id,
@ -356,7 +323,7 @@ def build_scrobbles_from_book_map(
in_progress=False,
played_to_completion=True,
long_play_complete=False,
timezone=timezone,
timezone=tz,
)
)
# Then start over
@ -398,9 +365,9 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
new_scrobbles = []
user = User.objects.filter(id=user_id).first()
tz = pytz.utc
tz = ZoneInfo("UTC")
if user:
tz = user.profile.timezone
tz = user.profile.tzinfo
is_os_file = "https://" not in file_path
if is_os_file:
@ -443,7 +410,7 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
if new_scrobbles:
created = Scrobble.objects.bulk_create(new_scrobbles)
if created:
NtfyNotification(created[-1]).send()
ScrobbleNtfyNotification(created[-1]).send()
fix_long_play_stats_for_scrobbles(created)
logger.info(
f"Created {len(created)} scrobbles",

View File

@ -14,14 +14,13 @@ logger = logging.getLogger(__name__)
# Grace period between page reads for it to be a new scrobble
SESSION_GAP_SECONDS = 1800 # a half hour
def update_scrobble_from_page_data(scrobble, commit=True):
page_list = list(scrobble.book_page_data.items())
first_page_start_ts = datetime.fromtimestamp(page_list[0][1]["start_ts"])
last_page_end_ts = datetime.fromtimestamp(page_list[-1][1]["end_ts"])
if (
datetime(2023, 10, 15) <= first_page_start_ts <= datetime(2023, 12, 15)
):
if datetime(2023, 10, 15) <= first_page_start_ts <= datetime(2023, 12, 15):
first_page_start_ts.replace(tzinfo=pytz.timezone("Europe/Paris"))
last_page_end_ts.replace(tzinfo=pytz.timezone("Europe/Paris"))
else:
@ -38,5 +37,7 @@ class Command(BaseCommand):
def handle(self, *args, **options):
scrobbles_to_create = []
for scrobble in Scrobble.objects.filter(media_type="Book", source="KOReader"):
for scrobble in Scrobble.objects.filter(
media_type="Book", source="KOReader"
):
update_scrobble_from_page_data(scrobble)

View File

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

View File

@ -0,0 +1,33 @@
# Generated by Django 4.2.19 on 2025-10-20 18:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('books', '0028_delete_page'),
]
operations = [
migrations.AddField(
model_name='book',
name='comicvine_id',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='book',
name='issue_number',
field=models.IntegerField(blank=True, max_length=5, null=True),
),
migrations.AddField(
model_name='book',
name='original_title',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='book',
name='volume_number',
field=models.IntegerField(blank=True, max_length=5, null=True),
),
]

View File

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

View File

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

View File

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

View File

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

View File

@ -1,13 +1,19 @@
from collections import OrderedDict
import logging
from datetime import timedelta, datetime
from collections import OrderedDict
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from uuid import uuid4
import requests
from books.constants import READCOMICSONLINE_URL
from books.openlibrary import (
lookup_author_from_openlibrary,
lookup_book_from_openlibrary,
)
from books.sources.google import lookup_book_from_google
from books.sources.semantic import lookup_paper_from_semantic
from books.utils import get_comic_issue_url
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.files.base import ContentFile
@ -16,28 +22,25 @@ from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from scrobbles.dataclasses import BaseLogData, LongPlayLogData
from scrobbles.mixins import (
LongPlayScrobblableMixin,
ObjectWithGenres,
ScrobblableConstants,
ScrobblableMixin,
)
from scrobbles.utils import get_scrobbles_for_media
from scrobbles.utils import get_scrobbles_for_media, next_url_if_exists
from taggit.managers import TaggableManager
from thefuzz import fuzz
from vrobbler.apps.books.comicvine import (
ComicVineClient,
lookup_comic_from_comicvine,
)
from vrobbler.apps.books.locg import (
lookup_comic_by_locg_slug,
lookup_comic_from_locg,
lookup_comic_writer_by_locg_slug,
)
from vrobbler.apps.books.sources.google import lookup_book_from_google
from vrobbler.apps.books.sources.semantic import lookup_paper_from_semantic
from vrobbler.apps.scrobbles.dataclasses import BookLogData
from vrobbler.apps.books.sources.comicvine import (
ComicVineClient,
lookup_comic_from_comicvine,
)
COMICVINE_API_KEY = getattr(settings, "COMICVINE_API_KEY", "")
@ -46,6 +49,34 @@ User = get_user_model()
BNULL = {"blank": True, "null": True}
@dataclass
class BookPageLogData(BaseLogData):
page_number: Optional[int] = None
end_ts: Optional[int] = None
start_ts: Optional[int] = None
duration: Optional[int] = None
@dataclass
class BookLogData(BaseLogData, LongPlayLogData):
koreader_hash: Optional[str] = None
page_data: Optional[dict[int, BookPageLogData]] = None
pages_read: Optional[int] = None
page_start: Optional[int] = None
page_end: Optional[int] = None
resume_url: Optional[str] = None
_excluded_fields = {"koreader_hash", "page_data"}
def avg_seconds_per_page(self):
if self.page_data:
total_duration = 0
for page_num, stats in self.page_data.items():
total_duration += stats.get("duration", 0)
if total_duration:
return int(total_duration / len(self.page_data))
class Author(TimeStampedModel):
name = models.CharField(max_length=255)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
@ -107,6 +138,7 @@ class Book(LongPlayScrobblableMixin):
)
title = models.CharField(max_length=255)
original_title = models.CharField(max_length=255, **BNULL)
authors = models.ManyToManyField(Author, blank=True)
koreader_data_by_hash = models.JSONField(**BNULL)
isbn_13 = models.CharField(max_length=255, **BNULL)
@ -117,6 +149,13 @@ class Book(LongPlayScrobblableMixin):
publish_date = models.DateField(**BNULL)
publisher = models.CharField(max_length=255, **BNULL)
first_sentence = models.TextField(**BNULL)
# ComicVine
comicvine_id = models.CharField(max_length=255, **BNULL)
readcomics_url = models.CharField(max_length=255, **BNULL)
next_readcomics_url = models.CharField(max_length=255, **BNULL)
issue_number = models.IntegerField(**BNULL)
volume_number = models.IntegerField(**BNULL)
# OpenLibrary
openlibrary_id = models.CharField(max_length=255, **BNULL)
cover = models.ImageField(upload_to="books/covers/", **BNULL)
cover_small = ImageSpecField(
@ -135,7 +174,11 @@ class Book(LongPlayScrobblableMixin):
genre = TaggableManager(through=ObjectWithGenres)
def __str__(self):
def __str__(self) -> str:
if self.issue_number and "Issue" not in str(self.title):
return f"{self.title} - Issue {self.issue_number}"
if self.volume_number and "Volume" not in str(self.title):
return f"{self.title} - Volume {self.volume_number}"
return f"{self.title}"
@property
@ -161,44 +204,120 @@ class Book(LongPlayScrobblableMixin):
return reverse("books:book_detail", kwargs={"slug": self.uuid})
@classmethod
def get_from_google(cls, title: str, overwrite: bool = False):
def get_from_comicvine(
cls, title: str, overwrite: bool = False, force_new: bool = False
) -> "Book":
book, created = cls.objects.get_or_create(title=title)
if not created and not overwrite:
if not created:
return book
book_dict = lookup_book_from_google(title)
book_dict = lookup_comic_from_comicvine(title)
if created or overwrite:
author_list = []
authors = book_dict.pop("authors")
cover_url = book_dict.pop("cover_url")
try:
genres = book_dict.pop("generes")
except:
genres = []
if authors:
for author_str in authors:
if author_str:
author_dicts = book_dict.pop("author_dicts")
if author_dicts:
for author_dict in author_dicts:
if author_dict.get("authorId"):
author, a_created = Author.objects.get_or_create(
name=author_str
semantic_id=author_dict.get("authorId")
)
author_list.append(author)
if a_created:
# TODO enrich author
author.name = author_dict.get("name")
author.save()
# TODO enrich author?
...
for k, v in book_dict.items():
setattr(book, k, v)
book.save()
if author_list:
book.authors.add(*author_list)
genres = book_dict.pop("genres", [])
if genres:
book.genre.add(*genres)
return book
@classmethod
def find_or_create(
cls,
title: str,
url: str = "",
enrich: bool = False,
commit: bool = True,
):
"""Given a title, get a Book instance.
If the book is not already in our database, or overwrite is True,
this method will enrich the Book with data from Google.
By default this method will also save the data back to the model. If you'd
like to batch create, use commit=False and you'll get an unsaved but enriched
instance back which you can then save at your convenience."""
# TODO use either a Google Books id identifier or author name like for tracks
book, created = cls.objects.get_or_create(original_title=title)
if not created:
logger.info(
"Found exact match for book by title", extra={"title": title}
)
if not enrich:
logger.info(
"Found book by title, but not enriching",
extra={"title": title},
)
return book
book_dict = None
if READCOMICSONLINE_URL in url:
book_dict = lookup_comic_from_comicvine(title)
book_dict["readcomics_url"] = get_comic_issue_url(url)
book_dict["next_readcomics_url"] = next_url_if_exists(
book_dict["readcomics_url"]
)
if not book_dict:
book_dict = lookup_book_from_google(title)
if not book_dict:
logger.warning(
"No book found in any source, using data as is",
extra={"title": title},
)
author_list = []
authors = book_dict.pop("authors", [])
cover_url = book_dict.pop("cover_url", "")
genres = book_dict.pop("generes", [])
if authors:
for author_str in authors:
if author_str:
author, a_created = Author.objects.get_or_create(
name=author_str
)
author_list.append(author)
if a_created:
# TODO enrich author
...
for k, v in book_dict.items():
setattr(book, k, v)
if commit:
book.save()
book.save_image_from_url(cover_url)
book.genre.add(*genres)
book.authors.add(*author_list)
return book
def save_image_from_url(self, url: str, force_update: bool = False):
if not self.cover or (force_update and url):
if url and (not self.cover or force_update):
r = requests.get(url)
if r.status_code == 200:
fname = f"{self.title}_{self.uuid}.jpg"
@ -294,7 +413,7 @@ class Book(LongPlayScrobblableMixin):
self.cover.save(fname, ContentFile(r.content), save=True)
if self.pages:
self.run_time_seconds = int(self.pages) * int(
self.base_run_time_seconds = int(self.pages) * int(
self.AVG_PAGE_READING_SECONDS
)
@ -378,27 +497,6 @@ class Book(LongPlayScrobblableMixin):
progress = int((last_scrobble.last_page_read / self.pages) * 100)
return progress
@classmethod
def find_or_create(cls, lookup_id: str, author: str = "") -> "Book":
book = cls.objects.filter(openlibrary_id=lookup_id).first()
if not book:
data = lookup_book_from_openlibrary(lookup_id, author)
if not data:
logger.error(
f"No book found on openlibrary, or in our database for {lookup_id}"
)
return book
book, book_created = cls.objects.get_or_create(
isbn_13=data["isbn"]
)
if book_created:
book.fix_metadata(data=data)
return book
class Paper(LongPlayScrobblableMixin):
"""Keeps track of Academic Papers"""

View File

@ -3,7 +3,6 @@ ComicVine API Information & Documentation:
https://comicvine.gamespot.com/api/
https://comicvine.gamespot.com/api/documentation
"""
import json
import logging
from django.conf import settings
@ -200,34 +199,72 @@ class ComicVineClient(object):
def lookup_comic_from_comicvine(title: str) -> dict:
original_title = title
issue_number = None
volume_nubmer = None
resource_type = "issue"
if "Issue " in title:
resource_type = "issue"
issue_number = title.split("Issue ")[1]
volume_number = None
if "Volume " in title:
resource_type = "volume"
volume_number = title.split("Volume ")[1]
api_key = getattr(settings, "COMICVINE_API_KEY", "")
if not api_key:
logger.warn("No ComicVine API key configured, not looking anything up")
logger.warning(
"No ComicVine API key configured, not looking anything up"
)
return {}
client = ComicVineClient(
api_key=getattr(settings, "COMICVINE_API_KEY", None)
)
result = [
r
for r in client.search(title).get("results")
if r.get("resource_type") == "volume"
][0]
if "volume" not in result.keys():
logger.warn("No result found on ComicVine", extra={"title": title})
raw_results = client.search(title).get("results")
results = [
r for r in raw_results if r.get("resource_type") == resource_type
]
if not results:
logger.warning("No comic found on ComicVine")
return {}
title = " ".join([result.get("volume").get("name"), result.get("name)")])
found_result = None
for result in results:
if result.get("issue_number") == str(issue_number):
found_result = result
break
if result.get("volume_number") == str(volume_number):
found_result = result
break
if not found_result:
found_result = results[0]
logger.info("ComicVine results", extra={"results": results})
if not found_result:
logger.warning("No matches found on ComicVine")
return {}
title = found_result.get("name")
if found_result.get("volume"):
title = found_result.get("volume").get("name")
data_dict = {
"title": title,
"cover_url": result.get("image").get("original_url"),
"comicvine_data": {
"id": result.get("id"),
"site_detail_url": result.get("site_detail_url"),
"description": result.get("description"),
"image": result.get("image").get("original_url"),
},
"original_title": original_title,
"issue_number": found_result.get("issue_number"),
"volume_number": found_result.get("volume_number"),
"cover_url": found_result.get("image").get("original_url"),
"comicvine_id": found_result.get("id"),
"comicvine_data": found_result,
"summary": found_result.get("description"),
"publish_date": found_result.get("cover_date"),
"first_publish_year": found_result.get("cover_date", "")[:4],
}
return data_dict

View File

@ -7,7 +7,7 @@ from django.conf import settings
API_KEY = settings.GOOGLE_API_KEY
GOOGLE_BOOKS_URL = (
"https://www.googleapis.com/books/v1/volumes?q=\"{title}\"&key={key}"
'https://www.googleapis.com/books/v1/volumes?q="{title}"&key={key}'
)
logger = logging.getLogger(__name__)
@ -29,6 +29,9 @@ def lookup_book_from_google(title: str) -> dict:
google_result = (
json.loads(response.content).get("items", [{}])[0].get("volumeInfo")
)
if not google_result:
return {}
publish_date = pendulum.parse(google_result.get("publishedDate"))
isbn_13 = ""
@ -59,13 +62,15 @@ def lookup_book_from_google(title: str) -> dict:
book_dict["genres"] = google_result.get("categories")
book_dict["cover_url"] = (
google_result.get("imageLinks", {})
.get("thumbnail")
.get("thumbnail", "")
.replace("zoom=1", "zoom=15")
.replace("&edge=curl", "")
)
book_dict["run_time_seconds"] = book_dict.get("pages", 10) * getattr(
settings, "AVERAGE_PAGE_READING_SECONDS", 60
)
book_dict["base_run_time_seconds"] = 3600
if book_dict.get("pages"):
book_dict["base_run_time_seconds"] = book_dict.get(
"pages", 10
) * getattr(settings, "AVERAGE_PAGE_READING_SECONDS", 60)
return book_dict

View File

@ -67,9 +67,9 @@ def lookup_paper_from_semantic(title: str) -> dict:
paper_dict["openaccess_pdf_url"] = result.get("openAccessPdf", {}).get(
"url"
)
paper_dict["run_time_seconds"] = paper_dict.get("pages", 10) * getattr(
settings, "AVERAGE_PAGE_READING_SECONDS", 60
)
paper_dict["base_run_time_seconds"] = paper_dict.get(
"pages", 10
) * getattr(settings, "AVERAGE_PAGE_READING_SECONDS", 60)
paper_dict["author_dicts"] = result.get("authors")
paper_dict["genres"] = result.get("fieldsOfStudy")

View File

@ -1,11 +1,16 @@
import pytest
from unittest import mock
from datetime import datetime
from books.koreader import (
KoReaderBookColumn,
build_book_map,
build_page_data,
build_scrobbles_from_book_map,
get_author_str_from_row,
lookup_or_create_authors_from_author_str,
create_book_from_row,
process_koreader_sqlite_file,
)
@ -52,3 +57,157 @@ def test_build_scrobbles_from_pages(
assert len(scrobbles[3].logdata.page_data.keys()) == 20
assert len(scrobbles[4].logdata.page_data.keys()) == 20
assert len(scrobbles[5].logdata.page_data.keys()) == 18
def test_get_author_str_from_row():
row = [
1,
"Test Book",
"John Smith\nJane Doe",
0,
0,
0,
300,
"",
"",
"abc123",
0,
120,
]
result = get_author_str_from_row(row)
assert result == "John Smith, Jane Doe"
def test_get_author_str_from_row_strips_middle_initials():
row = [
1,
"Test Book",
"John A. Smith",
0,
0,
0,
300,
"",
"",
"abc123",
0,
120,
]
result = get_author_str_from_row(row)
assert result == "John Smith"
@pytest.mark.django_db
def test_lookup_or_create_authors_from_author_str_creates_new_author():
author = lookup_or_create_authors_from_author_str("New Author")
assert len(author) == 1
assert author[0].name == "New Author"
@pytest.mark.django_db
def test_lookup_or_create_authors_from_author_str_finds_existing():
from books.models import Author
Author.objects.create(name="Existing Author")
author = lookup_or_create_authors_from_author_str("Existing Author")
assert len(author) == 1
assert author[0].name == "Existing Author"
@pytest.mark.django_db
def test_lookup_or_create_authors_from_author_str_ignores_na():
author = lookup_or_create_authors_from_author_str("N/A")
assert len(author) == 0
@pytest.mark.django_db
def test_lookup_or_create_authors_from_author_str_handles_multiple():
author = lookup_or_create_authors_from_author_str("Author One, Author Two")
assert len(author) == 2
@pytest.mark.django_db
def test_create_book_from_row():
row = [
1,
"Test Book - Author Name",
"Test Author",
0,
0,
0,
300,
"",
"",
"abc123def456",
3600,
120,
]
book = create_book_from_row(row)
assert book.title == "Test Book"
assert book.pages == 300
assert "abc123def456" in book.koreader_data_by_hash
assert book.authors.count() == 1
@pytest.mark.django_db
def test_create_book_from_row_handles_title_with_author_suffix():
row = [
1,
"Test Book - Author_Name",
"N/A",
0,
0,
0,
300,
"",
"",
"abc123def456",
3600,
120,
]
book = create_book_from_row(row)
assert book.title == "Test Book"
assert book.authors.count() == 1
assert book.authors.first().name == "Author"
@pytest.mark.django_db
def test_create_book_from_row_handles_null_bytes():
row = [
1,
"Test\x00Book",
"Test\x00Author",
0,
0,
0,
300,
"",
"",
"abc123def456",
3600,
120,
]
book = create_book_from_row(row)
assert "TestBook" in book.title
assert "TestAuthor" in book.authors.first().name
@pytest.mark.django_db
def test_build_book_map_ignores_garbage_titles(koreader_rows):
garbage_row = [
999,
"KOReader Quickstart Guide",
"N/A",
0,
0,
0,
0,
"",
"",
"garbage123",
0,
0,
]
book_map = build_book_map(koreader_rows.BOOK_ROWS + [garbage_row])
assert len(book_map) == 1
assert 999 not in book_map

View File

@ -0,0 +1,61 @@
import re
from urllib.parse import urlparse, urlunparse
from titlecase import titlecase
def parse_readcomicsonline_uri(uri: str) -> tuple:
try:
path = uri.split("comic/")[1]
except IndexError:
return "", "", ""
parts = path.split("/")
title = ""
volume = 1
page = 1
if len(parts) == 2:
title = titlecase(parts[0].replace("-", " "))
volume = parts[1]
if len(parts) == 3:
title = titlecase(parts[0].replace("-", " "))
volume = parts[1]
page = parts[2]
return title, volume, page
def get_comic_issue_url(url: str) -> str:
parsed = urlparse(url)
parts = [p for p in parsed.path.strip("/").split("/") if p]
# Find the index of "comic"
try:
comic_index = parts.index("comic")
except ValueError:
raise ValueError("URL does not contain '/comic/' segment")
# Extract title (next part after 'comic')
if len(parts) <= comic_index + 1:
raise ValueError("No comic title found after '/comic/'")
title = parts[comic_index + 1]
# Look for the first numeric segment after the title
number = None
for segment in parts[comic_index + 2 :]:
if segment.isdigit():
number = segment
break
# Build normalized path
new_parts = ["comic", title]
if number:
new_parts.append(number)
normalized_path = "/" + "/".join(new_parts)
# Rebuild full URL (same scheme and host)
simplified_url = urlunparse(
parsed._replace(path=normalized_path, query="", fragment="")
)
return simplified_url

View File

View File

@ -0,0 +1,8 @@
from rest_framework import serializers
from bricksets.models import BrickSet
class BrickSetSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = BrickSet
fields = "__all__"

View File

@ -0,0 +1,9 @@
from rest_framework import permissions, viewsets
from bricksets.api.serializers import BrickSetSerializer
from bricksets.models import BrickSet
class BrickSetViewSet(viewsets.ModelViewSet):
queryset = BrickSet.objects.all().order_by("-created")
serializer_class = BrickSetSerializer
permission_classes = [permissions.IsAuthenticated]

View File

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

View File

@ -1,22 +1,34 @@
from django.apps import apps
from dataclasses import dataclass
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 BrickSetLogData
from scrobbles.mixins import LongPlayScrobblableMixin
from vrobbler.apps.scrobbles.dataclasses import (
BaseLogData,
LongPlayLogData,
WithPeopleLogData,
)
BNULL = {"blank": True, "null": True}
@dataclass
class BrickSetLogData(BaseLogData, WithPeopleLogData, LongPlayLogData):
pass
class BrickSet(LongPlayScrobblableMixin):
""""""
number = models.CharField(max_length=10, **BNULL)
release_year = models.IntegerField(**BNULL)
piece_count = models.IntegerField(**BNULL)
brickset_rating = models.DecimalField(max_digits=3, decimal_places=1, **BNULL)
brickset_rating = models.DecimalField(
max_digits=3, decimal_places=1, **BNULL
)
lego_item_number = models.CharField(max_length=10, **BNULL)
box_image = models.ImageField(upload_to="brickset/boxes/", **BNULL)
box_image_small = ImageSpecField(
@ -48,13 +60,23 @@ class BrickSet(LongPlayScrobblableMixin):
def get_absolute_url(self):
return reverse("bricksets:brickset_detail", kwargs={"slug": self.uuid})
def __str__(self) -> str:
name = str(self.title)
if not self.title:
name = str(self.number)
return name
@property
def logdata_cls(self):
return BrickSetLogData
@classmethod
def find_or_create(cls, title: str) -> "BrickSet":
return cls.objects.filter(title=title).first()
def find_or_create(cls, brickset_id: str) -> "BrickSet":
brickset = cls.objects.filter(number=brickset_id).first()
if not brickset:
# TODO: enrich this from the website
brickset = cls.objects.create(number=brickset_id)
return brickset
@property
def primary_image_url(self) -> str:

View File

View File

@ -0,0 +1,14 @@
from rest_framework import serializers
from foods import models
class FoodCategorySerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.FoodCategory
fields = "__all__"
class FoodSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.Food
fields = "__all__"

View File

@ -0,0 +1,21 @@
from rest_framework import permissions, viewsets
from foods.api.serializers import (
FoodSerializer,
FoodCategorySerializer,
)
from foods.models import (
FoodCategory,
Food,
)
class FoodCategoryViewSet(viewsets.ModelViewSet):
queryset = FoodCategory.objects.all().order_by("-created")
serializer_class = FoodCategorySerializer
permission_classes = [permissions.IsAuthenticated]
class FoodViewSet(viewsets.ModelViewSet):
queryset = Food.objects.all().order_by("-created")
serializer_class = FoodSerializer
permission_classes = [permissions.IsAuthenticated]

View File

@ -0,0 +1,3 @@
#!/usr/bin/env python3
EXCLUDED_FOOD_DOMAINS = ["bbc.co.uk", "bbc.com", "www.bbc.com"]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.19 on 2025-09-11 13:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('foods', '0002_alter_food_run_time_seconds'),
]
operations = [
migrations.AddField(
model_name='food',
name='calories',
field=models.IntegerField(blank=True, null=True),
),
]

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 = [
('foods', '0003_food_calories'),
]
operations = [
migrations.RemoveField(
model_name='food',
name='run_time_seconds',
),
migrations.RemoveField(
model_name='food',
name='run_time_ticks',
),
migrations.AddField(
model_name='food',
name='base_run_time_seconds',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -0,0 +1,131 @@
# Generated by Django 4.2.29 on 2026-03-05 17:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("foods", "0004_remove_food_run_time_seconds_and_more"),
]
operations = [
migrations.AddField(
model_name="food",
name="carbohydrates",
field=models.DecimalField(
blank=True, decimal_places=2, max_digits=10, null=True
),
),
migrations.AddField(
model_name="food",
name="cook_time_minutes",
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="food",
name="fat",
field=models.DecimalField(
blank=True, decimal_places=2, max_digits=10, null=True
),
),
migrations.AddField(
model_name="food",
name="fiber",
field=models.DecimalField(
blank=True, decimal_places=2, max_digits=10, null=True
),
),
migrations.AddField(
model_name="food",
name="ingredients",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="food",
name="instructions",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="food",
name="prep_time_minutes",
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="food",
name="protein",
field=models.DecimalField(
blank=True, decimal_places=2, max_digits=10, null=True
),
),
migrations.AddField(
model_name="food",
name="servings",
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="food",
name="sodium",
field=models.DecimalField(
blank=True, decimal_places=2, max_digits=10, null=True
),
),
migrations.AddField(
model_name="food",
name="source_site",
field=models.CharField(blank=True, max_length=100, null=True),
),
migrations.AddField(
model_name="food",
name="source_url",
field=models.URLField(blank=True, null=True, unique=True),
),
migrations.AddField(
model_name="food",
name="sugar",
field=models.DecimalField(
blank=True, decimal_places=2, max_digits=10, null=True
),
),
migrations.AddField(
model_name="food",
name="total_time_minutes",
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
model_name="food",
name="yield_text",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddIndex(
model_name="food",
index=models.Index(
fields=["source_url"], name="foods_food_source__f42f6f_idx"
),
),
migrations.AddIndex(
model_name="food",
index=models.Index(
fields=["allrecipe_id"], name="foods_food_allreci_3e7a6c_idx"
),
),
migrations.AddIndex(
model_name="food",
index=models.Index(
fields=["description"], name="foods_food_descrip_fccd4d_idx"
),
),
migrations.RenameField(
model_name="food",
old_name="allrecipe_image",
new_name="recipe_image",
),
migrations.RemoveField(
model_name="foodcategory",
name="allrecipe_image",
),
migrations.AddField(
model_name="foodcategory",
name="category_image",
field=models.ImageField(blank=True, null=True, upload_to="food/category/"),
),
]

View File

@ -1,3 +1,6 @@
import json
from dataclasses import dataclass
from typing import Optional, Tuple
from uuid import uuid4
from django.apps import apps
@ -6,24 +9,32 @@ 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 FoodLogData
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
from foods.sources.rscraper import RecipeScraperService
from foods.sources.usda import NutritionCalculator
BNULL = {"blank": True, "null": True}
@dataclass
class FoodLogData(BaseLogData, WithPeopleLogData):
calories: Optional[int] = None
meal: Optional[str] = None
class FoodCategory(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
name = models.CharField(max_length=255)
allrecipe_image = models.ImageField(upload_to="food/recipe/", **BNULL)
allrecipe_image_small = ImageSpecField(
source="recipe_image",
category_image = models.ImageField(upload_to="food/category/", **BNULL)
category_image_small = ImageSpecField(
source="category_image",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
allrecipe_image_medium = ImageSpecField(
source="recipe_image",
category_image_medium = ImageSpecField(
source="category_image",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
@ -31,6 +42,7 @@ class FoodCategory(TimeStampedModel):
allrecipe_id = models.CharField(max_length=255, **BNULL)
description = models.TextField(**BNULL)
@classmethod
def find_or_create(cls, title: str) -> "FoodCategory":
return cls.objects.filter(title=title).first()
@ -39,16 +51,42 @@ class FoodCategory(TimeStampedModel):
class Food(ScrobblableMixin):
description = models.TextField(**BNULL)
allrecipe_image = models.ImageField(upload_to="food/recipe/", **BNULL)
allrecipe_image_small = ImageSpecField(
source="allrecipe_image",
# Recipe source tracking
source_url = models.URLField(null=True, blank=True, unique=True)
source_site = models.CharField(max_length=100, null=True, blank=True)
# Recipe data
description = models.TextField(**BNULL) # Recipe title
ingredients = models.TextField(**BNULL) # JSON or newline-separated
instructions = models.TextField(**BNULL) # JSON or newline-separated
prep_time_minutes = models.IntegerField(**BNULL)
cook_time_minutes = models.IntegerField(**BNULL)
total_time_minutes = models.IntegerField(**BNULL)
servings = models.IntegerField(**BNULL)
yield_text = models.CharField(
max_length=255, **BNULL
) # e.g., "8 calzones"
# Nutrition (per serving)
calories = models.IntegerField(**BNULL)
protein = models.DecimalField(max_digits=10, decimal_places=2, **BNULL)
fat = models.DecimalField(max_digits=10, decimal_places=2, **BNULL)
carbohydrates = models.DecimalField(
max_digits=10, decimal_places=2, **BNULL
)
fiber = models.DecimalField(max_digits=10, decimal_places=2, **BNULL)
sugar = models.DecimalField(max_digits=10, decimal_places=2, **BNULL)
sodium = models.DecimalField(max_digits=10, decimal_places=2, **BNULL)
recipe_image = models.ImageField(upload_to="food/recipe/", **BNULL)
recipe_image_small = ImageSpecField(
source="recipe_image",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
allrecipe_image_medium = ImageSpecField(
source="allrecipe_image",
recipe_image_medium = ImageSpecField(
source="recipe_image",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
@ -59,12 +97,20 @@ class Food(ScrobblableMixin):
FoodCategory, on_delete=models.DO_NOTHING, **BNULL
)
class Meta:
indexes = [
models.Index(fields=["source_url"]),
models.Index(fields=["allrecipe_id"]),
models.Index(fields=["description"]),
]
def get_absolute_url(self) -> str:
return reverse("foods:food_detail", kwargs={"slug": self.uuid})
@property
def subtitle(self):
return self.category.name
if self.category:
return self.category.name
@property
def strings(self) -> ScrobblableConstants:
@ -81,8 +127,8 @@ class Food(ScrobblableMixin):
@property
def primary_image_url(self) -> str:
url = ""
if self.allrecipe_image:
url = self.allrecipe_image.url
if self.recipe_image:
url = self.recipe_image.url
return url
@property
@ -90,21 +136,155 @@ class Food(ScrobblableMixin):
return FoodLogData
@classmethod
def find_or_create(cls, allrecipe_id: str) -> "Food":
food = cls.objects.filter(allrecipe_id=allrecipe_id).first()
def find_or_create_from_recipe(
cls, url: str, category=None
) -> Tuple["Food", bool]:
"""
Scrape a recipe URL and create/update Food instance.
Uses django-taggit for tags and existing FoodCategory for category.
"""
# Check if URL already exists
existing = cls.objects.filter(source_url=url).first()
if existing:
return existing, False
if not food:
food_dict = get_food_from_allrecipe_id(allrecipe_id)
# category_dict = {}
# Scrape the recipe
scraper = RecipeScraperService()
recipe_data = scraper.scrape_url(url)
# category, _created = FoodCategory.objects.get_or_create(
# **category_dict
# )
food = Food.objects.create(**food_dict)
# for category_id in category_ids:
# food.category.add(category_id)
# Download image
recipe_image = None
if recipe_data.get("image"):
recipe_image = scraper.download_image(recipe_data["image"])
return food
# Extract tags
tags = scraper.extract_tags(recipe_data)
# Calculate nutrition
nutrition = recipe_data.get("nutrition")
if not nutrition:
calculator = NutritionCalculator()
servings = (
scraper.parse_servings(recipe_data.get("yields", "1")) or 1
)
nutrition = calculator.calculate_nutrition(
recipe_data.get("ingredients", []), servings
)
else:
servings = (
scraper.parse_servings(recipe_data.get("yields", "1")) or 1
)
# Get or create category (if not provided)
if not category and recipe_data.get("category"):
category, _ = FoodCategory.objects.get_or_create(
name=recipe_data["category"]
)
# Create Food instance
food = cls.objects.create(
title=recipe_data.get("title"),
source_url=url,
source_site=recipe_data.get("site"),
ingredients=json.dumps(recipe_data.get("ingredients", [])),
instructions=json.dumps(recipe_data.get("instructions", [])),
prep_time_minutes=recipe_data.get("prep_time"),
cook_time_minutes=recipe_data.get("cook_time"),
total_time_minutes=recipe_data.get("total_time"),
servings=servings,
yield_text=recipe_data.get("yields"),
# cuisine=recipe_data.get("cuisine"),
# course=recipe_data.get("course"),
calories=int(nutrition.get("calories", 0)) if nutrition else None,
protein=nutrition.get("protein") if nutrition else None,
fat=nutrition.get("fat") if nutrition else None,
carbohydrates=(
nutrition.get("carbohydrates") if nutrition else None
),
fiber=nutrition.get("fiber") if nutrition else None,
category=category,
)
# Add tags via django-taggit
if tags:
food.genre.add(*tags)
# Save image if downloaded
if recipe_image:
food.recipe_image.save(recipe_image.name, recipe_image, save=True)
if "allrecipes.com" in url:
food.allrecipe_id = url.split("/recipe/")[-1].split("/")[0]
food.save()
return food, True
@classmethod
def find_or_create_from_ingredients(
cls,
ingredients: list[str],
title: str,
servings: int = 1,
category=None,
) -> Tuple["Food", bool]:
"""
Create Food from ingredient list (no URL).
Calculates nutrition using USDA API.
"""
# Check if similar recipe exists
existing = cls.objects.filter(description__icontains=title).first()
if existing:
return existing, False
calculator = NutritionCalculator()
nutrition = calculator.calculate_nutrition(ingredients, servings)
food = cls.objects.create(
description=title,
ingredients=json.dumps(ingredients),
servings=servings,
calories=int(nutrition.get("calories", 0)) if nutrition else None,
protein=nutrition.get("protein") if nutrition else None,
fat=nutrition.get("fat") if nutrition else None,
carbohydrates=(
nutrition.get("carbohydrates") if nutrition else None
),
fiber=nutrition.get("fiber") if nutrition else None,
category=category,
)
return food, True
def refresh_nutrition(self) -> bool:
"""Recalculate nutrition from ingredients (useful if USDA data updated)."""
if not self.ingredients:
return False
try:
ingredients = json.loads(self.ingredients)
calculator = NutritionCalculator()
nutrition = calculator.calculate_nutrition(
ingredients, self.servings or 1
)
self.calories = int(nutrition.get("calories", 0))
self.protein = nutrition.get("protein")
self.fat = nutrition.get("fat")
self.carbohydrates = nutrition.get("carbohydrates")
self.fiber = nutrition.get("fiber")
self.save(
update_fields=[
"calories",
"protein",
"fat",
"carbohydrates",
"fiber",
]
)
return True
except Exception:
return False
def scrobbles(self, user_id):
Scrobble = apps.get_model("scrobbles", "Scrobble")

View File

@ -0,0 +1,190 @@
import logging
from typing import Dict, List, Optional
from urllib.parse import urlparse
import requests
from django.core.files.base import ContentFile
from foods.constants import EXCLUDED_FOOD_DOMAINS
from recipe_scrapers import scrape_html
from recipe_scrapers._exceptions import (
NoSchemaFoundInWildMode,
SchemaOrgException,
)
logger = logging.getLogger(__name__)
class RecipeScraperService:
def __init__(self):
self.session = requests.Session()
self.session.headers.update(
{"User-Agent": "Mozilla/5.0 (compatible; Vrobbler/0.3)"}
)
def scrape(self, html: str, org_url: str) -> Dict:
"""Scrape recipe data from HTML.
Args:
html: The HTML content of the page
org_url: The original URL the HTML was fetched from
"""
try:
scraper = scrape_html(html, org_url=org_url, supported_only=False)
except Exception as e:
raise ValueError(f"Failed to scrape {org_url}: {str(e)}")
data_dict = {
"title": scraper.title(),
"ingredients": self._safe_call(scraper, "ingredients", []),
"instructions": self._safe_call(scraper, "instructions_list", []),
"yields": self._safe_call(scraper, "yields"),
"nutrition": self._safe_call(scraper, "nutrition"),
"course": self._safe_call(scraper, "course"),
"dietary": self._safe_call(scraper, "dietary", []),
"prep_time": self._safe_call(scraper, "prep_time"),
"total_time": self._safe_call(scraper, "total_time"),
"image": self._safe_call(scraper, "image"),
"cook_time": self._safe_call(scraper, "cook_time"),
"cuisine": self._safe_call(scraper, "cuisine"),
"site": self._safe_call(scraper, "host"),
"url": org_url,
"category": self._safe_call(scraper, "category"),
"keywords": self._safe_call(scraper, "keywords"),
}
return data_dict
def scrape_url(self, url: str):
response = self.session.get(url)
if response.status_code != 200:
raise Exception("Recipe website returned non 200 response")
return self.scrape(response.text, org_url=url)
@classmethod
def is_recipe(self, html: str, org_url: str) -> bool:
"""Check if HTML contains a recipe.
Args:
html: The HTML content to check
org_url: The original URL the HTML was fetched from
"""
try:
scraper = scrape_html(html, org_url=org_url, supported_only=False)
return scraper.schema is not None
except NoSchemaFoundInWildMode:
return False
except Exception:
return False
@classmethod
def is_recipe_url(cls, url: str) -> bool:
"""Check if a URL points to a recipe by fetching and checking the HTML."""
domain = urlparse(url).netloc
if domain in EXCLUDED_FOOD_DOMAINS and "food" not in url:
logger.info("[food] domain found in EXCLUDED_FOOD_DOMAINS")
return False
try:
response = requests.get(
url,
timeout=10,
headers={
"User-Agent": "Mozilla/5.0 (compatible; Vrobbler/0.3)"
},
)
if response.status_code != 200:
logger.debug("Recipe website returned non 200 response")
return False
return cls.is_recipe(response.text, url)
except Exception as e:
return False
def _safe_call(self, scraper, method, default=None):
"""
Safely call a scraper method, returning default on error.
Handles both missing methods (AttributeError) and methods that return None.
"""
try:
# Check if method exists before calling
if not hasattr(scraper, method):
return default
result = getattr(scraper, method)()
return result if result else default
except (
AttributeError,
TypeError,
KeyError,
ValueError,
SchemaOrgException,
):
# AttributeError: method doesn't exist on this scraper class
# TypeError: method exists but can't be called
# KeyError: method exists but data structure is unexpected
return default
def download_image(self, image_url: str) -> Optional[ContentFile]:
"""Download recipe image and return as Django ContentFile."""
if not image_url:
return None
try:
response = self.session.get(image_url, timeout=10)
response.raise_for_status()
filename = image_url.split("/")[-1].split("?")[0]
if not filename.endswith((".jpg", ".jpeg", ".png", ".webp")):
filename = "recipe_image.jpg"
return ContentFile(response.content, name=filename)
except Exception:
return None
def parse_servings(self, yield_text: str) -> Optional[int]:
"""Extract number of servings from yield text."""
import re
if not yield_text:
return None
match = re.search(r"(\d+)", yield_text)
return int(match.group(1)) if match else None
def extract_tags(self, recipe_data: Dict) -> List[str]:
"""Extract all tags from recipe metadata."""
tags = set()
# Add cuisine
if recipe_data.get("cuisine"):
cuisine = recipe_data["cuisine"]
if isinstance(cuisine, str):
tags.add(cuisine.strip())
elif isinstance(cuisine, list):
tags.update([c.strip() for c in cuisine if c])
# Add dietary
if recipe_data.get("dietary"):
dietary = recipe_data["dietary"]
if isinstance(dietary, str):
tags.add(dietary.strip())
elif isinstance(dietary, list):
tags.update([d.strip() for d in dietary if d])
# Add course
if recipe_data.get("course"):
course = recipe_data["course"]
if isinstance(course, str):
tags.add(course.strip())
elif isinstance(course, list):
tags.update([c.strip() for c in course if c])
# Add keywords
keywords = recipe_data.get("keywords")
if keywords:
if isinstance(keywords, str):
tags.update(
[k.strip() for k in keywords.split(",") if k.strip()]
)
elif isinstance(keywords, list):
tags.update([k.strip() for k in keywords if k.strip()])
return list(tags)

View File

@ -0,0 +1,577 @@
from django.conf import settings
import requests
from typing import Dict, List, Optional
from functools import lru_cache
from typing import List, Dict
import re
from fractions import Fraction
import logging
logger = logging.getLogger(__name__)
class USDAFoodAPI:
BASE_URL = "https://api.nal.usda.gov/fdc/v1"
def __init__(self, api_key: Optional[str] = None):
self.api_key = api_key or getattr(settings, "USDA_API_KEY", None)
if not self.api_key:
raise ValueError(
"USDA API key not found. Set USDA_API_KEY in your Django settings."
)
self.session = requests.Session()
def _get_base_params(self) -> Dict:
"""Return base parameters including the API key."""
return {"api_key": self.api_key}
@lru_cache(maxsize=100)
def search_foods(
self, query: str, page_size: int = 50, page_number: int = 1
) -> List[Dict]:
"""Search for food items by keyword."""
url = f"{self.BASE_URL}/foods/search"
params = {
**self._get_base_params(),
"query": query,
"pageSize": page_size,
"pageNumber": page_number,
"includeNutrientCodes": "203,204,205,208,269",
}
response = self.session.get(url, params=params)
response.raise_for_status()
data = response.json()
return data.get("foods", [])
def get_food_details(self, fdc_id: int) -> Dict:
"""Retrieve detailed nutrient data for a specific food item."""
url = f"{self.BASE_URL}/food/{fdc_id}"
params = {
**self._get_base_params(),
"nutrients": [
203,
204,
205,
208,
269,
], # Protein, Fat, Carbs, Calories, Fiber
}
response = self.session.get(url, params=params)
response.raise_for_status()
return response.json()
def get_multiple_foods(self, fdc_ids: List[int]) -> List[Dict]:
"""Retrieve details for multiple food items."""
url = f"{self.BASE_URL}/foods"
params = {
**self._get_base_params(),
"fdcIds": fdc_ids,
"nutrients": ["203", "204", "205", "208", "269"],
}
response = self.session.get(url, params=params)
response.raise_for_status()
return response.json().get("foods", [])
def extract_nutrients(self, food_data: Dict) -> Dict:
"""Extract key nutrients into a clean dictionary."""
nutrients = {
"protein": 0,
"fat": 0,
"carbohydrates": 0,
"calories": 0,
"fiber": 0,
"sugar": 0,
"sodium": 0,
}
nutrient_map = {
"203": "protein",
"204": "fat",
"205": "carbohydrates",
"208": "calories",
"269": "fiber",
"2000": "sodium",
"269": "sugar",
}
food_nutrients = food_data.get("foodNutrients", [])
if not food_nutrients:
logger.warning(
f"No nutrients found for food: {food_data.get('description', 'unknown')}"
)
return nutrients
for nutrient in food_nutrients:
nutrient_id = None
value = None
if isinstance(nutrient, dict):
if "nutrientNumber" in nutrient:
nutrient_id = str(nutrient.get("nutrientNumber", ""))
value = nutrient.get("value")
elif "nutrient" in nutrient and isinstance(
nutrient["nutrient"], dict
):
nutrient_id = str(
nutrient.get("nutrient", {}).get("id", "")
)
value = nutrient.get("value")
elif "number" in nutrient:
nutrient_id = str(nutrient.get("number", ""))
value = nutrient.get("value")
if (
nutrient_id
and nutrient_id in nutrient_map
and value is not None
):
key = nutrient_map[nutrient_id]
try:
nutrients[key] = float(value)
except (TypeError, ValueError):
pass
return nutrients
def get_food_summary(self, fdc_id: int) -> Dict:
"""Return a simplified summary of a food item."""
details = self.get_food_details(fdc_id)
return {
"fdcId": details.get("fdcId"),
"description": details.get("description"),
"dataType": details.get("dataType"),
"brandOwner": details.get("brandOwner"),
"servingSize": details.get("servingSize"),
"servingSizeUnit": details.get("servingSizeUnit"),
"nutrients": self.extract_nutrients(details),
}
class NutritionCalculator:
"""Calculate recipe nutrition from ingredients using USDA API."""
# Common units to strip from ingredient names
UNITS = [
"cup",
"cups",
"c",
"tablespoon",
"tablespoons",
"tbsp",
"tb",
"t",
"teaspoon",
"teaspoons",
"tsp",
"ts",
"ounce",
"ounces",
"oz",
"fluid ounce",
"fluid ounces",
"fl oz",
"pound",
"pounds",
"lb",
"lbs",
"gram",
"grams",
"g",
"kilogram",
"kilograms",
"kg",
"milliliter",
"milliliters",
"ml",
"liter",
"liters",
"l",
"piece",
"pieces",
"whole",
"large",
"medium",
"small",
"can",
"cans",
"bottle",
"bottles",
"jar",
"jars",
"package",
"packages",
"pkg",
"box",
"boxes",
"slice",
"slices",
"clove",
"cloves",
"stick",
"sticks",
"pinch",
"dash",
"handful",
"bunch",
"sprig",
"sprigs",
]
# Common modifiers to strip (don't affect nutrition much)
MODIFIERS = [
"all-purpose",
"all purpose",
"ap",
"bread",
"cake",
"self-rising",
"self rising",
"granulated",
"powdered",
"confectioners",
"brown",
"white",
"raw",
"extra virgin",
"virgin",
"light",
"dark",
"heavy",
"whipping",
"fresh",
"dried",
"frozen",
"canned",
"jarred",
"bottled",
"chopped",
"diced",
"minced",
"sliced",
"grated",
"shredded",
"crushed",
"melted",
"softened",
"room temperature",
"cold",
"warm",
"hot",
"packed",
"sifted",
"sieved",
"ground",
"whole",
"halved",
"quartered",
"peeled",
"unpeeled",
"seeded",
"pitted",
"cored",
"trimmed",
"optional",
"to taste",
"as needed",
"for garnish",
"for serving",
]
def __init__(self):
self.usda = USDAFoodAPI()
self._unit_pattern = self._build_unit_pattern()
def _build_unit_pattern(self) -> re.Pattern:
"""Build regex pattern for matching units."""
# Sort by length (longest first) to match "tablespoon" before "tbsp"
sorted_units = sorted(self.UNITS, key=len, reverse=True)
unit_str = "|".join(re.escape(u) for u in sorted_units)
return re.compile(rf"\b({unit_str})\b", re.IGNORECASE)
def parse_ingredient(self, ingredient_text: str) -> Dict:
"""
Parse ingredient string into quantity, unit, and clean ingredient name.
Examples:
"2 cups all-purpose flour"{qty: 2, unit: 'cups', name: 'flour'}
"1 1/2 tsp salt"{qty: 1.5, unit: 'tsp', name: 'salt'}
"1/4 cup olive oil"{qty: 0.25, unit: 'cup', name: 'olive oil'}
"3 large eggs"{qty: 3, unit: 'large', name: 'eggs'}
"""
text = ingredient_text.strip()
# Parse quantity (handles whole numbers, fractions, and mixed numbers)
quantity, remaining = self._parse_quantity(text)
# Parse unit from remaining text
unit, remaining = self._parse_unit(remaining)
# Clean the ingredient name
ingredient_name = self._clean_ingredient_name(remaining)
return {
"quantity": quantity,
"unit": unit,
"ingredient": ingredient_name,
"original": text,
}
def _parse_quantity(self, text: str) -> tuple:
"""
Extract quantity from start of ingredient text.
Handles: "2", "1/2", "1 1/2", "0.5", etc.
"""
text = text.strip()
# Pattern for mixed numbers: "1 1/2", "2 3/4"
mixed_pattern = r"^(\d+)\s+(\d+)/(\d+)"
mixed_match = re.match(mixed_pattern, text)
if mixed_match:
whole = int(mixed_match.group(1))
numerator = int(mixed_match.group(2))
denominator = int(mixed_match.group(3))
quantity = whole + (numerator / denominator)
remaining = text[mixed_match.end() :].strip()
return quantity, remaining
# Pattern for simple fractions: "1/2", "3/4"
fraction_pattern = r"^(\d+)/(\d+)"
fraction_match = re.match(fraction_pattern, text)
if fraction_match:
numerator = int(fraction_match.group(1))
denominator = int(fraction_match.group(2))
quantity = numerator / denominator
remaining = text[fraction_match.end() :].strip()
return quantity, remaining
# Pattern for decimals: "0.5", "1.5"
decimal_pattern = r"^(\d+\.?\d*)"
decimal_match = re.match(decimal_pattern, text)
if decimal_match:
quantity = float(decimal_match.group(1))
remaining = text[decimal_match.end() :].strip()
return quantity, remaining
# No quantity found
return 1, text
def _parse_unit(self, text: str) -> tuple:
"""Extract unit from text."""
text = text.strip()
match = self._unit_pattern.match(text)
if match:
unit = match.group(1).lower()
remaining = text[match.end() :].strip()
return unit, remaining
return None, text
def _clean_ingredient_name(self, text: str) -> str:
"""
Clean ingredient name for better USDA matching.
Removes modifiers, extra whitespace, and normalizes text.
"""
text = text.strip()
# Remove parenthetical notes: "flour (sifted)" → "flour"
text = re.sub(r"\s*\([^)]*\)", "", text)
# Remove common modifiers
for modifier in sorted(self.MODIFIERS, key=len, reverse=True):
# Use word boundaries to avoid partial matches
pattern = rf"\b{re.escape(modifier)}\b"
text = re.sub(pattern, "", text, flags=re.IGNORECASE)
# Remove extra whitespace
text = re.sub(r"\s+", " ", text)
# Remove trailing punctuation
text = text.strip(" ,;:.")
# Remove leading "of" (e.g., "cup of flour" → "flour")
text = re.sub(r"^of\s+", "", text, flags=re.IGNORECASE)
return text.strip()
def _find_usda_match(self, ingredient_name: str) -> Optional[Dict]:
"""
Find best USDA match for an ingredient name.
Tries multiple search strategies for better matching.
"""
strategies = [
# Strategy 1: Direct search
ingredient_name,
# Strategy 2: Singular form (remove trailing 's')
(
ingredient_name.rstrip("s")
if ingredient_name.endswith("s")
else None
),
# Strategy 3: Remove common suffixes
re.sub(
r"\s+(fresh|dried|ground|chopped|sliced)$",
"",
ingredient_name,
flags=re.IGNORECASE,
),
# Strategy 4: Just the last word (sometimes works for simple ingredients)
(
ingredient_name.split()[-1]
if len(ingredient_name.split()) > 1
else None
),
]
for query in strategies:
if not query or len(query) < 2:
continue
try:
results = self.usda.search_foods(query, page_size=5)
if results:
logger.debug(
f"Found {query}: {results[0].get('description')}"
)
return results[0]
except Exception as e:
logger.warning(f"USDA search failed for '{query}': {e}")
continue
return None
def _convert_to_grams(
self, quantity: float, unit: str, ingredient_name: str
) -> float:
"""
Convert quantity to grams for more accurate nutrition calculation.
This is simplified - production code should use a proper conversion table.
"""
# Base conversions (approximate)
volume_to_grams = {
"cup": 240,
"cups": 240,
"tablespoon": 15,
"tablespoons": 15,
"tbsp": 15,
"teaspoon": 5,
"teaspoons": 5,
"tsp": 5,
"ounce": 28,
"ounces": 28,
"oz": 28,
"pound": 454,
"pounds": 454,
"lb": 454,
"lbs": 454,
"gram": 1,
"grams": 1,
"g": 1,
}
# Get base weight in grams
base_grams = volume_to_grams.get(
unit, 100
) # Default to 100g if unknown
# Adjust for ingredient type (very simplified)
if any(
word in ingredient_name.lower()
for word in ["flour", "sugar", "rice", "grain"]
):
base_grams = volume_to_grams.get(unit, 125) # Dry ingredients
elif any(
word in ingredient_name.lower()
for word in ["oil", "butter", "fat"]
):
base_grams = volume_to_grams.get(unit, 220) # Dense liquids
elif any(
word in ingredient_name.lower()
for word in ["milk", "water", "broth"]
):
base_grams = volume_to_grams.get(unit, 240) # Liquids
return quantity * base_grams
def calculate_nutrition(
self, ingredients: List[str], servings: int = 1
) -> Dict:
"""
Calculate total nutrition for a recipe from ingredient list.
Returns nutrition per serving.
"""
totals = {
"calories": 0,
"protein": 0,
"fat": 0,
"carbohydrates": 0,
"fiber": 0,
"sugar": 0,
"sodium": 0,
}
matched_count = 0
unmatched = []
for ingredient in ingredients:
parsed = self.parse_ingredient(ingredient)
# Find USDA match
match = self._find_usda_match(
" ".join(
[
str(parsed["quantity"]),
str(parsed["unit"]),
str(parsed["ingredient"]),
]
)
)
if not match:
match = self._find_usda_match(parsed["ingredient"])
if match:
try:
nutrients = self.usda.extract_nutrients(match)
# Convert to grams for more accurate calculation
grams = self._convert_to_grams(
parsed["quantity"],
parsed["unit"],
parsed["ingredient"],
)
# USDA data is typically per 100g, so scale accordingly
multiplier = grams / 100
totals["calories"] += (
nutrients.get("calories", 0) * multiplier
)
totals["protein"] += (
nutrients.get("protein", 0) * multiplier
)
totals["fat"] += nutrients.get("fat", 0) * multiplier
totals["carbohydrates"] += (
nutrients.get("carbohydrates", 0) * multiplier
)
totals["fiber"] += nutrients.get("fiber", 0) * multiplier
totals["sugar"] += nutrients.get("sugar", 0) * multiplier
totals["sodium"] += nutrients.get("sodium", 0) * multiplier
matched_count += 1
except Exception:
unmatched.append(parsed["original"])
else:
unmatched.append(parsed["original"])
# Divide by servings for per-serving nutrition
if servings > 0:
for key in totals:
totals[key] = round(totals[key] / servings, 2)
# Add metadata
totals["_matched"] = matched_count
totals["_total"] = len(ingredients)
totals["_unmatched"] = unmatched
return totals

View File

@ -0,0 +1,8 @@
from lifeevents.models import LifeEvent
from rest_framework import serializers
class LifeEventSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = LifeEvent
fields = "__all__"

View File

@ -0,0 +1,10 @@
from rest_framework import permissions, viewsets
from lifeevents.api import serializers
from lifeevents import models
class LifeEventViewSet(viewsets.ModelViewSet):
queryset = models.LifeEvent.objects.all().order_by("-created")
serializer_class = serializers.LifeEventSerializer
permission_classes = [permissions.IsAuthenticated]

View File

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

View File

@ -1,12 +1,18 @@
from dataclasses import dataclass
from django.apps import apps
from django.db import models
from django.urls import reverse
from scrobbles.dataclasses import LifeEventLogData
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
BNULL = {"blank": True, "null": True}
@dataclass
class LifeEventLogData(BaseLogData, WithPeopleLogData):
pass
class LifeEvent(ScrobblableMixin):
description = models.TextField(**BNULL)

View File

@ -1,12 +1,10 @@
from django.views import generic
from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView
from lifeevents.models import LifeEvent
class LifeEventListView(generic.ListView):
class LifeEventListView(ScrobbleableListView):
model = LifeEvent
paginate_by = 20
class LifeEventDetailView(generic.DetailView):
class LifeEventDetailView(ScrobbleableDetailView):
model = LifeEvent
slug_field = "uuid"

View File

@ -0,0 +1,8 @@
from locations import models
from rest_framework import serializers
class GeoLocationSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.GeoLocation
fields = "__all__"

View File

@ -0,0 +1,10 @@
from rest_framework import permissions, viewsets
from locations.api import serializers
from locations import models
class GeoLocationViewSet(viewsets.ModelViewSet):
queryset = models.GeoLocation.objects.all().order_by("-created")
serializer_class = serializers.GeoLocationSerializer
permission_classes = [permissions.IsAuthenticated]

View File

@ -3,4 +3,3 @@ from django.apps import AppConfig
class LocationConfig(AppConfig):
name = "locations"

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 = [
('locations', '0007_alter_geolocation_run_time_seconds'),
]
operations = [
migrations.RemoveField(
model_name='geolocation',
name='run_time_seconds',
),
migrations.RemoveField(
model_name='geolocation',
name='run_time_ticks',
),
migrations.AddField(
model_name='geolocation',
name='base_run_time_seconds',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -1,13 +1,13 @@
from decimal import Decimal, getcontext
import logging
from dataclasses import dataclass
from decimal import Decimal
from typing import Dict
from uuid import uuid4
from django.contrib.auth import get_user_model
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import models
from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
logger = logging.getLogger(__name__)
@ -18,6 +18,11 @@ GEOLOC_ACCURACY = int(getattr(settings, "GEOLOC_ACCURACY", 4))
GEOLOC_PROXIMITY = Decimal(getattr(settings, "GEOLOC_PROXIMITY", "0.0001"))
@dataclass
class GeoLocationLogData(BaseLogData, WithPeopleLogData):
pass
class GeoLocation(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, "LOCATION_COMPLETION_PERCENT", 100)
@ -38,9 +43,13 @@ class GeoLocation(ScrobblableMixin):
def get_absolute_url(self):
return reverse(
"locations:geo_location_detail", kwargs={"slug": self.uuid}
"locations:geolocation_detail", kwargs={"slug": self.uuid}
)
@property
def logdata_cls(self):
return GeoLocationLogData
@classmethod
def find_or_create(cls, data_dict: Dict) -> "GeoLocation":
"""Given a data dict from GPSLogger, does the heavy lifting of looking up

View File

@ -8,11 +8,11 @@ urlpatterns = [
path(
"locations/",
views.GeoLocationListView.as_view(),
name="geo_locations_list",
name="geolocation_list",
),
path(
"locations/<slug:slug>/",
views.GeoLocationDetailView.as_view(),
name="geo_location_detail",
name="geolocation_detail",
),
]

View File

@ -9,7 +9,12 @@ class GeoLocationListView(generic.ListView):
paginate_by = 75
def get_queryset(self):
return super().get_queryset().filter(scrobble__user_id=self.request.user.id).order_by("-scrobble__timestamp")
return (
super()
.get_queryset()
.filter(scrobble__user_id=self.request.user.id)
.order_by("-scrobble__timestamp")
)
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)

View File

View File

@ -0,0 +1,8 @@
from rest_framework import serializers
from moods.models import Mood
class MoodSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Mood
fields = "__all__"

View File

@ -0,0 +1,9 @@
from rest_framework import permissions, viewsets
from moods.api.serializers import MoodSerializer
from moods.models import Mood
class MoodViewSet(viewsets.ModelViewSet):
queryset = Mood.objects.all().order_by("-created")
serializer_class = MoodSerializer
permission_classes = [permissions.IsAuthenticated]

File diff suppressed because one or more lines are too long

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 = [
('moods', '0003_alter_mood_run_time_seconds'),
]
operations = [
migrations.RemoveField(
model_name='mood',
name='run_time_seconds',
),
migrations.RemoveField(
model_name='mood',
name='run_time_ticks',
),
migrations.AddField(
model_name='mood',
name='base_run_time_seconds',
field=models.IntegerField(blank=True, null=True),
),
]

View File

@ -1,19 +1,25 @@
import logging
from dataclasses import dataclass
from typing import Optional
from django.contrib.auth import get_user_model
from django.db import models
from django.urls import reverse
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from scrobbles.dataclasses import BaseLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
from vrobbler.apps.scrobbles.dataclasses import MoodLogData
logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
User = get_user_model()
@dataclass
class MoodLogData(BaseLogData):
reasons: Optional[str] = None
class Mood(ScrobblableMixin):
description = models.TextField(**BNULL)
image = models.ImageField(upload_to="moods/", **BNULL)
@ -36,7 +42,7 @@ class Mood(ScrobblableMixin):
return str(self.uuid)
def get_absolute_url(self):
return reverse("moods:mood-detail", kwargs={"slug": self.uuid})
return reverse("moods:mood_detail", kwargs={"slug": self.uuid})
@property
def subtitle(self) -> str:

View File

@ -5,10 +5,10 @@ app_name = "moods"
urlpatterns = [
path("moods/", views.MoodListView.as_view(), name="mood-list"),
path("moods/", views.MoodListView.as_view(), name="mood_list"),
path(
"moods/<slug:slug>/",
views.MoodDetailView.as_view(),
name="mood-detail",
name="mood_detail",
),
]

View File

@ -16,6 +16,7 @@ class AlbumAdmin(admin.ModelAdmin):
"theaudiodb_mood",
"musicbrainz_id",
)
raw_id_fields = ("album_artist",)
list_filter = (
"theaudiodb_score",
"theaudiodb_genre",
@ -49,13 +50,17 @@ class TrackAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"title",
"album",
"primary_album",
"artist",
"musicbrainz_id",
)
raw_id_fields = ("artist", "albums", "album")
list_filter = ("album", "artist")
search_fields = ("title",)
ordering = ("-created",)
filter_horizontal = [
"albums",
]
inlines = [
ScrobbleInline,
]

View File

@ -16,9 +16,7 @@ def scrobble_counts(user=None):
now = now_user_timezone(user.profile)
user_filter = Q(user=user)
start_of_today = datetime.combine(
now.date(), datetime.min.time(), now.tzinfo
)
start_of_today = datetime.combine(now.date(), datetime.min.time(), now.tzinfo)
starting_day_of_current_week = now.date() - timedelta(
days=now.today().isoweekday() % 7
)
@ -31,9 +29,7 @@ def scrobble_counts(user=None):
media_type=Scrobble.MediaType.TRACK,
)
data = {}
data["today"] = finished_scrobbles_qs.filter(
timestamp__gte=start_of_today
).count()
data["today"] = finished_scrobbles_qs.filter(timestamp__gte=start_of_today).count()
data["week"] = finished_scrobbles_qs.filter(
timestamp__gte=starting_day_of_current_week
).count()
@ -47,9 +43,7 @@ def scrobble_counts(user=None):
return data
def week_of_scrobbles(
user=None, start=None, media: str = "tracks"
) -> dict[str, int]:
def week_of_scrobbles(user=None, start=None, media: str = "tracks") -> dict[str, int]:
now = timezone.now()
user_filter = Q()
@ -89,6 +83,7 @@ def live_charts(
media_type: str = "Track",
chart_period: str = "all",
limit: int = 15,
app_label: str = "music",
) -> QuerySet:
now = timezone.now()
tzinfo = now.tzinfo
@ -100,13 +95,11 @@ def live_charts(
seven_days_ago = now - timedelta(days=7)
thirty_days_ago = now - timedelta(days=30)
start_of_today = datetime.combine(now, datetime.min.time(), tzinfo)
start_day_of_week = start_of_today - timedelta(
days=now.today().isoweekday() % 7
)
start_day_of_week = start_of_today - timedelta(days=now.today().isoweekday() % 7)
start_day_of_month = now.replace(day=1)
start_day_of_year = now.replace(month=1, day=1)
media_model = apps.get_model(app_label="music", model_name=media_type)
media_model = apps.get_model(app_label=app_label, model_name=media_type)
period_queries = {
"today": {"scrobble__timestamp__gte": start_of_today},
@ -119,17 +112,13 @@ def live_charts(
}
time_filter = Q()
completion_filter = Q(
scrobble__user=user, scrobble__played_to_completion=True
)
completion_filter = Q(scrobble__user=user, scrobble__played_to_completion=True)
user_filter = Q(scrobble__user=user)
count_field = "scrobble"
if media_type == "Artist":
for period, query_dict in period_queries.items():
period_queries[period] = {
"track__" + k: v for k, v in query_dict.items()
}
period_queries[period] = {"track__" + k: v for k, v in query_dict.items()}
completion_filter = Q(
track__scrobble__user=user,
track__scrobble__played_to_completion=True,
@ -154,3 +143,221 @@ def live_charts(
def artist_scrobble_count(artist_id: int, filter: str = "today") -> int:
return Scrobble.objects.filter(track__artist=artist_id).count()
def live_tv_charts(
user: "User",
chart_period: str = "all",
limit: int = 15,
) -> QuerySet:
from django.db.models import OuterRef, Subquery
from videos.models import Video, Series
now = timezone.now()
tzinfo = now.tzinfo
now = now.date()
if user.is_authenticated:
now = now_user_timezone(user.profile)
tzinfo = now.tzinfo
seven_days_ago = now - timedelta(days=7)
thirty_days_ago = now - timedelta(days=30)
start_of_today = datetime.combine(now, datetime.min.time(), tzinfo)
start_day_of_week = start_of_today - timedelta(days=now.today().isoweekday() % 7)
start_day_of_month = now.replace(day=1)
start_day_of_year = now.replace(month=1, day=1)
period_queries = {
"today": Q(timestamp__gte=start_of_today),
"week": Q(timestamp__gte=start_day_of_week),
"last7": Q(timestamp__gte=seven_days_ago),
"last30": Q(timestamp__gte=thirty_days_ago),
"month": Q(timestamp__gte=start_day_of_month),
"year": Q(timestamp__gte=start_day_of_year),
"all": Q(),
}
time_filter = period_queries[chart_period]
completion_filter = Q(
user=user,
played_to_completion=True,
video__video_type=Video.VideoType.TV_EPISODE,
)
scrobble_counts = (
Scrobble.objects.filter(
completion_filter,
time_filter,
video__tv_series=OuterRef("pk"),
)
.values("video__tv_series")
.annotate(c=Count("id"))
.values("c")
)
return (
Series.objects.annotate(num_scrobbles=Subquery(scrobble_counts))
.filter(num_scrobbles__gt=0)
.order_by("-num_scrobbles")[:limit]
)
def live_youtube_channel_charts(
user: "User",
chart_period: str = "all",
limit: int = 15,
) -> QuerySet:
from django.db.models import OuterRef, Subquery
from videos.models import Video, Channel
now = timezone.now()
tzinfo = now.tzinfo
now = now_user_timezone(user.profile)
tzinfo = now.tzinfo
seven_days_ago = now - timedelta(days=7)
thirty_days_ago = now - timedelta(days=30)
start_of_today = datetime.combine(now, datetime.min.time(), tzinfo)
start_day_of_week = start_of_today - timedelta(days=now.today().isoweekday() % 7)
start_day_of_year = now.replace(month=1, day=1)
period_queries = {
"today": Q(timestamp__gte=start_of_today),
"week": Q(timestamp__gte=start_day_of_week),
"last7": Q(timestamp__gte=seven_days_ago),
"last30": Q(timestamp__gte=thirty_days_ago),
"year": Q(timestamp__gte=start_day_of_year),
"all": Q(),
}
time_filter = period_queries[chart_period]
completion_filter = Q(
user=user,
played_to_completion=True,
video__video_type=Video.VideoType.YOUTUBE,
)
scrobble_counts = (
Scrobble.objects.filter(
completion_filter,
time_filter,
video__channel=OuterRef("pk"),
)
.values("video__channel")
.annotate(c=Count("id"))
.values("c")
)
return (
Channel.objects.annotate(num_scrobbles=Subquery(scrobble_counts))
.filter(num_scrobbles__gt=0)
.order_by("-num_scrobbles")[:limit]
)
def batch_live_charts(
user: "User",
media_type: str = "Track",
limit: int = 15,
app_label: str = "music",
) -> dict[str, list]:
from django.db.models import Count, Q
now = timezone.now()
tzinfo = now.tzinfo
now = now.date()
if user.is_authenticated:
now = now_user_timezone(user.profile)
tzinfo = now.tzinfo
seven_days_ago = now - timedelta(days=7)
thirty_days_ago = now - timedelta(days=30)
start_of_today = datetime.combine(now, datetime.min.time(), tzinfo)
start_day_of_year = now.replace(month=1, day=1)
media_model = apps.get_model(app_label=app_label, model_name=media_type)
if media_type == "Artist":
base_filter = Q(
track__scrobble__user=user, track__scrobble__played_to_completion=True
)
count_field = "track__scrobble"
else:
base_filter = Q(scrobble__user=user, scrobble__played_to_completion=True)
count_field = "scrobble"
scrobble_filter_prefix = (
"track__scrobble__" if media_type == "Artist" else "scrobble__"
)
queryset = media_model.objects.filter(
Q(track__scrobble__user=user)
if media_type == "Artist"
else Q(scrobble__user=user)
).annotate(
num_scrobbles_today=Count(
count_field,
filter=Q(
**{
f"{scrobble_filter_prefix}played_to_completion": True,
f"{scrobble_filter_prefix}timestamp__gte": start_of_today,
}
),
distinct=True,
),
num_scrobbles_last7=Count(
count_field,
filter=Q(
**{
f"{scrobble_filter_prefix}played_to_completion": True,
f"{scrobble_filter_prefix}timestamp__gte": seven_days_ago,
}
),
distinct=True,
),
num_scrobbles_last30=Count(
count_field,
filter=Q(
**{
f"{scrobble_filter_prefix}played_to_completion": True,
f"{scrobble_filter_prefix}timestamp__gte": thirty_days_ago,
}
),
distinct=True,
),
num_scrobbles_year=Count(
count_field,
filter=Q(
**{
f"{scrobble_filter_prefix}played_to_completion": True,
f"{scrobble_filter_prefix}timestamp__gte": start_day_of_year,
}
),
distinct=True,
),
num_scrobbles=Count(
count_field,
filter=Q(
**{
f"{scrobble_filter_prefix}played_to_completion": True,
}
),
distinct=True,
),
)
items = list(queryset)
for item in items:
item.count = item.num_scrobbles
def filter_by_count_attr(attr_name):
filtered = [item for item in items if getattr(item, attr_name) > 0]
return sorted(filtered, key=lambda x: -getattr(x, attr_name))[:limit]
return {
"today": filter_by_count_attr("num_scrobbles_today"),
"last7": filter_by_count_attr("num_scrobbles_last7"),
"last30": filter_by_count_attr("num_scrobbles_last30"),
"year": filter_by_count_attr("num_scrobbles_year"),
"all": filter_by_count_attr("num_scrobbles"),
}

View File

@ -90,6 +90,7 @@ JELLYFIN_POST_KEYS = {
"TAGLINE": "TAGLINE",
"PLAYBACK_POSITION_TICKS": "PlaybackPositionTicks",
"PLAYBACK_POSITION": "PlaybackPosition",
"MEDIA_SOURCE_ID": "MediaSourceId",
"ARTIST_MB_ID": "Provider_musicbrainzartist",
"ALBUM_MB_ID": "Provider_musicbrainzalbum",
"RELEASEGROUP_MB_ID": "Provider_musicbrainzreleasegroup",

View File

@ -1,162 +0,0 @@
import logging
from datetime import datetime, timedelta
import pylast
import pytz
from django.conf import settings
from music.models import Track
logger = logging.getLogger(__name__)
PYLAST_ERRORS = tuple(
getattr(pylast, exc_name)
for exc_name in (
"ScrobblingError",
"NetworkError",
"MalformedResponseError",
"WSError",
)
if hasattr(pylast, exc_name)
)
class LastFM:
def __init__(self, user):
try:
self.client = pylast.LastFMNetwork(
api_key=getattr(settings, "LASTFM_API_KEY"),
api_secret=getattr(settings, "LASTFM_SECRET_KEY"),
username=user.profile.lastfm_username,
password_hash=pylast.md5(user.profile.lastfm_password),
)
self.user = self.client.get_user(user.profile.lastfm_username)
self.vrobbler_user = user
except PYLAST_ERRORS as e:
logger.error(f"Error during Last.fm setup: {e}")
def import_from_lastfm(self, last_processed=None):
"""Given a last processed time, import all scrobbles from LastFM since then"""
from scrobbles.models import Scrobble
new_scrobbles = []
source = "Last.fm"
lastfm_scrobbles = self.get_last_scrobbles(time_from=last_processed)
for lfm_scrobble in lastfm_scrobbles:
track = Track.find_or_create(
title=lfm_scrobble.get("title"),
artist_name=lfm_scrobble.get("artist"),
album_name=lfm_scrobble.get("album"),
)
timezone = settings.TIME_ZONE
if self.vrobbler_user.profile:
timezone = self.vrobbler_user.profile.timezone
timestamp = lfm_scrobble.get("timestamp")
new_scrobble = Scrobble(
user=self.vrobbler_user,
timestamp=timestamp,
source=source,
track=track,
timezone=timezone,
played_to_completion=True,
in_progress=False,
media_type=Scrobble.MediaType.TRACK,
)
# Vrobbler scrobbles on finish, LastFM scrobbles on start
seconds_eariler = timestamp - timedelta(seconds=20)
seconds_later = timestamp + timedelta(seconds=20)
existing = Scrobble.objects.filter(
created__gte=seconds_eariler,
created__lte=seconds_later,
track=track,
).first()
if existing:
logger.debug(f"Skipping existing scrobble {new_scrobble}")
continue
logger.debug(f"Queued scrobble {new_scrobble} for creation")
new_scrobbles.append(new_scrobble)
created = Scrobble.objects.bulk_create(new_scrobbles)
# TODO Add a notification for users that their import is complete
logger.info(
f"Last.fm import fnished",
extra={
"scrobbles_created": len(created),
"user_id": self.vrobbler_user,
"lastfm_user": self.user,
},
)
return created
def get_last_scrobbles(self, time_from=None, time_to=None):
"""Given a user, Last.fm api key, and secret key, grab a list of scrobbled
tracks"""
lfm_params = {}
scrobbles = []
if time_from:
lfm_params["time_from"] = int(time_from.timestamp())
if time_to:
lfm_params["time_to"] = int(time_to.timestamp())
# if not time_from and not time_to:
lfm_params["limit"] = None
found_scrobbles = self.user.get_recent_tracks(**lfm_params)
# TOOD spin this out into a celery task over certain threshold of found scrobbles?
for scrobble in found_scrobbles:
logger.debug(f"Processing {scrobble}")
run_time = None
mbid = None
artist = None
log_dict = {"scrobble": scrobble}
try:
run_time = int(scrobble.track.get_duration() / 1000)
mbid = scrobble.track.get_mbid()
artist = scrobble.track.get_artist().name
log_dict["artist"] = artist
log_dict["mbid"] = mbid
log_dict["run_time"] = run_time
except pylast.MalformedResponseError as e:
logger.warning(e)
except pylast.WSError as e:
logger.info(
"LastFM barfed trying to get the track for {scrobble.track}",
extra=log_dict,
)
except pylast.NetworkError as e:
logger.info(
"LastFM barfed trying to get the track for {scrobble.track}",
extra=log_dict,
)
if not artist:
logger.info(
f"Silly LastFM, no artist found for scrobble",
extra=log_dict,
)
continue
# TODO figure out if this will actually work
# timestamp = datetime.fromtimestamp(int(scrobble.timestamp), UTC)
timestamp = datetime.utcfromtimestamp(
int(scrobble.timestamp)
).replace(tzinfo=pytz.utc)
logger.info(
f"Scrobble appended to list for bulk create", extra=log_dict
)
scrobbles.append(
{
"artist": artist,
"album": scrobble.album,
"title": scrobble.track.title,
"mbid": mbid,
"run_time_seconds": run_time,
"timestamp": timestamp,
}
)
return scrobbles

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