Compare commits

...

403 Commits

Author SHA1 Message Date
d69d7311d5 [version] Bump to 0.15.2 2024-09-08 00:45:36 -04:00
c484dab210 [books] Fix importing order of page data 2024-09-08 00:44:55 -04:00
3d62a6a227 [videos] Fix error looking up TV shows 2024-09-07 23:07:39 -04:00
1a5d4a6717 [scrobbles] Clean up dataclass inheritence 2024-09-07 11:36:36 -04:00
8f0491a90b [pyproject] Upgrade version to 0.15.0 2024-09-07 11:04:01 -04:00
7e961076b4 [bricksets] Add brick set scrobble type 2024-09-07 02:02:16 -04:00
14062f3b60 [videos] Mock in youtube lookup 2024-09-06 11:02:48 -04:00
0a8acdf33f [videos] Add skatevideo lookup properly 2024-09-06 11:01:45 -04:00
b5d194e74f [music] Fix jellyfin music scrobbling sort of 2024-09-06 10:27:04 -04:00
3a50a8b015 [videos] Fixing imdb lookups and making it more modular 2024-09-06 01:22:54 -04:00
921cf9d8b3 [videogames] Add log data to VideoGame model 2024-08-21 11:09:15 -04:00
ba0be65ed0 [scrobbles] Allow boardgame screenshots and clean up koreader fields 2024-08-20 20:10:32 -04:00
caad6329c9 [scrobbles] Fixing tests and breaking more 2024-08-19 18:59:37 -04:00
cfd6ac861e [scrobbles] Fix datalog return value and message 2024-08-19 12:27:47 -04:00
ca36e25948 [scrobbles] Fix datalog test case for board games 2024-08-19 12:12:42 -04:00
c84acf6ae7 [boardgames] Add str rep for player dataclass 2024-08-19 10:14:10 -04:00
610464e732 [scrobbles] Fix serializing logdata 2024-08-19 10:00:07 -04:00
047dd22069 [moods] Add mood fixture data 2024-08-19 09:46:32 -04:00
2c73121367 [scrobbles] Fix aggregator tests 2024-08-19 09:45:16 -04:00
9affd6e03a [scrobbles] Add some tests around jellyfin and start cleaning up 2024-08-16 12:04:09 -04:00
b414bbf59c [templates] Fix status template logdata call 2024-08-12 00:03:52 -05:00
5e22cb3106 [scrobbling] Refactor webhook and simplify 2024-08-12 00:03:30 -05:00
cc9a2a64df [settings] Fix missing moods app in testing settings 2024-08-10 23:30:38 -04:00
c2b334b926 [moods] Fix missing init file 2024-08-10 23:28:36 -04:00
ad6f0e1b62 [deps] Update poetry reqs to fix HLTB bug 2024-08-10 23:10:26 -04:00
1df8157d8d [music] Fix error when mb fails 2024-08-10 23:10:11 -04:00
7d33e6afdb [scrobbles] Fix migrations 2024-08-10 22:35:37 -04:00
40d112c58c [music] Fix failure when musicbrainz has no track 2024-08-10 22:14:07 -04:00
cdfd8af078 [views] Clean up base scrobblable views 2024-08-10 22:12:36 -04:00
0ce19527a2 [scrobbles] Fix mood starting, and clean up code 2024-08-10 22:12:36 -04:00
f3fc58e2c0 [moods] Add moods 2024-08-10 22:12:24 -04:00
b02b75fa90 [boardgames] Add BGG username to user profile 2024-08-07 14:52:26 -03:00
b626ac583a [books] Move book metadata into the log field 2024-08-07 14:51:21 -03:00
adc10ab43b [lifeevents] Fix bad metadata reference 2024-08-06 11:52:35 -03:00
5555b1b89e [lifeevents] Fix data class bug 2024-08-06 11:46:22 -03:00
7fd90f8cf0 [lifeevents] Clean up life event status with details 2024-08-06 11:35:04 -03:00
f80daba67b [boardgames] Update metadata for board game scrobbles 2024-08-05 12:27:42 -03:00
8bd4fd1d4b [webpags] Redirect to webpage url when we're done 2024-08-04 00:20:22 -04:00
f30e416e8d [webpages] Actually add the forms file 2024-08-04 00:11:48 -04:00
f29a039562 [webpages] Provide default iframe reading method 2024-08-03 23:49:31 -04:00
882367cee6 [views] Actually speed up the dashboard view 2024-07-23 16:35:48 -04:00
74c32bc4d5 [views] Move elaborate charts to the charts page 2024-07-23 16:24:59 -04:00
dcd7196010 Fix drone ntfy version 2024-07-14 01:15:27 -04:00
2e57b2ce07 Add dataclass wizard and fix dataclasses 2024-07-03 23:22:19 -04:00
9d664ba476 Update metadata to use JSONWizard 2024-07-03 15:53:27 -04:00
0fa89af1d9 [scrobbles] Fix timestamp log marker for locations 2024-06-07 01:27:58 -04:00
2c5516ae4e [scrobbles] Add life events to status page 2024-05-23 21:21:58 -04:00
0cba46b103 [scrobbles] Fix how we get our redirect url 2024-05-23 21:20:02 -04:00
5014c4428b [scrobbles] Allow junk in the scrobble log 2024-05-23 00:42:01 -04:00
1d554429f1 [scrobbles] Add metadata accessor 2024-05-23 00:37:20 -04:00
daea268465 Fix display of life events 2024-05-23 00:26:56 -04:00
1cceb2bd63 [lifeevents] Add metadata dataclasses 2024-05-23 00:16:24 -04:00
e15d253d58 [lifeevents] Add life events to scrobbles 2024-05-23 00:14:06 -04:00
8ed0bd3d21 [locations] Fix gps update log format 2024-05-05 22:13:15 -04:00
113f200eb7 [scrobbles] Move scrobble_log to log 2024-05-05 22:09:21 -04:00
1b5ffd2a3c [scrobbles] Fix pendulum bug 2024-04-24 14:06:49 -07:00
f168608cee [scrobbles] Fix scrobbling apocalypse 2024-04-22 14:27:33 -04:00
c28f93a1bb [scrobbles] Little reorg and trying to fix JF stop issue 2024-04-22 14:05:42 -04:00
99ad2f797f [scrobbles] Try to fix Jellyfin stop bug 2024-04-22 12:55:14 -04:00
1f28179ed1 [music] Fix lastfm import missing timezones 2024-04-21 22:36:21 -04:00
1648f988ff [videogames] Fix missing covers 2024-04-21 22:36:21 -04:00
dc3a77f14f [scrobbles] Fix typo in timezone saving 2024-04-21 13:29:36 -04:00
5994dccf23 [scrobbles] Clean up location provider log 2024-04-19 15:34:19 -04:00
bfadc0d87b [scrobbles] Just kidding, dt cannot be seriazlied 2024-04-19 15:28:20 -04:00
78782cc538 [scrobbles] Fix geoloc scrobble notes 2024-04-19 15:22:13 -04:00
06277f21de [scrobbles] Clean up inline admin 2024-04-19 15:13:07 -04:00
8fbe37a163 [scrobbles] Remove playback ticks 2024-04-19 15:01:15 -04:00
20dd4d217a [scrobbles] Remove source_id field 2024-04-19 14:57:27 -04:00
53657a9454 [videogames] Add template for video game list 2024-04-19 11:27:27 -04:00
42066cebb2 [scrobbles] Fix display of pages read 2024-04-19 11:27:15 -04:00
6d60a729b7 [scrobbles] Just push old data into JSON 2024-04-19 11:20:30 -04:00
89541a13f2 [webpages] Push to archivebox in a different place 2024-04-19 11:00:07 -04:00
f87bc5fd55 [scrobbles] Unscrewup scrobble migratiosn 2024-04-19 10:48:49 -04:00
0a8078dee0 [scrobbles] Allow skipping the Archivebox push 2024-04-19 10:35:53 -04:00
37da74708c [scrobbles] Fix scrobble having no user in tests 2024-04-19 10:31:33 -04:00
b470e7acea [scrobbles] Fix a migration and how we save timezones 2024-04-19 10:17:38 -04:00
5cdd12783f [scrobbles] Add timezones to scrobbles for better representation 2024-04-19 09:44:15 -04:00
8bf43f298c [books] Fix bug in koreader import using stats, not first_page 2024-04-19 08:45:37 -04:00
a269949a23 [scrobbles] Add webpages to status 2024-04-16 15:17:23 -04:00
8a5f200b44 [scrobbles] Add sports to the status 2024-04-16 15:14:52 -04:00
1c41ca3e18 [locations] Remove redundant field def 2024-04-16 15:09:21 -04:00
b69963b75f [books] Add helper method to get all page data 2024-04-16 15:08:50 -04:00
1327d5da40 [books] Fix index error issue with some imports 2024-04-16 10:47:10 -04:00
5d5b49f19b [webpages] Add bookmarklet support for URLs 2024-04-16 09:54:05 -04:00
d93c827b80 [books] Fix ordering problem with pages 2024-04-16 03:00:20 -04:00
2349c39487 [templates] Fix div mess in status page 2024-04-13 20:33:52 -04:00
ae96831fe4 [scrobbles] Fix status page container 2024-04-13 15:33:38 -04:00
706be782dc [dev] Ignore all sqlite files 2024-04-13 15:26:08 -04:00
a72e0b0fb9 [scrobbles] Add a status page 2024-04-13 15:25:13 -04:00
f5df6c97a9 [scrobbles] Wrap title and sub in span 2024-04-13 01:24:12 -04:00
e00f2de4b1 [videos] Put some views behind login 2024-04-13 01:16:37 -04:00
b4a7cafa3d [profiles] Hide archivebox password 2024-04-13 00:40:00 -04:00
aebe4f899d [webpages] Add pushing webpages to Archivebox 2024-04-13 00:39:18 -04:00
2bf0ca1a8c [books] Fix streaming SQL requets bug 2024-04-10 18:31:39 -04:00
a0c414135c [scrobbles] Fix bug in migrate script and update logging 2024-04-05 12:00:51 -04:00
1dcd151c65 [scrobbles] Migrate scrobble log to JSON field 2024-04-05 11:56:09 -04:00
2e9d92d9c7 [scrobbles] Add json log migration script 2024-04-05 11:38:52 -04:00
8776f75d12 [podcasts] Skip failing tests, bye Google Podcasts 2024-04-05 11:09:10 -04:00
c2a53875a0 [webpages] Clean up how we fetch webpage data 2024-04-05 10:39:30 -04:00
ffb0b7372d [vrobbler] Poetry update 2024-04-02 00:09:44 -04:00
d03744c240 [webpages] Add title cleaner and better url fetching 2024-04-02 00:09:26 -04:00
d9a7929cb0 [webpages] Update lock file for htmldate 2024-03-26 22:51:33 -04:00
9bc087d824 [webpages] Add annotation JS 2024-03-26 22:44:47 -04:00
e65ab4d8c2 [webpages] Break domains out to model 2024-03-26 22:33:56 -04:00
2d26a7c3bd [books] Add comicvine 2024-03-26 22:02:15 -04:00
1fd3e47656 [webpages] Clean up webpage view and add date 2024-03-26 22:01:57 -04:00
dd0ce967c8 [scrobbles] Fix bad update fields call 2024-03-25 16:45:50 -04:00
1f6a4956d2 [videos] Also fix it in series 2024-03-25 15:52:09 -04:00
22f5d288d4 [videos] Fix typo in imdb static path 2024-03-25 15:43:07 -04:00
77cf67ea09 [videoss] Finally add IMDB logo 2024-03-25 15:02:29 -04:00
d1dfc5502d [webpages] Properly use user agent header 2024-03-25 09:58:01 -04:00
0d83050956 [books] Adjust KO migration script for DST 2024-03-25 09:57:25 -04:00
134bd3f650 [books] Fix timezones and duplicated scrobbles 2024-03-23 02:11:06 -04:00
261f2eb9ce [books] Fix KoReader import at end of session 2024-03-19 00:19:40 -04:00
8f56bfba50 [videogames] Fix how we look up IGDB games 2024-03-18 14:58:46 -04:00
750fa05b15 [videogames] Order by ID desc from IGDB to get better results 2024-03-18 14:41:00 -04:00
302ddf6650 [videogames] Fix index error with IGDB lookups 2024-03-18 14:26:09 -04:00
49dd148f9f [scrobbling] Fix missing location in create 2024-03-12 23:15:48 -04:00
79d4e79f3e [scrobbling] Fix locations in proximity of named locations 2024-03-12 18:28:59 -04:00
874d9dc7d2 [locations] Dramatically simplify checking for mvement 2024-03-12 17:53:09 -04:00
0cd95036da [scrobbling] Fix creation of new location scrobbles 2024-03-12 17:12:36 -04:00
829c6b9978 [scrobbling] Fix bad call to has moved 2024-03-12 17:08:43 -04:00
df1bfd4177 [locations] Refactor has moved to not use multiple points 2024-03-12 16:57:56 -04:00
0fff2ec388 [scrobbling] Little location scrobble log clean up 2024-03-12 12:53:11 -04:00
8a83c9fdb3 [scrobbling] Don't run locations though can_be_updated 2024-03-12 12:51:18 -04:00
651fc7a745 [scrobbling] End around for locations 2024-03-12 12:41:00 -04:00
c22a306840 [scrobbling] Tryin in vain to clean up location loggin 2024-03-12 12:38:33 -04:00
11e6161502 [locations] Fix misisng return value 2024-03-12 12:25:48 -04:00
3af2ad203c [scrobbling] Still trying to understand movement 2024-03-12 11:58:44 -04:00
7697dd006d [scrobbling] Futher clean up logging for locations 2024-03-12 11:43:15 -04:00
73a6277e29 [scrobbling] Fix confusing location logging around creating locations 2024-03-12 11:36:51 -04:00
79fc407c33 [scrobbles] Fix bug in can be updated method 2024-03-08 16:53:44 -05:00
84ec84c018 [scrobbles] Try fixing mopidy within existing framework 2024-03-08 14:59:37 -05:00
beb9569663 [scrobbles] Try to fix mopidy double scrobbles 2024-03-08 14:52:01 -05:00
efcf01b49f [scrobbles] Revert previous change, silly 2024-03-08 14:33:31 -05:00
01270a9e32 [scrobbles] Try to fix mopidy double scrobble 2024-03-08 14:16:06 -05:00
c9280c2bad [scrobbles] Fix broken tests 2024-03-08 13:56:53 -05:00
416fd807f4 [scrobbles] Clean up logs and refactor can_be_updated 2024-03-08 13:51:23 -05:00
3069e93697 [scrobbles] Clean up logging in scrobblers 2024-03-08 13:24:57 -05:00
e47f4905e8 [scrobbles] Radically simplify stopping ... may need to roll this back 2024-03-08 12:45:45 -05:00
bf59fb213e [scrobbles] Add media type to our new log 2024-03-08 12:23:39 -05:00
589bf533ea [scrobbles] Add new log on update for scrobble data 2024-03-08 12:14:09 -05:00
1fb29304a3 [scrobbles] Try tweaking completion time for tracks 2024-03-06 20:30:00 -05:00
42604b9e23 [scrobbles] More robust fix for video and track overruns 2024-03-06 19:56:47 -05:00
4642063510 [scrobbles] Add check for long playing videos 2024-03-06 19:07:29 -05:00
65944eb911 [scrobbling] Add media type to filter on 2024-03-06 18:44:10 -05:00
4cf2ceb2dd [scrobbling] Add logging to scrobbler webhooks 2024-03-06 18:29:04 -05:00
78151e5070 [scrobbling] Missed cleaning one log 2024-03-06 18:07:00 -05:00
f90fa160d7 [scrobbling] Enrich some more logs 2024-03-06 18:01:52 -05:00
3b7498c419 [scrobbling] Clean up a few more logs 2024-03-06 17:53:20 -05:00
a59997dafb [scrobbles] Mostly cleaning up our logs 2024-03-06 17:46:55 -05:00
4cdb6150dc [locations] Fix proximity stuff 2024-03-06 16:14:46 -05:00
7fa21c27ea [locations] Remove RawGeoLocation model and ScrobbledPage model 2024-02-19 19:22:13 -05:00
b981c090c6 [locations] Change name of model function 2024-02-18 11:42:42 -05:00
605435b9ea [locations] Make finding proximity locations easier 2024-02-18 11:39:37 -05:00
f4d00b4a22 [locations] Fix typo in settings 2024-02-18 01:46:32 -05:00
504620fc89 [locations] I think proximity logic was reversed 2024-02-18 01:39:53 -05:00
df3424c68f [scrobbles] We don't have a user here yet 2024-02-18 01:39:40 -05:00
71b5af7615 [locations] Oops, don't want to update location scrobbles 2024-02-18 01:25:23 -05:00
7035f01441 [locations] Check for close named locations before creating new 2024-02-18 01:18:15 -05:00
525c7c4e2b [books] Clean up fragmented KoReader scrobbling 2024-02-13 14:54:29 -05:00
4e330a6f03 [webpages] Fix lookup key for scrobbles 2024-02-12 23:06:29 -05:00
ff18ad8efc [webpages] Remane foreign key to web_page 2024-02-12 22:58:07 -05:00
15c1317395 [locations] One more time, trying to fix this stuff 2024-02-12 15:12:12 -05:00
6b80d107b0 [locations] Fix scrobbling logic for locations 2024-02-12 13:47:15 -05:00
13124aca6b [locations] Need to spoof user in gps webhook 2024-02-12 12:42:27 -05:00
a685c6ff9c [locations] Fix calculating playback time 2024-02-12 12:36:38 -05:00
02200bb1f7 Forgot to return when updating! 2024-02-12 12:16:30 -05:00
0bbd488f0d [scrobbling] Sometimes we wont have timestamps 2024-02-12 12:11:07 -05:00
2c08336e33 [locations] Don't scrobble all the locations! 2024-02-12 11:59:15 -05:00
a637a59a40 [scrobbling] Fix location function name typo 2024-02-12 11:48:53 -05:00
123f7c53f9 Oops, no scrobble maybe 2024-02-12 11:38:14 -05:00
2c26cc01ba [scrobbling] Fix scrobble overwriting bug and refactor location scrobbling 2024-02-12 11:34:06 -05:00
36bc1c2e95 [locations] Tigten up how we finish locations 2024-02-11 00:38:10 -05:00
5651ccb990 [scrobbles] can be updated needs no params now 2024-02-11 00:25:12 -05:00
cd7b06eaa2 [settings] Fix typo 2024-02-11 00:16:48 -05:00
949416059d [locations] Try to fix geolocs finally 2024-02-11 00:10:50 -05:00
ca434cb08a [locations] Fix geoloc proximity comparison
Also, make it configurable
2024-02-10 18:13:39 -05:00
2661aee915 [locations] Refactor finding locations 2024-02-10 16:58:07 -05:00
74f672a2fd [locations] settings needs to be int cast 2024-02-10 15:37:31 -05:00
449b74ae3f [scrobbles] Maybe this was a good thing? 2024-02-10 15:30:23 -05:00
3d1ad8da3a [locations] Need to be specific to geolocs 2024-02-10 15:29:06 -05:00
75e374bb69 [locations] Do not mark geolocs complete automatically 2024-02-10 15:23:37 -05:00
2ba694655b [locations] Fix constant typo 2024-02-10 15:14:42 -05:00
879942d070 [locations] Geo accuracy should be int 2024-02-10 15:12:31 -05:00
bfd210d280 [locations] Turns out we were looking them up wrong 2024-02-10 15:06:16 -05:00
e5c6b5e8d9 [locations] Fix updating locations one more time 2024-02-10 14:43:46 -05:00
3cd1603b91 [locations] Try new logic on whether we've moved 2024-02-10 14:37:01 -05:00
27523bc7ff [locations] Flip the logic on whether we've moved 2024-02-10 14:30:41 -05:00
67ef1a15ec [podcasts] Fix missning enum type for podcasts 2024-02-10 14:16:13 -05:00
288027bfce [locations] Diff loc using Decimal math 2024-02-10 14:11:38 -05:00
bfdf73d4c0 [podcasts] Fix name for Episode model to PodcastEpisode 2024-02-10 13:57:42 -05:00
033e3c3b35 [locations] Try another tack for whether we've moved or not 2024-02-10 13:18:45 -05:00
40504f83e1 Add reuse-db option to pytest 2024-02-08 23:05:28 -05:00
ccca81bbab Add tests for Location models 2024-02-08 23:05:20 -05:00
84f49af163 Need to rethink this, it may be fine 2024-02-04 02:07:35 -05:00
e044c70072 Fix inversion of next and last for scrobbles 2024-02-04 01:45:54 -05:00
d3e09c25bd Fix bug in looking up book MD5 hash 2024-02-03 00:42:11 -05:00
30f78a9290 Fix bug in koreader migrator and delete scrobbles before migrating 2024-02-03 00:41:47 -05:00
a5553966de Add script for building scrobbles from past pages 2024-01-31 01:19:58 -05:00
9e2d7a6bc0 Start to add comicvine lookups and consoldiate koreader data 2024-01-29 01:48:56 -05:00
70c7eda415 [books] Don't auto update metadata on save 2024-01-27 01:16:45 -05:00
0ad7dac6cb Fix bug in end_ts calc for book scrobbles 2024-01-27 01:16:45 -05:00
209875f0e6 Import fix, and don't overwrite KoReader data when OL conflicts 2024-01-27 01:16:45 -05:00
919fa1b0b4 Add fuzzing for book titles 2024-01-27 01:16:45 -05:00
0b3bc53704 Fix openlibrary lookups 2024-01-27 01:16:45 -05:00
dfc1365fa3 Fix koreader importing 2024-01-27 01:16:45 -05:00
f22ef1a163 Set user profile TZ to UTC by default, and setup signal 2024-01-27 01:16:45 -05:00
7cb818b585 Fix issue where pages may not come back from OL 2024-01-27 01:16:45 -05:00
1a5cd5106f Update drone file with dev requirements 2024-01-25 10:30:42 -05:00
3b65144b68 Fine tuning koreader imports on real files 2024-01-25 10:15:15 -05:00
4ae13b3a1a Refactor new KoReader importer a bit 2024-01-25 02:08:03 -05:00
3de2be50cf Start refactoring the koreader importer 2024-01-22 20:26:11 -05:00
80d197bc54 Add new field to scrobbles for page data 2024-01-22 19:40:39 -05:00
a274796405 Fix bug in GeoLocation setting 2024-01-22 00:52:01 -05:00
37fd1d8458 Fix old style poetry groups 2024-01-22 00:50:30 -05:00
45ddce36aa Look in DB for book first 2024-01-21 00:31:07 -05:00
c3ddd01a6f Default to getting a boardgame in the DB 2024-01-21 00:27:31 -05:00
6920c99931 Don't relookup games if we have it already 2024-01-18 17:20:20 -05:00
d2d71b3c85 Fix typo in view for resuming scrobbles 2024-01-18 17:06:00 -05:00
6300df8e9e Update accuracy of geolocations 2024-01-02 19:09:42 -05:00
b427731bc3 Use raw IDs for scrobbler media 2023-12-28 23:18:01 -05:00
6058024434 Fix date parsing for real in podcasts 2023-12-23 16:07:38 -05:00
a4d9bd607c Remove whitespace on podcast names and episode titles 2023-12-23 15:49:45 -05:00
9349009ffa Try sorting IGDB results by ID 2023-12-23 15:36:17 -05:00
2293c839e8 Make podcast str parsing sensitive to episode number length 2023-12-23 15:25:48 -05:00
34fb2df782 Turns out we don't need the click thing 2023-12-21 13:22:03 -05:00
3b2158d85c Merge branch 'develop' of secstate/vrobbler into main 2023-12-21 13:19:12 -05:00
7daef6677d Add click to notification 2023-12-21 13:16:22 -05:00
d895cf9110 Fix bad drone file 2023-12-21 12:37:10 -05:00
dffb4f087c Try fixing drone 2023-12-21 12:36:38 -05:00
37c1aab749 Fix timeout on deploy 2023-12-21 12:31:21 -05:00
9083e744da Actually use the drone plugin 2023-12-21 12:18:59 -05:00
af1b6cb4ed Fix parsing podcast URLs 2023-12-21 12:14:33 -05:00
de8727ee05 Also hup immortal, cause sometimes it's a pain 2023-12-21 11:44:14 -05:00
fd645420b8 Escape colons in yaml files 2023-12-21 11:10:44 -05:00
1fdad9ce5b Add curl command to deploy setp 2023-12-21 11:00:57 -05:00
81551ecb41 Add ntfy to deploy pipeline 2023-12-21 11:00:05 -05:00
7a5b4c1b08 Update drone with ntfy curl 2023-12-21 10:59:02 -05:00
c144f07f78 Fix parsing of podcast URIs 2023-12-21 10:52:51 -05:00
b44af1f79d Fix geolocation centers 2023-12-15 21:18:18 -05:00
22f3b94448 Add webpage pages and redirect 2023-12-12 00:35:01 +01:00
36adf5a904 Add detail page for webpages 2023-12-12 00:35:01 +01:00
c2f43861fd Fix scrobble counts on webpage list view 2023-12-12 00:35:01 +01:00
356579f78a Fix bug with stopping wrong scrobble 2023-12-09 23:14:50 +01:00
e5ed265e8f Fix None title bug 2023-12-09 22:34:49 +01:00
97d5478792 Better logging 2023-12-09 22:22:56 +01:00
96ca2f5602 Stop location scrobbles properly 2023-12-09 21:32:13 +01:00
478780e9b3 Better instrumentation 2023-12-08 15:52:43 +01:00
9aaed4f332 Fix looking up past locations 2023-12-08 15:36:31 +01:00
4327ff499c Check for similar title location if we've gotten that far 2023-12-08 15:02:48 +01:00
b53017d226 Update cover images too 2023-12-05 16:56:21 +01:00
4e694bd1b3 Fix top chart view 2023-12-05 16:39:34 +01:00
7a9f4a0876 Get the old homepage back! 2023-12-05 16:10:27 +01:00
431976296f Fix typo 2023-12-03 00:48:02 +01:00
1e84497c85 Actually save run time seconds 2023-12-01 14:00:24 +01:00
05458b1fb9 Add subtitle and update play time for webpages 2023-12-01 01:00:58 +01:00
93a514fc37 Down size long play images 2023-12-01 00:47:26 +01:00
c4c810e23f Catch if video game cover is missing 2023-12-01 00:42:53 +01:00
d1de4103e1 Fix early access of url that may not exist 2023-12-01 00:20:55 +01:00
d0b298e4cc Fix resizing function for music 2023-12-01 00:19:53 +01:00
91ed364747 Return 200 if resource already exists (no scrobble created) 2023-12-01 00:17:10 +01:00
e6f78f4b1d Add downsampling for screenshots 2023-12-01 00:08:22 +01:00
f452ed748e Fix video cover image dealy 2023-11-30 23:56:08 +01:00
63ddb51e89 Add even more imagekit gloriousness 2023-11-30 23:37:42 +01:00
61c10db0dd Try adding imagekit just once 2023-11-30 22:52:38 +01:00
6696d38638 Fix bad import of scrobbler methods 2023-11-30 21:37:01 +01:00
84351f8bcc Add time to read calc 2023-11-30 18:38:46 +01:00
eb1a22e69f Add webpage urls 2023-11-30 18:21:16 +01:00
34ecb71c44 Add text extraction to webpages 2023-11-30 18:19:30 +01:00
33dfbc2c2e Add default readtime to webpages, and fix reverse 2023-11-30 18:09:25 +01:00
8eb2526404 Fix no webpage media obj 2023-11-30 18:01:12 +01:00
8e99918813 Add webpage scrobbling modifier 2023-11-30 17:56:45 +01:00
ea894e1ebf Fix scrobble admin and add webpage views 2023-11-30 17:54:28 +01:00
edcfbd829a Fix import in views 2023-11-30 17:50:53 +01:00
4e1db4aa6f Add webpage scrobbling 2023-11-30 17:49:53 +01:00
f303115ca0 Ooops, use bggeek ID 2023-11-27 20:56:56 +01:00
5928a07ef0 Fix missing import 2023-11-27 20:53:26 +01:00
dea34c926b Allow playing board games again 2023-11-27 20:46:48 +01:00
7564292f5b Order locations by scrobble timestmap 2023-11-27 18:49:24 +01:00
5aa155094f Fix bug where geo locs were not getitng scrobbled 2023-11-27 18:25:42 +01:00
635e8053cd Fix bug in notes for geo scrobbles 2023-11-26 21:36:45 +01:00
0d3da63aaf Fix bug in geo scrobbler 2023-11-26 21:32:26 +01:00
1670a7cd7a Try to ignore duplicate geo scrobbles 2023-11-26 21:20:49 +01:00
0505d01b71 Use timestamp to get latest scrobble for media type 2023-11-26 20:53:31 +01:00
aae2dd7567 Exclude geo locs from now playing 2023-11-26 11:12:48 +01:00
88c252eb45 Fix wrong ordering for long plays on completion 2023-11-26 11:09:09 +01:00
abd07f3113 Clean up location templates 2023-11-25 15:56:05 +01:00
9ba9319885 Add scrobble to locations list 2023-11-25 15:39:27 +01:00
22ad340d43 Filter locations by user 2023-11-25 15:36:33 +01:00
b0574ecf80 Update admin for geolocations 2023-11-25 00:38:08 +01:00
e9db212121 Fix dupped lat code 2023-11-25 00:36:07 +01:00
d5d0325420 And again 2023-11-25 00:31:44 +01:00
1bc5cf31e0 That's why we should have tests 2023-11-25 00:27:12 +01:00
2e2d491e2e Truncate better 2023-11-25 00:22:34 +01:00
c7d1272e99 Remove funky truncation end around 2023-11-24 19:51:37 +01:00
7a6bd169bf Add truncated lat lon to model 2023-11-24 19:17:43 +01:00
bbaa8fa078 Fix issue with GPS scrobbles being always in progress 2023-11-24 15:52:36 +01:00
21995c62fb No need to save raw anymore 2023-11-24 15:41:34 +01:00
880f810aa1 Fix scrobbling of locations 2023-11-24 15:41:10 +01:00
49e4e9b69a Remove custom repo for pypi 2023-11-24 15:18:11 +01:00
b50778b43f Add locations to test settings 2023-11-24 15:15:20 +01:00
da944f9ef1 Try and fix things 2023-11-24 14:33:09 +01:00
f6509bbaa8 Update migration path 2023-11-24 13:45:40 +01:00
f9aab6296b Gah, maybe still truncate a little 2023-11-24 02:06:26 +01:00
899129dfd9 Don't dumb down geolocations 2023-11-24 02:04:24 +01:00
3eb51a871e Fix migrations and add location tab 2023-11-24 01:55:53 +01:00
ca3e495467 Clean up location detail 2023-11-24 01:35:32 +01:00
85a91b340a Add location templates 2023-11-24 01:33:33 +01:00
e04be4fbdd Fix Last.FM imports coming in as videos 2023-11-24 00:26:35 +01:00
f9a98e9de9 Truncate altitude to two places 2023-11-24 00:26:02 +01:00
f18d81296b Use constant for location providers 2023-11-23 23:27:16 +01:00
e86d8cc9fe Fix silly bug in sportsdb settings 2023-11-23 23:26:03 +01:00
4ab796fe12 Add pendulum 2023-11-23 09:39:03 +01:00
fc64dfadba Add basic location tracking 2023-11-23 00:58:11 +01:00
a5d729d26a Add stub for future board game resume function 2023-09-14 00:43:05 -04:00
a661135394 Slim down the homepage 2023-09-14 00:23:12 -04:00
f1b1989424 Add book lookup via locg 2023-08-31 01:27:37 -04:00
145212fe46 Fix first sentence length 2023-08-18 22:48:02 -04:00
607c2522f3 Add ipython 2023-08-15 14:57:30 -04:00
8646fd6881 Add ScrobbledPage model to start book transition 2023-07-25 15:22:23 -04:00
63b783229c Simplify scrapper test 2023-07-25 14:59:54 -04:00
b7e15da87a Add genres to books 2023-07-25 14:55:50 -04:00
8a597ef1b0 Reorganize tadb lookup to actually work 2023-06-21 14:36:01 -04:00
ca65ce11f2 Still need to bail on bad responses 2023-06-21 11:35:43 -04:00
ad2f28c214 Allow forcing updates from TADB 2023-06-21 11:18:06 -04:00
e517327ce3 Clean up video detail pages 2023-06-15 17:20:03 -04:00
ff5a88cb17 Add screenshots and saves to VG details 2023-06-15 11:20:10 -04:00
1fedf77b87 Fix error getting process log lines 2023-06-15 11:13:53 -04:00
837aa53c9b Add duration to video game and board game lists 2023-06-15 11:13:38 -04:00
9e38798f44 Add board and video games to index 2023-06-06 11:59:51 -04:00
6d841167ea Clean up tick logic 2023-06-05 22:21:15 -04:00
6e97298949 Fix timestamp updated with every tick 2023-06-05 22:20:14 -04:00
40fc4d21d3 Try ignoring jellyfin video progress 2023-06-05 14:12:49 -04:00
9eeb708a51 Trying again with Jellyfin 2023-06-05 10:57:03 -04:00
35214de127 Fix jellyfin overwriting scrobbles 2023-06-05 10:20:08 -04:00
0adc97f440 Add video game screenshots to scrobbles 2023-06-03 14:19:06 -04:00
0140126ed8 Condense aggergator tests 2023-06-01 19:57:39 -04:00
f7b8bc3bfc Remove reuse-db flag 2023-06-01 19:51:40 -04:00
7e16388f81 Add tests for BGG 2023-06-01 19:47:32 -04:00
6dc09e723d Fix tests 2023-06-01 19:28:33 -04:00
1d50c32ba9 Set timezone properly for vg scrobbles 2023-06-01 16:18:25 -04:00
0bf4d28482 Actually fix the TSV timezone issue 2023-05-31 23:08:03 -04:00
bcf2b9d1ea I don't think TSV files are in UTC after all 2023-05-31 22:27:09 -04:00
a3bf9b0081 Fix title of retroarch import page 2023-05-31 20:19:09 -04:00
1e35f10945 Add retroarch imports to list view 2023-05-31 20:18:29 -04:00
955b6f028a Clean up retroarch scrobbling 2023-05-29 23:05:58 -04:00
cc751d0953 Duplicate long play complete from last scrobble 2023-05-28 10:25:34 -04:00
a7e8e4f1dc Fix importing from AGB 2023-05-28 10:11:24 -04:00
f55aed7b3d Fix case where cover url is missing 2023-05-27 23:11:50 -04:00
79265feb39 Need to actually used a timezone instance 2023-05-27 23:09:54 -04:00
1ba5b4cf4b Fix retroarch defaulting to utc 2023-05-27 23:01:29 -04:00
39add993e0 Fix looking up games by hltb only 2023-05-27 22:21:20 -04:00
4fd8a5b9a5 Catch json decoding errors 2023-05-27 00:19:50 -04:00
dad936cd6b Fix tests 2023-05-26 23:12:55 -04:00
704409cf6e Fix retroarch times not in UTC 2023-05-26 23:12:43 -04:00
6ee57cb7cd Fix minor issues with retroarch scrobbling 2023-05-26 22:55:09 -04:00
95da4c063e Fix scraping of MAME games 2023-05-25 01:06:20 -04:00
93de6d1556 Add retroarch import tasks and models 2023-05-24 22:38:20 -04:00
dbbb2b43a8 Add scrobble list to import detail view 2023-05-24 19:31:16 -04:00
7758c56d10 Bump python drone version 2023-05-24 17:16:42 -04:00
6c56cfab85 Initial retroarch import code 2023-05-24 17:12:28 -04:00
6753c3717f Fix book lookup if OL ID exists 2023-05-23 16:33:22 -04:00
085c666c19 AudioScrobbler imports only tracks, set media type 2023-05-22 11:36:47 -04:00
dca66003fd Limit imports to last 10 2023-05-04 09:56:05 -04:00
dc55c538ab Try release group ID 2023-04-30 10:25:19 -04:00
bb69be4817 Rather change names than fuck up MB IDs 2023-04-30 10:25:19 -04:00
af0223ab4c Put import records in rows 2023-04-26 19:35:56 -04:00
9ed5d5dc1a Add columsn to list import 2023-04-26 14:03:14 -04:00
dedf28c6de Update import listings 2023-04-26 12:01:50 -04:00
3607d4d4a4 Fix build badge 2023-04-22 20:54:29 -04:00
6bdb7b6e1f Fix scrobbling movies 2023-04-22 20:38:03 -04:00
c864f408a1 Ooops. Set artist image on actual model 2023-04-19 00:25:25 -04:00
f1c22bfbc0 Disable lastfm import button if auto imports on 2023-04-19 00:20:45 -04:00
88bd049d95 Set media type on KOReader import to book 2023-04-19 00:20:45 -04:00
8f4ce4441b Default to album art if artist not found 2023-04-18 13:29:43 -04:00
bee9ac8d25 Clean up board game scrobbling 2023-04-18 11:37:47 -04:00
61c9e362a8 Save playback position on manual scrobbles 2023-04-18 11:15:56 -04:00
efde57078e Add scrobbling of board games 2023-04-18 11:15:02 -04:00
fc927c72d1 Add board game urls and views 2023-04-18 11:04:02 -04:00
5c3a554010 Make genre optional 2023-04-17 22:33:36 -04:00
f21650505c Add board game media type 2023-04-17 18:46:32 -04:00
0217c96faf Add boardgames as scrobblable 2023-04-17 18:29:39 -04:00
4654902adc Simplify, simplify, simplify
The way we calculate past seconds for long plays should be much less
error prone and uses our built-in previous scrobble accessor for media types.
2023-04-15 13:12:21 -04:00
954b35b1d0 Set stop_timestamp when stopping scrobbles 2023-04-15 13:02:07 -04:00
620f52f9ef Fix manual scrobble of long plays 2023-04-15 09:43:53 -04:00
e495a08d1f Clean up sports lookuops a little 2023-04-15 09:13:32 -04:00
74ce5ec9ab Fix force finishing scrobbles not marked completed 2023-04-15 09:13:12 -04:00
fb107c083f Greatly simplify scrobbling books 2023-04-15 09:03:06 -04:00
f9dcc0d341 Fix a handful of little bugs
Refactor of manual scrobbling missed an import
KoReader book removed in sqlite fails import
Looking up album by name and artists screws up on MBid unique
2023-04-13 10:20:21 -04:00
9eef5d721b Image changed on tests 2023-04-11 22:48:50 -04:00
6d613028fc Fix looking up book using ISBN 2023-04-11 22:47:10 -04:00
7db98f0979 Clean up code for manaul scrobblign 2023-04-11 18:08:43 -04:00
4e38605008 Fix wrong model type for podcast media type 2023-04-09 01:11:25 -04:00
7106a0840d Add media type to scrobbles 2023-04-09 01:05:09 -04:00
1f7846f096 Add media type to scrobble admin filter 2023-04-08 23:02:26 -04:00
010565efb7 Update todos 2023-04-07 12:07:10 -04:00
9baf1069b6 Fix it so lastfm imports dont barf 2023-04-06 15:39:13 -04:00
2896225826 Fix bad lookup of artists 2023-04-06 14:09:00 -04:00
be6b3c5e2e Add ability to restart lastfm imports 2023-04-06 14:00:48 -04:00
e487f50683 Add a command to remove zombie scrobbles 2023-04-06 13:53:22 -04:00
8042c726b0 Allow profile disable of lastfm imports 2023-04-06 13:37:10 -04:00
59e29d858a Add command for running lastfm imports 2023-04-06 13:36:02 -04:00
226 changed files with 15859 additions and 4344 deletions

View File

@ -4,18 +4,17 @@
################
kind: pipeline
name: run_tests
name: build & deploy
steps:
# Run tests against Python/Flask engine backend (with pytest)
- name: pytest with coverage
image: python:3.10.4
image: python:3.11.1
commands:
# Install dependencies
- cp vrobbler.conf.test vrobbler.conf
- pip install poetry
- poetry config repositories.unblink "https://pypi.unbl.ink/pypi/simple"
- poetry install
- poetry install --with dev
# Start with a fresh database (which is already running as a service from Drone)
- poetry run pytest --cov-report term:skip-covered --cov=vrobbler tests
environment:
@ -31,7 +30,7 @@ steps:
- vrobbler.service
username: root
ssh_key:
from_secret: ssh_key
from_secret: jail_key
command_timeout: 2m
script:
- pip uninstall -y vrobbler
@ -42,6 +41,28 @@ steps:
when:
branch:
- main
- name: build success notification
image: parrazam/drone-ntfy:0.3-linux-amd64
when:
status: [success]
settings:
url: https://ntfy.unbl.ink
topic: drone
priority: low
tags:
- failure
- vrobbler
- name: build failure notification
image: parrazam/drone-ntfy:0.3-linux-amd64
when:
status: [failure]
settings:
url: https://ntfy.unbl.ink
topic: drone
priority: high
tags:
- success
- vrobbler
volumes:
- name: docker
host:

2
.gitignore vendored
View File

@ -1,4 +1,4 @@
db.sqlite3
db.sqlite3*
vrobbler.conf
media/
dist/

View File

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

3
data/moods.json Normal file

File diff suppressed because one or more lines are too long

5763
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,11 @@
[tool.poetry]
name = "vrobbler"
version = "0.11.12"
version = "0.15.2"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]
[tool.poetry.dependencies]
python = "^3.8"
python = ">=3.9,<4.0"
Django = "^4.0.3"
django-extensions = "^3.1.5"
python-dateutil = "^2.8.2"
@ -40,8 +40,17 @@ beautifulsoup4 = "^4.11.2"
django-storages = "^1.13.2"
boto3 = "^1.26.98"
stream-sqlite = "^0.0.41"
ipython = "^8.14.0"
pendulum = "^2.1.2"
trafilatura = "^1.6.3"
django-imagekit = "^5.0.0"
thefuzz = "^0.22.1"
dataclass-wizard = "0.22.0"
[tool.poetry.dev-dependencies]
[tool.poetry.group.dev]
optional = true
[tool.poetry.group.dev.dependencies]
Werkzeug = "2.0.3"
black = "^22.3"
coverage = "^7.0.5"
@ -53,7 +62,6 @@ pytest-django = "^4.5.2"
pytest-flake8 = "^1.1"
pytest-isort = "^3.0"
pytest-runner = "^6.0"
pytest-selenium = "^2.0.1"
time-machine = "^2.9.0"
types-pytz = "^2022.1"
types-requests = "^2.27"
@ -61,9 +69,9 @@ bandit = "^1.7.4"
[tool.pytest.ini_options]
minversion = "6.0"
addopts = "-ra -q"
addopts = "-ra -q --reuse-db"
testpaths = ["tests"]
DJANGO_SETTINGS_MODULE='vrobbler.settings'
DJANGO_SETTINGS_MODULE='vrobbler.settings-testing'
[tool.black]
line-length = 79

View File

@ -0,0 +1,27 @@
from boardgames.bgg import (
take_first,
lookup_boardgame_id_from_bgg,
lookup_boardgame_from_bgg,
)
def test_take_first():
assert take_first([]) == ""
assert take_first(["a", "b"]) == "a"
def test_lookup_boardgame_id_from_bgg():
bgg_id = lookup_boardgame_id_from_bgg("Cosmic Encounter")
assert bgg_id == "15"
bgg_id = lookup_boardgame_id_from_bgg("Comedy Encounter")
assert bgg_id == None
def test_lookup_boardgame_from_bgg():
bgg_result = lookup_boardgame_from_bgg(15)
assert bgg_result.get("bggeek_id") == 15
bgg_result = lookup_boardgame_from_bgg("Cosmic Encounter")
assert bgg_result.get("bggeek_id") == "15"

View File

@ -1,28 +1,21 @@
import pytest
from vrobbler.apps.podcasts.scrapers import scrape_data_from_google_podcasts
expected_desc = (
expected_desc_snippet = (
"NPR's Up First is the news you need to start your day. "
"The three biggest stories of the day, with reporting and analysis "
"from NPR News — in 10 minutes. Available weekdays by 6 a.m. ET, "
"with hosts Leila Fadel, Steve Inskeep, Michel Martin and A Martinez. "
"Also available on Saturdays by 8 a.m. ET, with Ayesha Rascoe and "
"Scott Simon. On Sundays, hear a longer exploration behind the "
"headlines with Rachel Martin, available by 8 a.m. ET. Subscribe "
"and listen, then support your local NPR station at donate.npr.org. "
"Support NPR's reporting by subscribing to Up First+ and unlock "
"sponsor-free listening. Learn more at plus.npr.org/UpFirst"
)
expected_img_url = "https://encrypted-tbn2.gstatic.com/images?q=tbn:ANd9GcT-PqtK-bauo8wm8dBE__SVGArlvfBYY8rqxr2kA5UwjKzEx8c"
expected_img_url = "https://encrypted-tbn2.gstatic.com/images?q=tbn:ANd9GcR1F0CfR24RR6sme531yIkCrnK4zzmo97jeualO5drVPKG6oCk"
expected_google_url = "https://podcasts.google.com/feed/aHR0cHM6Ly9mZWVkcy5ucHIub3JnLzUxMDMxOC9wb2RjYXN0LnhtbA"
@pytest.mark.skip("Google Podcasts is gone")
def test_get_not_allowed_from_mopidy():
query = "Up First"
result_dict = scrape_data_from_google_podcasts(query)
assert result_dict["title"] == query
assert result_dict["description"] == expected_desc
assert expected_desc_snippet in result_dict["description"]
assert result_dict["image_url"] == expected_img_url
assert result_dict["producer"] == "NPR"
assert result_dict["google_url"] == expected_google_url

View File

@ -1,19 +1,39 @@
import json
import pytest
from rest_framework.authtoken.models import Token
import pytest
from django.contrib.auth import get_user_model
from rest_framework.authtoken.models import Token
from scrobbles.models import Scrobble
from boardgames.models import BoardGame
User = get_user_model()
@pytest.fixture
def boardgame_scrobble():
user = User.objects.create(
email="test@exmaple.com", first_name="Test", last_name="User"
)
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"}
]
},
)
class MopidyRequest:
name = "Same in the End"
artist = "Sublime"
album = "Sublime"
track_number = 4
run_time_ticks = 156604
run_time = "156"
run_time = 60
playback_time_ticks = 15045
musicbrainz_track_id = "54214d63-5adf-4909-87cd-c65c37a6d558"
musicbrainz_album_id = "03b864cd-7761-314c-a892-05a89ddff00d"
@ -65,8 +85,8 @@ def valid_auth_token():
@pytest.fixture
def mopidy_track_request_data():
return MopidyRequest().request_json
def mopidy_track():
return MopidyRequest()
@pytest.fixture
@ -78,6 +98,61 @@ def mopidy_track_diff_album_request_data(**kwargs):
@pytest.fixture
def mopidy_podcast_request_data():
def mopidy_podcast():
mopidy_uri = "local:podcast:Up%20First/2022-01-01%20Up%20First.mp3"
return MopidyRequest(mopidy_uri=mopidy_uri).request_json
return MopidyRequest(mopidy_uri=mopidy_uri)
class JellyfinTrackRequest:
name = "Emotion"
artist = "Carly Rae Jepsen"
album = "Emotion"
track_number = 1
item_type = "Audio"
timestamp = "2024-01-14 12:00:19"
run_time_ticks = 156604
run_time = "00:00:60"
playback_time_ticks = 15045
musicbrainz_track_id = "54214d63-5adf-4909-87cd-c65c37a6d558"
musicbrainz_album_id = "03b864cd-7761-314c-a892-05a89ddff00d"
musicbrainz_artist_id = "95f5b748-d370-47fe-85bd-0af2dc450bc0"
status = "resumed"
def __init__(self, **kwargs):
self.request_data = {
"Name": kwargs.get("name", self.name),
"Artist": kwargs.get("artist", self.artist),
"Album": kwargs.get("album", self.album),
"TrackNumber": int(kwargs.get("track_number", self.track_number)),
"RunTime": kwargs.get("run_time", self.run_time),
"ItemType": kwargs.get("item_type", self.item_type),
"UtcTimestamp": kwargs.get("timestamp", self.timestamp),
"PlaybackPositionTicks": int(
kwargs.get("playback_time_ticks", self.playback_time_ticks)
),
"Provider_musicbrainztrack": kwargs.get(
"musicbrainz_track_id", self.musicbrainz_track_id
),
"Provider_musicbrainzalbum": kwargs.get(
"musicbrainz_album_id", self.musicbrainz_album_id
),
"Provider_musicbrainzartist": kwargs.get(
"musicbrainz_artist_id", self.musicbrainz_artist_id
),
"Status": kwargs.get("status", self.status),
}
def __eq__(self, other):
for key in self.request_data.keys():
if self.request_data[key] != getattr(self, key):
return False
return True
@property
def request_json(self):
return json.dumps(self.request_data)
@pytest.fixture
def jellyfin_track():
return JellyfinTrackRequest()

View File

@ -6,16 +6,18 @@ from django.contrib.auth import get_user_model
from django.urls import reverse
from django.utils import timezone
from music.aggregators import live_charts, scrobble_counts, week_of_scrobbles
from music.models import Album, Artist
from profiles.models import UserProfile
from scrobbles.models import Scrobble
def build_scrobbles(client, request_data, num=7, spacing=2):
def build_scrobbles(client, request_json, num=7, spacing=2):
url = reverse("scrobbles:mopidy-webhook")
user = get_user_model().objects.create(username="Test User")
UserProfile.objects.create(user=user, timezone="US/Eastern")
user.profile.timezone = "US/Eastern"
user.profile.save()
for i in range(num):
client.post(url, request_data, content_type="application/json")
client.post(url, request_json, content_type="application/json")
s = Scrobble.objects.last()
s.user = user
s.timestamp = timezone.now() - timedelta(days=i * spacing)
@ -25,8 +27,8 @@ def build_scrobbles(client, request_data, num=7, spacing=2):
@pytest.mark.django_db
@time_machine.travel(datetime(2022, 3, 4, 1, 24))
def test_scrobble_counts_data(client, mopidy_track_request_data):
build_scrobbles(client, mopidy_track_request_data)
def test_scrobble_counts_data(client, mopidy_track):
build_scrobbles(client, mopidy_track.request_json)
user = get_user_model().objects.first()
count_dict = scrobble_counts(user)
assert count_dict == {
@ -39,64 +41,31 @@ def test_scrobble_counts_data(client, mopidy_track_request_data):
@pytest.mark.django_db
def test_week_of_scrobbles_data(client, mopidy_track_request_data):
build_scrobbles(client, mopidy_track_request_data, 7, 1)
@time_machine.travel(datetime(2022, 3, 4, 1, 24))
def test_live_charts(client, mopidy_track):
build_scrobbles(client, mopidy_track.request_json, 7, 1)
user = get_user_model().objects.first()
week = week_of_scrobbles(user)
assert list(week.values()) == [1, 1, 1, 1, 1, 1, 1]
@pytest.mark.django_db
def test_top_tracks_by_day(client, mopidy_track_request_data):
build_scrobbles(client, mopidy_track_request_data, 7, 1)
user = get_user_model().objects.first()
tops = live_charts(user)
assert tops[0].title == "Same in the End"
@pytest.mark.django_db
def test_top_tracks_by_week(client, mopidy_track_request_data):
build_scrobbles(client, mopidy_track_request_data, 7, 1)
user = get_user_model().objects.first()
tops = live_charts(user, chart_period="week")
assert tops[0].title == "Same in the End"
@pytest.mark.django_db
def test_top_tracks_by_month(client, mopidy_track_request_data):
build_scrobbles(client, mopidy_track_request_data, 7, 1)
user = get_user_model().objects.first()
tops = live_charts(user, chart_period="month")
assert tops[0].title == "Same in the End"
@pytest.mark.django_db
def test_top_tracks_by_year(client, mopidy_track_request_data):
build_scrobbles(client, mopidy_track_request_data, 7, 1)
user = get_user_model().objects.first()
tops = live_charts(user, chart_period="year")
assert tops[0].title == "Same in the End"
@pytest.mark.django_db
def test_top__artists_by_week(client, mopidy_track_request_data):
build_scrobbles(client, mopidy_track_request_data, 7, 1)
user = get_user_model().objects.first()
tops = live_charts(user, chart_period="week", media_type="Artist")
assert tops[0].name == "Sublime"
@pytest.mark.django_db
def test_top__artists_by_month(client, mopidy_track_request_data):
build_scrobbles(client, mopidy_track_request_data, 7, 1)
user = get_user_model().objects.first()
tops = live_charts(user, chart_period="month", media_type="Artist")
assert tops[0].name == "Sublime"
@pytest.mark.django_db
def test_top__artists_by_year(client, mopidy_track_request_data):
build_scrobbles(client, mopidy_track_request_data, 7, 1)
user = get_user_model().objects.first()
tops = live_charts(user, chart_period="year", media_type="Artist")
assert tops[0].name == "Sublime"

View File

@ -0,0 +1,31 @@
import pytest
from scrobbles.dataclasses import BoardGameLogData, BoardGameScoreLogData
@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="",
bgg_username="",
color="Blue",
character=None,
team=None,
score=30,
win=True,
new=None,
)
],
location=None,
geo_location_id=None,
difficulty=None,
solo=None,
two_handed=None,
)
assert len(boardgame_scrobble.logdata.players) == 1
assert boardgame_scrobble.logdata.players[0].user.id == 1
assert boardgame_scrobble.logdata.players[0].name == "Test"

View File

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

View File

@ -1,7 +1,12 @@
from datetime import datetime, timedelta
from unittest.mock import patch
from django.utils import timezone
import pytest
import time_machine
from django.urls import reverse
from music.models import Track
from podcasts.models import Episode
from podcasts.models import PodcastEpisode
from scrobbles.models import Scrobble
@ -25,31 +30,57 @@ def test_bad_mopidy_request_data(client, valid_auth_token):
)
@pytest.mark.parametrize(
"seconds, expected_percent_played, expected_scrobble_id",
[
(1, 1, 1),
(58, 96, 1),
(59, 98, 1),
(60, 100, 1),
(1, 1, 2),
(1, 1, 3),
],
)
@pytest.mark.django_db
def test_scrobble_mopidy_track(
client, mopidy_track_request_data, valid_auth_token
client,
mopidy_track,
valid_auth_token,
seconds,
expected_percent_played,
expected_scrobble_id,
):
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.media_obj.__class__ == Track
assert scrobble.media_obj.title == "Same in the End"
# Start new scrobble
minutes = 0
calc_seconds = seconds
if seconds >= 60:
minutes = 1
calc_seconds = calc_seconds % 10
with time_machine.travel(datetime(2024, 1, 14, 12, minutes, calc_seconds)):
mopidy_track.request_data["playback_time_ticks"] = seconds * 1000
response = client.post(
url,
mopidy_track.request_json,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
assert response.data == {"scrobble_id": expected_scrobble_id}
scrobble = Scrobble.objects.get(id=1)
assert scrobble.percent_played == expected_percent_played
assert scrobble.media_obj.__class__ == Track
assert scrobble.media_obj.title == "Same in the End"
@pytest.mark.skip(reason="API is unstable")
@pytest.mark.skip(reason="Allmusic API is unstable")
@pytest.mark.django_db
def test_scrobble_mopidy_same_track_different_album(
client,
mopidy_track_request_data,
mopidy_track,
mopidy_track_diff_album_request_data,
valid_auth_token,
):
@ -57,7 +88,7 @@ def test_scrobble_mopidy_same_track_different_album(
headers = {"Authorization": f"Token {valid_auth_token}"}
response = client.post(
url,
mopidy_track_request_data,
mopidy_track.request_data,
content_type="application/json",
headers=headers,
)
@ -96,5 +127,85 @@ def test_scrobble_mopidy_podcast(
assert response.data == {"scrobble_id": 1}
scrobble = Scrobble.objects.get(id=1)
assert scrobble.media_obj.__class__ == Episode
assert scrobble.media_obj.__class__ == PodcastEpisode
assert scrobble.media_obj.title == "Up First"
@pytest.mark.django_db
@patch("music.utils.lookup_artist_from_mb", return_value={})
@patch(
"music.utils.lookup_album_dict_from_mb",
return_value={"year": "1999", "mb_group_id": 1},
)
@patch("music.utils.lookup_track_from_mb", return_value={})
@patch("music.models.lookup_artist_from_tadb", return_value={})
@patch("music.models.lookup_album_from_tadb", return_value={"year": "1999"})
@patch("music.models.Album.fetch_artwork", return_value=None)
@patch("music.models.Album.scrape_allmusic", return_value=None)
def test_scrobble_jellyfin_track(
mock_lookup_artist,
mock_lookup_album,
mock_lookup_track,
mock_lookup_artist_tadb,
mock_lookup_album_tadb,
mock_fetch_artwork,
mock_scrape_allmusic,
client,
jellyfin_track,
valid_auth_token,
):
url = reverse("scrobbles:jellyfin-webhook")
headers = {"Authorization": f"Token {valid_auth_token}"}
with time_machine.travel(datetime(2024, 1, 14, 12, 00, 1)):
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
"%Y-%m-%d %H:%M:%S"
)
response = client.post(
url,
jellyfin_track.request_json,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
assert response.data == {"scrobble_id": 1}
scrobble = Scrobble.objects.get(id=1)
assert scrobble.media_obj.__class__ == Track
assert scrobble.media_obj.title == "Emotion"
with time_machine.travel(datetime(2024, 1, 14, 12, 0, 58)):
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
"%Y-%m-%d %H:%M:%S"
)
response = client.post(
url,
jellyfin_track.request_json,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
assert response.data == {"scrobble_id": 1}
scrobble = Scrobble.objects.get(id=1)
assert scrobble.media_obj.__class__ == Track
assert scrobble.media_obj.title == "Emotion"
with time_machine.travel(datetime(2024, 1, 14, 12, 1, 1)):
jellyfin_track.request_data["UtcTimestamp"] = timezone.now().strftime(
"%Y-%m-%d %H:%M:%S"
)
response = client.post(
url,
jellyfin_track.request_json,
content_type="application/json",
headers=headers,
)
assert response.status_code == 200
assert response.data == {"scrobble_id": 2}
scrobble = Scrobble.objects.get(id=1)
assert scrobble.media_obj.__class__ == Track
assert scrobble.media_obj.title == "Emotion"

View File

@ -3,6 +3,10 @@
A fun way to keep track of things in the project to fix or improve.
* Version 1.0.0
** TODO What to do with Youtube videos from LastFM and web-scrobbler :bug:source:lastfm:
** TODO Add a user profile page with ability to change settings :profiles:improvement:
** 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]
@ -149,6 +153,7 @@ 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 Add Amazon scraper to look up books when OL fails :books:improvement:
** TODO [#C] Move to using more robust mopidy-webhooks pacakge form pypi :utility:improvement:
*** Example payloads from mopidy-webhooks
**** Podcast playback ended
@ -440,7 +445,5 @@ has to re-populate when the server restarts.
}
}
#+end_src
** TODO [#C] Consider a purge command for duplicated and stuck in-progress scrobbles :utililty:improvement:
** TODO What to do with Youtube videos from LastFM and web-scrobbler :bug:source:lastfm:
** TODO Fix bug in Jellyfin scrobbles that spam more scrobbles after completion :scrobbling:videos:bug:
** TODO Fix bug in podcast scrobbling where a second scrobble is created after completion :scrobbling:podcasts:bug:

View File

@ -20,6 +20,7 @@ VROBBLER_THESPORTSDB_API_KEY="<key>"
VROBBLER_THEAUDIODB_API_KEY="<key>"
VROBBLER_IGDB_CLIENT_ID="<id>"
VROBBLER_IGDB_CLIENT_SECRET="<key>"
VROBBLER_COMICVINE_API_KEY="<key>"
# Storages
# VROBBLER_DATABASE_URL="postgres://USER:PASSWORD@HOST:PORT/NAME"

View File

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

View File

@ -0,0 +1,30 @@
from django.contrib import admin
from boardgames.models import BoardGame, BoardGamePublisher
from scrobbles.admin import ScrobbleInline
@admin.register(BoardGamePublisher)
class BoardGamePublisherAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"name",
"uuid",
)
ordering = ("-created",)
@admin.register(BoardGame)
class GameAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"bggeek_id",
"title",
"published_date",
)
search_fields = ("title",)
ordering = ("-created",)
inlines = [
ScrobbleInline,
]

View File

@ -0,0 +1,92 @@
import logging
from typing import Optional
import requests
from bs4 import BeautifulSoup
logger = logging.getLogger(__name__)
SEARCH_ID_URL = (
"https://boardgamegeek.com/xmlapi/search?search={query}&exact=1"
)
GAME_ID_URL = "https://boardgamegeek.com/xmlapi/boardgame/{id}"
def take_first(thing: Optional[list]) -> str:
first = ""
try:
first = thing[0]
except IndexError:
pass
if first:
try:
first = first.get_text()
except:
pass
return first
def lookup_boardgame_id_from_bgg(title: str) -> Optional[int]:
soup = None
headers = {"User-Agent": "Vrobbler 0.11.12"}
game_id = None
url = SEARCH_ID_URL.format(query=title)
r = requests.get(url, headers=headers)
if r.status_code == 200:
soup = BeautifulSoup(r.text, "xml")
if soup:
result = soup.findAll("boardgame")
if not result:
return game_id
game_id = result[0].get("objectid", None)
return game_id
def lookup_boardgame_from_bgg(lookup_id: str) -> dict:
soup = None
game_dict = {}
headers = {"User-Agent": "Vrobbler 0.11.12"}
title = ""
bgg_id = None
try:
bgg_id = int(lookup_id)
logger.debug(f"Using BGG ID {bgg_id} to find board game")
except ValueError:
title = lookup_id
logger.debug(f"Using title {title} to find board game")
if not bgg_id:
bgg_id = lookup_boardgame_id_from_bgg(title)
url = GAME_ID_URL.format(id=bgg_id)
r = requests.get(url, headers=headers)
if r.status_code == 200:
soup = BeautifulSoup(r.text, "xml")
if soup:
seconds_to_play = None
minutes = take_first(soup.findAll("playingtime"))
if minutes:
seconds_to_play = int(minutes) * 60
game_dict = {
"bggeek_id": bgg_id,
"title": take_first(soup.findAll("name", primary="true")),
"description": take_first(soup.findAll("description")),
"year_published": take_first(soup.findAll("yearpublished")),
"publisher_name": take_first(soup.findAll("boardgamepublisher")),
"cover_url": take_first(soup.findAll("image")),
"min_players": take_first(soup.findAll("minplayers")),
"max_players": take_first(soup.findAll("maxplayers")),
"recommended_age": take_first(soup.findAll("age")),
"run_time_seconds": seconds_to_play,
}
return game_dict

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,26 @@
# Generated by Django 4.1.7 on 2023-04-18 02:33
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0040_alter_scrobble_media_type"),
("boardgames", "0005_remove_boardgame_summary"),
]
operations = [
migrations.AlterField(
model_name="boardgame",
name="genre",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="scrobbles.ObjectWithGenres",
to="scrobbles.Genre",
verbose_name="Tags",
),
),
]

View File

@ -0,0 +1,170 @@
import logging
from datetime import datetime
from typing import Optional
from uuid import uuid4
import requests
from boardgames.bgg import lookup_boardgame_from_bgg
from django.conf import settings
from django.core.files.base import ContentFile
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 BoardGameLogData
from scrobbles.mixins import ScrobblableMixin
logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
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)
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse(
"boardgames:publisher_detail", kwargs={"slug": self.uuid}
)
class BoardGame(ScrobblableMixin):
COMPLETION_PERCENT = getattr(
settings, "BOARD_GAME_COMPLETION_PERCENT", 100
)
FIELDS_FROM_BGGEEK = [
"igdb_id",
"alternative_name",
"rating",
"rating_count",
"release_date",
"cover",
"screenshot",
]
title = models.CharField(max_length=255)
publisher = models.ForeignKey(
BoardGamePublisher, **BNULL, on_delete=models.DO_NOTHING
)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
description = models.TextField(**BNULL)
cover = models.ImageField(upload_to="boardgames/covers/", **BNULL)
cover_small = ImageSpecField(
source="cover",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
cover_medium = ImageSpecField(
source="cover",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
)
layout_image = models.ImageField(upload_to="boardgames/layouts/", **BNULL)
layout_image_small = ImageSpecField(
source="layout_image",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
layout_image_medium = ImageSpecField(
source="layout_image",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
)
rating = models.FloatField(**BNULL)
max_players = models.PositiveSmallIntegerField(**BNULL)
min_players = models.PositiveSmallIntegerField(**BNULL)
published_date = models.DateField(**BNULL)
recommended_age = models.PositiveSmallIntegerField(**BNULL)
bggeek_id = models.CharField(max_length=255, **BNULL)
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse(
"boardgames:boardgame_detail", kwargs={"slug": self.uuid}
)
@property
def logdata_cls(self):
return BoardGameLogData
def primary_image_url(self) -> str:
url = ""
if self.cover:
url = self.cover.url
return url
def bggeek_link(self):
link = ""
if self.bggeek_id:
link = f"https://boardgamegeek.com/boardgame/{self.bggeek_id}"
return link
def get_start_url(self):
return reverse("scrobbles:start", kwargs={"uuid": self.uuid})
def fix_metadata(self, data: dict = {}, force_update=False) -> None:
if not self.published_date or force_update:
if not data:
data = lookup_boardgame_from_bgg(str(self.bggeek_id))
cover_url = data.pop("cover_url")
year = data.pop("year_published")
publisher_name = data.pop("publisher_name")
if year:
data["published_date"] = datetime(int(year), 1, 1)
# Fun trick for updating all fields at once
BoardGame.objects.filter(pk=self.id).update(**data)
self.refresh_from_db()
# Add publishers
(
self.publisher,
_created,
) = BoardGamePublisher.objects.get_or_create(name=publisher_name)
self.save()
# 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")
@classmethod
def find_or_create(
cls, lookup_id: str, data: Optional[dict] = {}
) -> Optional["BoardGame"]:
"""Given a Lookup ID (either BGG or BGA ID), return a board game object"""
boardgame = cls.objects.filter(bggeek_id=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 created:
boardgame.fix_metadata(data=data)
return boardgame

View File

@ -0,0 +1,21 @@
from django.urls import path
from boardgames import views
app_name = "boardgames"
urlpatterns = [
path(
"board-game/", views.BoardGameListView.as_view(), name="boardgame_list"
),
path(
"board-game/<slug:slug>/",
views.BoardGameDetailView.as_view(),
name="boardgame_detail",
),
path(
"board-game-publisher/<slug:slug>/",
views.BoardGamePublisherDetailView.as_view(),
name="publisher_detail",
),
]

View File

View File

@ -0,0 +1,17 @@
from django.views import generic
from boardgames.models import BoardGame, BoardGamePublisher
class BoardGameListView(generic.ListView):
model = BoardGame
paginate_by = 20
class BoardGameDetailView(generic.DetailView):
model = BoardGame
slug_field = "uuid"
class BoardGamePublisherDetailView(generic.DetailView):
model = BoardGamePublisher
slug_field = "uuid"

View File

@ -0,0 +1,142 @@
from enum import Enum
from typing import Optional
from bs4 import BeautifulSoup
import requests
import logging
logger = logging.getLogger(__name__)
USER_AGENT = (
"Mozilla/5.0 (Android 4.4; Mobile; rv:41.0) Gecko/41.0 Firefox/41.0"
)
AMAZON_SEARCH_URL = "https://www.amazon.com/s?k={amazon_id}"
class AmazonAttribute(Enum):
SERIES = 0
PAGES = 1
LANGUAGE = 2
PUBLISHER = 3
PUB_DATE = 4
DIMENSIONS = 5
ISBN_10 = 6
ISBN_13 = 7
def strip_and_clean(text):
return text.strip("\n").rstrip().lstrip()
def get_rating_from_soup(soup) -> Optional[int]:
rating = None
try:
potential_rating = soup.find("div", class_="allmusic-rating")
if potential_rating:
rating = int(strip_and_clean(potential_rating.get_text()))
except ValueError:
pass
return rating
def get_review_from_soup(soup) -> str:
review = ""
try:
potential_text = soup.find("div", class_="text")
if potential_text:
review = strip_and_clean(potential_text.get_text())
except ValueError:
pass
return review
def scrape_data_from_amazon(url) -> dict:
data_dict = {}
headers = {"User-Agent": USER_AGENT}
r = requests.get(url, headers=headers)
if r.status_code == 200:
soup = BeautifulSoup(r.text, "html.parser")
# TODO Fix this scraper
data_dict["rating"] = get_rating_from_soup(soup)
data_dict["review"] = get_review_from_soup(soup)
return data_dict
def get_amazon_product_dict(amazon_id: str) -> dict:
data_dict = {}
url = ""
search_url = AMAZON_SEARCH_URL.format(amazon_id=amazon_id)
headers = {
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
"accept-language": "en-GB,en;q=0.9",
}
response = requests.get(search_url, headers=headers)
if response.status_code != 200:
logger.info(f"Bad http response from Amazon {response}")
return data_dict
soup = BeautifulSoup(response.text, "html.parser")
results = soup.find("a", class_="a-link-normal")
if not results:
logger.info(f"No search results for {amazon_id}")
return data_dict
product_url = "https://www.amazon.com" + str(results.get("href", ""))
data_dict = {}
response = requests.get(product_url, headers=headers)
if response.status_code != 200:
logger.info(f"Bad http response from Amazon {response}")
return data_dict
soup = BeautifulSoup(response.text, "html.parser")
try:
data_dict["title"] = soup.findAll("span", class_="celwidget")[
1
].text.strip()
data_dict["cover_url"] = soup.find("img", class_="frontImage").get(
"src"
)
data_dict["summary"] = soup.findAll(
"div", class_="a-expander-content"
)[1].text
meta = soup.findAll("div", class_="rpi-attribute-value")
data_dict["isbn"] = meta[AmazonAttribute.ISBN_10.value].text.strip()
pages = meta[AmazonAttribute.PAGES.value].text
if "pages" in pages:
data_dict["pages"] = (
meta[AmazonAttribute.PAGES.value]
.text.split("pages")[0]
.strip()
)
except IndexError as e:
logger.error(
f"Amazon lookup is failing for this product {amazon_id}: {e}"
)
except AttributeError as e:
logger.error(
f"Amazon lookup is failing for this product {amazon_id}: {e}"
)
return data_dict
def lookup_book_from_amazon(amazon_id: str) -> dict:
top = {}
return {
"title": top.get("title"),
"isbn": isbn,
"openlibrary_id": ol_id,
"goodreads_id": get_first("id_goodreads", top),
"first_publish_year": top.get("first_publish_year"),
"first_sentence": first_sentence,
"pages": top.get("number_of_pages_median", None),
"cover_url": COVER_URL.format(id=ol_id),
"ol_author_id": ol_author_id,
"subject_key_list": top.get("subject_key", []),
}

View File

@ -0,0 +1,233 @@
"""
ComicVine API Information & Documentation:
https://comicvine.gamespot.com/api/
https://comicvine.gamespot.com/api/documentation
"""
import json
import logging
from django.conf import settings
import requests
logger = logging.getLogger(__name__)
class ComicVineClient(object):
"""
Interacts with the ``search`` resource of the ComicVine API. Requires an
account on https://comicvine.gamespot.com/ in order to obtain an API key.
"""
# All API requests made by this client will be made to this URL.
API_URL = "https://www.comicvine.com/api/search/"
# A valid User-Agent header must be set in order for our API requests to
# be accepted, otherwise our request will be rejected with a
# **403 - Forbidden** error.
HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:7.0) "
"Gecko/20130825 Firefox/36.0"
}
# A set of valid resource types to return in results.
RESOURCE_TYPES = {
"character",
"issue",
"location",
"object",
"person",
"publisher",
"story_arc",
"team",
"volume",
}
def __init__(self, api_key, expire_after=300):
"""
Store the API key in a class variable, and install the requests cache,
configuring it using the ``expire_after`` parameter.
:param api_key: Your personal ComicVine API key.
:type api_key: str
:param expire_after: The number of seconds to retain an entry in cache.
:type expire_after: int or None
"""
self.api_key = api_key
def search(self, query, offset=0, limit=10, resources=None):
"""
Perform a search against the API, using the provided query term. If
required, a list of resource types to filter search results to can
be included.
Take the JSON contained in the response and provide it to the custom
``Response`` object's constructor. Return the ``Response`` object.
:param query: The search query with which to make the request.
:type query: str
:param offset: The index of the first record returned.
:type offset: int or None
:param limit: How many records to return **(max 10)**
:type limit: int or None
:param resources: A list of resources to include in the search results.
:type resources: list or None
:type use_cache: bool
:return: The response object containing the results of the search
query.
:rtype: comicvine_search.response.Response
"""
params = self._request_params(query, offset, limit, resources)
json_data = self._query_api(params)
return json_data
def _request_params(self, query, offset, limit, resources):
"""
Construct a dict containing the required key-value pairs of parameters
required in order to make the API request.
The documentation for the ``search`` resource can be found at
https://comicvine.gamespot.com/api/documentation#toc-0-30.
Regarding 'limit', as per the documentation:
The number of results to display per page. This value defaults to
10 and can not exceed this number.
:param query: The search query with which to make the request.
:type query: str
:param offset: The index of the first record returned.
:type offset: int
:param limit: How many records to return **(max 10)**
:type limit: int
:param resources: A list of resources to include in the search results.
:type resources: list or None
:return: A dictionary of request parameters.
:rtype: dict
"""
return {
"api_key": self.api_key,
"format": "json",
"limit": min(10, limit), # hard limit of 10
"offset": max(0, offset), # cannot provide negative offset
"query": query,
"resources": self._validate_resources(resources),
}
def _validate_resources(self, resources):
"""
Provided a list of resources, first convert it to a set and perform an
intersection with the set of valid resource types, ``RESOURCE_TYPES``.
Return a comma-separted string of the remaining valid resources, or
None if the set is empty.
:param resources: A list of resources to include in the search results.
:type resources: list or None
:return: A comma-separated string of valid resources.
:rtype: str or None
"""
if not resources:
return None
valid_resources = self.RESOURCE_TYPES & set(resources)
return ",".join(valid_resources) if valid_resources else None
def _query_api(self, params):
"""
Query the ComicVine API's ``search`` resource, providing the required
headers and parameters with the request. Optionally allow the caller
of the function to disable the request cache.
If an error occurs during the request, handle it accordingly. Upon
success, return the JSON from the response.
:param params: Parameters to include with the request.
:type params: dict
:param use_cache: Toggle the use of requests_cache.
:type use_cache: bool
:return: The JSON contained in the response.
:rtype: dict
"""
# Since we're performing the identical action regardless of whether
# or not the request cache is to be used, store the procedure in a
# local function to avoid repetition.
def __httpget():
response = requests.get(
self.API_URL, headers=self.HEADERS, params=params
)
if not response.ok:
self._handle_http_error(response)
return response.json()
return __httpget()
def _handle_http_error(self, response):
"""
Provided a ``requests.Response`` object, if the status code is
anything other than **200**, we will treat it as an error.
Using the response's status code, determine which type of exception to
raise. Construct an exception message from the response's status code
and reason properties before raising the exception.
:param response: The requests.Response object returned by the HTTP
request.
:type response: requests.Response
:raises ComicVineUnauthorizedException: if no API key provided.
:raises ComicVineForbiddenException: if no User-Agent header provided.
:raises ComicVineApiException: if an unidentified error occurs.
"""
exception = {
401: Exception,
403: Exception,
}.get(response.status_code, Exception)
message = f"{response.status_code} {response.reason}"
raise exception(message)
def lookup_comic_from_comicvine(title: str) -> dict:
api_key = getattr(settings, "COMICVINE_API_KEY", "")
if not api_key:
logger.warn("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})
return {}
title = " ".join([result.get("volume").get("name"), result.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"),
},
}
return data_dict

View File

@ -1,22 +1,20 @@
import codecs
from collections import OrderedDict
import logging
import os
import re
import sqlite3
from datetime import datetime
from datetime import datetime, timedelta
from enum import Enum
from typing import Iterable, List
import pytz
import requests
from books.models import Author, Book, Page
from books.models import Author, Book
from books.openlibrary import get_author_openlibrary_id
from django.db.models import Sum
from pylast import httpx, tempfile
from scrobbles.models import Scrobble
from django.apps import apps
from django.contrib.auth import get_user_model
from stream_sqlite import stream_sqlite
logger = logging.getLogger(__name__)
User = get_user_model()
class KoReaderBookColumn(Enum):
@ -43,11 +41,79 @@ class KoReaderPageStatColumn(Enum):
def _sqlite_bytes(sqlite_url):
with httpx.stream("GET", sqlite_url) as r:
yield from r.iter_bytes(chunk_size=65_536)
with requests.get(sqlite_url, stream=True) as r:
yield from r.iter_content(chunk_size=65_536)
def get_book_map_from_sqlite(rows: Iterable) -> dict:
# Grace period between page reads for it to be a new scrobble
SESSION_GAP_SECONDS = 1800 # a half hour
def get_author_str_from_row(row):
"""Given a the raw author string from KoReader, convert it to a single line and
strip the middle initials, as OpenLibrary lookup usually fails with those.
"""
ko_authors = row[KoReaderBookColumn.AUTHORS.value].replace("\n", ", ")
# Strip middle initials, OpenLibrary often fails with these
return re.sub(" [A-Z]. ", " ", ko_authors)
def lookup_or_create_authors_from_author_str(ko_author_str: str) -> list:
"""Takes a string of authors from KoReader and returns a list
of Authors from our database
"""
author_str_list = ko_author_str.split(", ")
author_list = []
for author_str in author_str_list:
logger.debug(f"Looking up author {author_str}")
# KoReader gave us nothing, bail
if author_str == "N/A":
logger.warn(f"KoReader author string is N/A, no authors to find")
continue
author = Author.objects.filter(name=author_str).first()
if not author:
author = Author.objects.create(
name=author_str,
openlibrary_id=get_author_openlibrary_id(author_str),
)
author.fix_metadata()
logger.debug(f"Created author {author}")
author_list.append(author)
return author_list
def create_book_from_row(row: list):
# No KoReader book yet, create it
author_str = get_author_str_from_row(row)
total_pages = row[KoReaderBookColumn.PAGES.value]
run_time = total_pages * Book.AVG_PAGE_READING_SECONDS
book = Book.objects.create(
title=row[KoReaderBookColumn.TITLE.value],
pages=total_pages,
koreader_data_by_hash={
str(row[KoReaderBookColumn.MD5.value]): {
"title": row[KoReaderBookColumn.TITLE.value],
"author_str": author_str,
"book_id": row[KoReaderBookColumn.ID.value],
"pages": total_pages,
}
},
run_time_seconds=run_time,
)
book.fix_metadata()
# Add authors
author_list = lookup_or_create_authors_from_author_str(author_str)
if author_list:
book.authors.add(*author_list)
# self._lookup_authors
return book
def build_book_map(rows) -> dict:
"""Given an interable of sqlite rows from the books table, lookup existing
books, create ones that don't exist, and return a mapping of koreader IDs to
primary key IDs for page creation.
@ -56,204 +122,284 @@ def get_book_map_from_sqlite(rows: Iterable) -> dict:
book_id_map = {}
for book_row in rows:
if (
book_row[KoReaderBookColumn.TITLE.value]
== "KOReader Quickstart Guide"
):
logger.info(
"Ignoring the KOReader quickstart guide. No on wants that."
)
continue
book = Book.objects.filter(
koreader_md5=book_row[KoReaderBookColumn.MD5.value]
koreader_data_by_hash__icontains=book_row[
KoReaderBookColumn.MD5.value
]
).first()
if not book:
book, created = Book.objects.get_or_create(
title=book_row[KoReaderBookColumn.TITLE.value]
)
if created:
total_pages = book_row[KoReaderBookColumn.PAGES.value]
run_time = total_pages * book.AVG_PAGE_READING_SECONDS
ko_authors = book_row[
KoReaderBookColumn.AUTHORS.value
].replace("\n", ", ")
# Strip middle initials, OpenLibrary often fails with these
ko_authors = re.sub(" [A-Z]. ", " ", ko_authors)
book_dict = {
"title": book_row[KoReaderBookColumn.TITLE.value],
"pages": total_pages,
"koreader_md5": book_row[KoReaderBookColumn.MD5.value],
"koreader_id": int(book_row[KoReaderBookColumn.ID.value]),
"koreader_authors": ko_authors,
"run_time_seconds": run_time,
}
Book.objects.filter(pk=book.id).update(**book_dict)
# Add authors
authors = ko_authors.split(", ")
author_list = []
for author_str in authors:
logger.debug(f"Looking up author {author_str}")
if author_str == "N/A":
continue
author, created = Author.objects.get_or_create(
name=author_str
)
if created:
author.openlibrary_id = get_author_openlibrary_id(
author_str
)
author.save(update_fields=["openlibrary_id"])
author.fix_metadata()
logger.debug(f"Created author {author}")
book.authors.add(author)
# This will try to fix metadata by looking it up on OL
book.fix_metadata()
book = create_book_from_row(book_row)
book.refresh_from_db()
total_seconds = 0
if book_row[KoReaderBookColumn.TOTAL_READ_TIME.value]:
total_seconds = book_row[KoReaderBookColumn.TOTAL_READ_TIME.value]
book_id_map[book_row[KoReaderBookColumn.ID.value]] = (
book.id,
total_seconds,
)
book_id_map[book_row[KoReaderBookColumn.ID.value]] = {
"book_id": book.id,
"hash": book_row[KoReaderBookColumn.MD5.value],
"total_seconds": total_seconds,
}
return book_id_map
def build_scrobbles_from_pages(
rows: Iterable, book_id_map: dict, user_id: int
) -> List[Scrobble]:
new_scrobbles = []
def build_page_data(page_rows: list, book_map: dict, user_tz=None) -> dict:
"""Given rows of page data from KoReader, parse each row and build
scrobbles for our user, loading the page data into the page_data
field on the scrobble instance.
"""
book_ids_not_found = []
for page_row in page_rows:
koreader_book_id = page_row[KoReaderPageStatColumn.ID_BOOK.value]
if koreader_book_id not in book_map.keys():
book_ids_not_found.append(koreader_book_id)
continue
if "pages" not in book_map[koreader_book_id].keys():
book_map[koreader_book_id]["pages"] = {}
new_scrobbles = []
pages_found = []
book_read_time_map = {}
for page_row in rows:
koreader_id = page_row[KoReaderPageStatColumn.ID_BOOK.value]
page_number = page_row[KoReaderPageStatColumn.PAGE.value]
ts = page_row[KoReaderPageStatColumn.START_TIME.value]
book_id = book_id_map[koreader_id][0]
book_read_time_map[book_id] = book_id_map[koreader_id][1]
duration = page_row[KoReaderPageStatColumn.DURATION.value]
start_ts = page_row[KoReaderPageStatColumn.START_TIME.value]
page, page_created = Page.objects.get_or_create(
book_id=book_id, number=page_number, user_id=user_id
book_map[koreader_book_id]["pages"][page_number] = {
"duration": duration,
"start_ts": start_ts,
"end_ts": start_ts + duration,
}
if book_ids_not_found:
logger.info(
f"Found pages for books not in file: {set(book_ids_not_found)}"
)
if page_created:
page.start_time = datetime.utcfromtimestamp(ts).replace(
tzinfo=pytz.utc
return book_map
def build_scrobbles_from_book_map(
book_map: dict, user: "User"
) -> list["Scrobble"]:
Scrobble = apps.get_model("scrobbles", "Scrobble")
scrobbles_to_create = []
pages_not_found = []
for koreader_book_id, book_dict in book_map.items():
book_id = book_dict["book_id"]
if "pages" not in book_dict.keys():
pages_not_found.append(book_id)
continue
should_create_scrobble = False
scrobble_page_data = {}
playback_position_seconds = 0
prev_page_stats = {}
last_page_number = 0
pages_processed = 0
total_pages_read = len(book_map[koreader_book_id]["pages"])
ordered_pages = sorted(
book_map[koreader_book_id]["pages"].items(),
key=lambda x: x[1]["start_ts"],
)
for cur_page_number, stats in ordered_pages:
pages_processed += 1
seconds_from_last_page = 0
if prev_page_stats:
seconds_from_last_page = stats.get(
"end_ts"
) - prev_page_stats.get("start_ts")
playback_position_seconds = playback_position_seconds + stats.get(
"duration"
)
page.duration_seconds = page_row[
KoReaderPageStatColumn.DURATION.value
]
page.save(update_fields=["start_time", "duration_seconds"])
pages_found.append(page)
playback_position_seconds = 0
for page in set(pages_found):
# Add up page seconds to set the aggregate time of all pages to reading time
playback_position_seconds = (
playback_position_seconds + page.duration_seconds
)
if page.is_scrobblable:
# Check to see if a scrobble with this timestamp, book and user already exists
scrobble = Scrobble.objects.filter(
timestamp=page.start_time,
book_id=page.book_id,
user_id=user_id,
).first()
if not scrobble:
logger.debug(
f"Queueing scrobble for {page.book}, page {page.number}"
end_of_reading = pages_processed == total_pages_read
big_jump_to_this_page = (cur_page_number - last_page_number) > 10
is_session_gap = seconds_from_last_page > SESSION_GAP_SECONDS
if (
is_session_gap and not big_jump_to_this_page
) or end_of_reading:
should_create_scrobble = True
if should_create_scrobble:
scrobble_page_data = dict(
sorted(
scrobble_page_data.items(),
key=lambda x: x[1]["start_ts"],
)
)
new_scrobble = Scrobble(
book_id=page.book_id,
user_id=user_id,
source="KOReader",
timestamp=page.start_time,
played_to_completion=True,
playback_position_seconds=playback_position_seconds,
in_progress=False,
book_pages_read=page.number,
long_play_complete=False,
)
new_scrobbles.append(new_scrobble)
# After setting a scrobblable page, reset our accumulator
playback_position_seconds = 0
return new_scrobbles
try:
first_page = scrobble_page_data.get(
list(scrobble_page_data.keys())[0]
)
last_page = scrobble_page_data.get(
list(scrobble_page_data.keys())[-1]
)
except IndexError:
logger.error(
"Could not process book, no page data found",
extra={"scrobble_page_data": scrobble_page_data},
)
continue
timezone = user.profile.timezone
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"
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)
scrobble = Scrobble.objects.filter(
timestamp=timestamp,
book_id=book_id,
user_id=user.id,
).first()
if not scrobble:
logger.info(
f"Queueing scrobble for {book_id}, page {cur_page_number}"
)
log_data = {
"koreader_hash": book_dict.get("hash"),
"page_data": scrobble_page_data,
"pages_read": cur_page_number,
}
scrobbles_to_create.append(
Scrobble(
book_id=book_id,
user_id=user.id,
source="KOReader",
media_type=Scrobble.MediaType.BOOK,
timestamp=timestamp,
log=log_data,
stop_timestamp=stop_timestamp,
playback_position_seconds=playback_position_seconds,
in_progress=False,
played_to_completion=True,
long_play_complete=False,
timezone=timezone,
)
)
# Then start over
should_create_scrobble = False
playback_position_seconds = 0
scrobble_page_data = {}
# We accumulate pages for the scrobble until we should create a new one
scrobble_page_data[cur_page_number] = stats
last_page_number = cur_page_number
prev_page_stats = stats
if pages_not_found:
logger.info(f"Pages not found for books: {set(pages_not_found)}")
return scrobbles_to_create
def enrich_koreader_scrobbles(scrobbles: list) -> None:
def fix_long_play_stats_for_scrobbles(scrobbles: list) -> None:
"""Given a list of scrobbles, update pages read, long play seconds and check
for media completion"""
for scrobble in scrobbles:
scrobble.book_pages_read = scrobble.book.page_set.last().number
# But if there's a next scrobble, set pages read to their starting page
#
if scrobble.next:
scrobble.book_pages_read = scrobble.next.book_pages_read - 1
scrobble.long_play_seconds = scrobble.book.page_set.filter(
number__lte=scrobble.book_pages_read
).aggregate(Sum("duration_seconds"))["duration_seconds__sum"]
scrobble.save(update_fields=["book_pages_read", "long_play_seconds"])
def process_koreader_sqlite_url(file_url, user_id) -> list:
book_id_map = {}
new_scrobbles = []
for table_name, pragma_table_info, rows in stream_sqlite(
_sqlite_bytes(file_url), max_buffer_size=1_048_576
):
logger.debug(f"Found table {table_name} - processing")
if table_name == "book":
book_id_map = get_book_map_from_sqlite(rows)
if table_name == "page_stat_data":
new_scrobbles = build_scrobbles_from_pages(
rows, book_id_map, user_id
if scrobble.previous and not scrobble.previous.long_play_complete:
scrobble.long_play_seconds = scrobble.playback_position_seconds + (
scrobble.previous.long_play_seconds or 0
)
logger.debug(f"Creating {len(new_scrobbles)} new scrobbles")
else:
scrobble.long_play_seconds = scrobble.playback_position_seconds
scrobble.log["book_pages_read"] = scrobble.calc_pages_read()
created = []
if new_scrobbles:
created = Scrobble.objects.bulk_create(new_scrobbles)
enrich_koreader_scrobbles(created)
logger.info(
f"Created {len(created)} scrobbles",
extra={"created_scrobbles": created},
)
return created
scrobble.save(update_fields=["log", "long_play_seconds"])
def process_koreader_sqlite_file(file_path, user_id) -> list:
"""Given a sqlite file from KoReader, open the book table, iterate
over rows creating scrobbles from each book found"""
# Create a SQL connection to our SQLite database
con = sqlite3.connect(file_path)
cur = con.cursor()
Scrobble = apps.get_model("scrobbles", "Scrobble")
book_id_map = get_book_map_from_sqlite(cur.execute("SELECT * FROM book"))
new_scrobbles = build_scrobbles_from_pages(
cur.execute("SELECT * from page_stat_data"), book_id_map, user_id
)
new_scrobbles = []
user = User.objects.filter(id=user_id).first()
tz = pytz.utc
if user:
tz = user.profile.timezone
is_os_file = "https://" not in file_path
if is_os_file:
# Loading sqlite file from local filesystem
con = sqlite3.connect(file_path)
cur = con.cursor()
try:
book_map = build_book_map(cur.execute("SELECT * FROM book"))
except sqlite3.OperationalError:
logger.warning("KOReader sqlite file had not table: book")
return new_scrobbles
book_map = build_page_data(
cur.execute(
"SELECT * from page_stat_data ORDER BY id_book, start_time"
),
book_map,
tz,
)
new_scrobbles = build_scrobbles_from_book_map(book_map, user)
else:
# Streaming the sqlite file off S3
book_map = {}
for table_name, pragma_table_info, rows in stream_sqlite(
_sqlite_bytes(file_path), max_buffer_size=1_048_576
):
logger.debug(f"Found table {table_name} - processing")
if table_name == "book":
book_map = build_book_map(rows)
for table_name, pragma_table_info, rows in stream_sqlite(
_sqlite_bytes(file_path), max_buffer_size=1_048_576
):
if table_name == "page_stat_data":
book_map = build_page_data(rows, book_map, tz)
new_scrobbles = build_scrobbles_from_book_map(book_map, user)
logger.info(f"Creating {len(new_scrobbles)} new scrobbles")
created = []
if new_scrobbles:
created = Scrobble.objects.bulk_create(new_scrobbles)
enrich_koreader_scrobbles(created)
fix_long_play_stats_for_scrobbles(created)
logger.info(
f"Created {len(created)} scrobbles",
extra={"created_scrobbles": created},
)
return created
def process_koreader_sqlite(file_path: str, user_id: int) -> list:
is_os_file = "https://" not in file_path
if is_os_file:
created = process_koreader_sqlite_file(file_path, user_id)
else:
created = process_koreader_sqlite_url(file_path, user_id)
return created

124
vrobbler/apps/books/locg.py Normal file
View File

@ -0,0 +1,124 @@
#!/usr/bin/env python3
from enum import Enum
from typing import Optional
from bs4 import BeautifulSoup
import requests
import logging
logger = logging.getLogger(__name__)
HEADERS = {
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
"accept-language": "en-GB,en;q=0.9",
}
LOCG_WRTIER_URL = ""
LOCG_WRITER_DETAIL_URL = "https://leagueofcomicgeeks.com/people/{slug}"
LOCG_SEARCH_URL = (
"https://leagueofcomicgeeks.com/search/ajax_issues?query={query}"
)
LOCG_DETAIL_URL = "https://leagueofcomicgeeks.com/comic/{locg_slug}"
def strip_and_clean(text):
return text.strip("\n").strip()
def get_rating_from_soup(soup) -> Optional[int]:
rating = None
try:
potential_rating = soup.find("div", class_="allmusic-rating")
if potential_rating:
rating = int(strip_and_clean(potential_rating.get_text()))
except ValueError:
pass
return rating
def lookup_comic_writer_by_locg_slug(slug: str) -> dict:
data_dict = {}
writer_url = LOCG_WRITER_DETAIL_URL.format(slug=slug)
response = requests.get(writer_url, headers=HEADERS)
if response.status_code != 200:
logger.info(f"Bad http response from LOCG {response}")
return data_dict
soup = BeautifulSoup(response.text, "html.parser")
data_dict["locg_slug"] = slug
data_dict["name"] = soup.find("h1").text.strip()
data_dict["photo_url"] = soup.find("div", class_="avatar").img.get("src")
return data_dict
def lookup_comic_by_locg_slug(slug: str) -> dict:
data_dict = {}
product_url = LOCG_DETAIL_URL.format(locg_slug=slug)
response = requests.get(product_url, headers=HEADERS)
if response.status_code != 200:
logger.info(f"Bad http response from LOCG {response}")
return data_dict
soup = BeautifulSoup(response.text, "html.parser")
try:
data_dict["title"] = soup.find("h1").text.strip()
data_dict["summary"] = soup.find("p").text.strip()
data_dict["cover_url"] = (
soup.find("div", class_="cover-art").find("img").get("src")
)
attrs = soup.findAll("div", class_="details-addtl-block")
try:
data_dict["pages"] = (
attrs[1]
.find("div", class_="value")
.text.split("pages")[0]
.strip()
)
except IndexError:
logger.warn(f"No ISBN field")
try:
data_dict["isbn"] = (
attrs[3].find("div", class_="value").text.strip()
)
except IndexError:
logger.warn(f"No ISBN field")
writer_slug = None
try:
writer_slug = (
soup.findAll("div", class_="name")[5]
.a.get("href")
.split("people/")[1]
)
except IndexError:
logger.warn(f"No wrtier found")
if writer_slug:
data_dict["locg_writer_slug"] = writer_slug
except AttributeError:
logger.warn(f"Trouble parsing HTML, elements missing")
return data_dict
def lookup_comic_from_locg(title: str) -> dict:
search_url = LOCG_SEARCH_URL.format(query=title)
response = requests.get(search_url, headers=HEADERS)
if response.status_code != 200:
logger.warn(f"Bad http response from LOCG {response}")
return {}
soup = BeautifulSoup(response.text, "html.parser")
try:
slug = soup.findAll("a")[1].get("href").split("comic/")[1]
except IndexError:
logger.warn(f"No comic found on LOCG for {title}")
return {}
return lookup_comic_by_locg_slug(slug)

View File

@ -0,0 +1,42 @@
#!/usr/bin/env python3
import logging
import pytz
from datetime import datetime, timedelta
from books.models import Book
from django.core.management.base import BaseCommand
from scrobbles.models import Scrobble
from vrobbler.apps.books.koreader import fix_long_play_stats_for_scrobbles
from vrobbler.apps.scrobbles.utils import timestamp_user_tz_to_utc
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)
):
first_page_start_ts.replace(tzinfo=pytz.timezone("Europe/Paris"))
last_page_end_ts.replace(tzinfo=pytz.timezone("Europe/Paris"))
else:
first_page_start_ts.replace(tzinfo=pytz.timezone("US/Eastern"))
last_page_end_ts.replace(tzinfo=pytz.timezone("US/Eastern"))
scrobble.timestamp = first_page_start_ts
scrobble.stop_timestamp = last_page_end_ts
if commit:
scrobble.save(update_fields=["timestamp", "stop_timestamp"])
class Command(BaseCommand):
def handle(self, *args, **options):
scrobbles_to_create = []
for scrobble in Scrobble.objects.filter(media_type="Book", source="KOReader"):
update_scrobble_from_page_data(scrobble)

View File

@ -0,0 +1,160 @@
import logging
import pytz
from datetime import datetime, timedelta
from books.models import Book
from django.core.management.base import BaseCommand
from scrobbles.models import Scrobble
from vrobbler.apps.books.koreader import fix_long_play_stats_for_scrobbles
from vrobbler.apps.scrobbles.utils import timestamp_user_tz_to_utc
logger = logging.getLogger(__name__)
# Grace period between page reads for it to be a new scrobble
SESSION_GAP_SECONDS = 1800 # a half hour
class Command(BaseCommand):
def handle(self, *args, **options):
scrobbles_to_create = []
for book in Book.objects.filter(koreader_id__isnull=False):
book.scrobble_set.all().delete()
koreader_data = book.koreader_data_by_hash or {}
if book.koreader_md5:
koreader_data[book.koreader_md5] = {
"title": book.title,
"book_id": book.koreader_id,
"author_str": book.koreader_authors,
"pages": book.pages,
}
book.koreader_data_by_hash = koreader_data
book.save(update_fields=["koreader_data_by_hash"])
# Next parse all this book's pages into new scrobbles
should_create_scrobble = False
scrobble_page_data = {}
playback_position_seconds = 0
prev_page = None
pages_processed = 0
total_pages = book.pages
for page in book.page_set.order_by("number"):
user = page.user
book_id = page.book.id
pages_processed += 1
scrobble_page_data[page.number] = {
"duration": page.duration_seconds,
"start_ts": page.start_time.timestamp(),
"end_ts": page.end_time.timestamp(),
}
seconds_from_last_page = 0
if prev_page:
seconds_from_last_page = (
page.end_time.timestamp()
- prev_page.start_time.timestamp()
)
playback_position_seconds = (
playback_position_seconds + page.duration_seconds
)
end_of_reading = pages_processed == total_pages
big_jump_to_this_page = False
if prev_page:
big_jump_to_this_page = (
page.number - prev_page.number
) > 10
if (
seconds_from_last_page > SESSION_GAP_SECONDS
and not big_jump_to_this_page
):
should_create_scrobble = True
if should_create_scrobble:
first_page = scrobble_page_data.get(
list(scrobble_page_data.keys())[0]
)
last_page = scrobble_page_data.get(
list(scrobble_page_data.keys())[-1]
)
start_ts = int(first_page.get("start_ts"))
end_ts = start_ts + playback_position_seconds
timestamp = datetime.fromtimestamp(start_ts).replace(
tzinfo=user.profile.tzinfo
)
stop_timestamp = datetime.fromtimestamp(end_ts).replace(
tzinfo=user.profile.tzinfo
)
# 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")
)
):
timestamp.replace(tzinfo=pytz.timezone("Europe/Paris"))
elif (
timestamp.tzinfo._dst.seconds == 0
or stop_timestamp.tzinfo._dst.seconds == 0
):
timestamp = timestamp - timedelta(hours=1)
stop_timestamp = stop_timestamp - timedelta(hours=1)
scrobble = Scrobble.objects.filter(
timestamp=timestamp,
book_id=book_id,
user_id=user.id,
).first()
if scrobble:
logger.info(
f"Found existing scrobble {scrobble}, updating"
)
scrobble.book_page_data = scrobble_page_data
scrobble.playback_position_seconds = (
scrobble.calc_reading_duration()
)
scrobble.save(
update_fields=[
"book_page_data",
"playback_position_seconds",
]
)
if not scrobble:
logger.info(
f"Queueing scrobble for {book_id}, page {page.number}"
)
scrobbles_to_create.append(
Scrobble(
book_id=book_id,
user_id=user.id,
source="KOReader",
media_type=Scrobble.MediaType.BOOK,
timestamp=timestamp,
stop_timestamp=stop_timestamp,
playback_position_seconds=playback_position_seconds,
book_koreader_hash=list(
book.koreader_data_by_hash.keys()
)[0],
book_page_data=scrobble_page_data,
book_pages_read=page.number,
in_progress=False,
played_to_completion=True,
long_play_complete=False,
)
)
# Then start over
should_create_scrobble = False
playback_position_seconds = 0
scrobble_page_data = {}
prev_page = page
created = Scrobble.objects.bulk_create(scrobbles_to_create)
fix_long_play_stats_for_scrobbles(created)

View File

@ -0,0 +1,29 @@
import logging
import pytz
from datetime import datetime, timedelta
from books.models import Book
from django.core.management.base import BaseCommand
from scrobbles.models import Scrobble
from vrobbler.apps.books.koreader import fix_long_play_stats_for_scrobbles
from vrobbler.apps.scrobbles.utils import timestamp_user_tz_to_utc
logger = logging.getLogger(__name__)
class Command(BaseCommand):
def handle(self, *args, **options):
total_books = Book.objects.all().count()
processed_books = 0
for book in Book.objects.all():
for scrobble in book.scrobble_set.all():
log_data = {
"koreader_hash": scrobble.book_koreader_hash,
"page_data": scrobble.book_page_data,
"pages_read": scrobble.book_pages_read,
}
scrobble.log = log_data
scrobble.save(update_fields=["log"])
processed_books += 1
logger.info(f"Processed book {processed_books} of {total_books}")

View File

@ -0,0 +1,26 @@
# Generated by Django 4.1.7 on 2023-04-18 02:33
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0040_alter_scrobble_media_type"),
("books", "0013_page_user"),
]
operations = [
migrations.AlterField(
model_name="book",
name="genre",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="scrobbles.ObjectWithGenres",
to="scrobbles.Genre",
verbose_name="Tags",
),
),
]

View File

@ -0,0 +1,30 @@
# Generated by Django 4.1.7 on 2023-08-19 02:47
from django.db import migrations, models
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0043_scrobbledpage"),
("books", "0014_alter_book_genre"),
]
operations = [
migrations.AlterField(
model_name="book",
name="first_sentence",
field=models.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name="book",
name="genre",
field=taggit.managers.TaggableManager(
help_text="A comma-separated list of tags.",
through="scrobbles.ObjectWithGenres",
to="scrobbles.Genre",
verbose_name="Tags",
),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 4.1.7 on 2023-08-26 04:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("books", "0015_alter_book_first_sentence_alter_book_genre"),
]
operations = [
migrations.AddField(
model_name="book",
name="locg_slug",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="book",
name="summary",
field=models.TextField(blank=True, null=True),
),
]

View File

@ -0,0 +1,20 @@
# Generated by Django 4.1.7 on 2023-08-26 16:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("books", "0016_book_locg_slug_book_summary"),
]
operations = [
migrations.AlterField(
model_name="book",
name="authors",
field=models.ManyToManyField(
blank=True, null=True, to="books.author"
),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.7 on 2023-08-31 03:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("books", "0017_alter_book_authors"),
]
operations = [
migrations.AddField(
model_name="author",
name="locg_slug",
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.7 on 2023-11-21 23:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("books", "0018_author_locg_slug"),
]
operations = [
migrations.AlterField(
model_name="book",
name="authors",
field=models.ManyToManyField(blank=True, to="books.author"),
),
]

View File

@ -0,0 +1,28 @@
# Generated by Django 4.2.9 on 2024-01-29 05:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("books", "0019_alter_book_authors"),
]
operations = [
migrations.AddField(
model_name="author",
name="comicvine_data",
field=models.JSONField(blank=True, null=True),
),
migrations.AddField(
model_name="book",
name="comicvine_data",
field=models.JSONField(blank=True, null=True),
),
migrations.AddField(
model_name="book",
name="koreader_data_by_hash",
field=models.JSONField(blank=True, null=True),
),
]

View File

@ -1,5 +1,6 @@
from collections import OrderedDict
import logging
from datetime import timedelta
from datetime import timedelta, datetime
from uuid import uuid4
import requests
@ -13,8 +14,29 @@ from django.core.files.base import ContentFile
from django.db import models
from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
from scrobbles.mixins import LongPlayScrobblableMixin, ScrobblableMixin
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from scrobbles.mixins import (
LongPlayScrobblableMixin,
ObjectWithGenres,
ScrobblableMixin,
)
from scrobbles.utils import get_scrobbles_for_media
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.scrobbles.dataclasses import BookLogData
COMICVINE_API_KEY = getattr(settings, "COMICVINE_API_KEY", "")
logger = logging.getLogger(__name__)
User = get_user_model()
@ -26,12 +48,26 @@ class Author(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
openlibrary_id = models.CharField(max_length=255, **BNULL)
headshot = models.ImageField(upload_to="books/authors/", **BNULL)
headshot_small = ImageSpecField(
source="headshot",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
headshot_medium = ImageSpecField(
source="headshot",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
)
bio = models.TextField(**BNULL)
wikipedia_url = models.CharField(max_length=255, **BNULL)
isni = models.CharField(max_length=255, **BNULL)
locg_slug = models.CharField(max_length=255, **BNULL)
wikidata_id = models.CharField(max_length=255, **BNULL)
goodreads_id = models.CharField(max_length=255, **BNULL)
librarything_id = models.CharField(max_length=255, **BNULL)
comicvine_data = models.JSONField(**BNULL)
amazon_id = models.CharField(max_length=255, **BNULL)
def __str__(self):
@ -63,31 +99,54 @@ class Book(LongPlayScrobblableMixin):
)
title = models.CharField(max_length=255)
authors = models.ManyToManyField(Author)
authors = models.ManyToManyField(Author, blank=True)
goodreads_id = models.CharField(max_length=255, **BNULL)
# All individual koreader fields are deprecated
koreader_id = models.IntegerField(**BNULL)
koreader_authors = models.CharField(max_length=255, **BNULL)
koreader_md5 = models.CharField(max_length=255, **BNULL)
koreader_data_by_hash = models.JSONField(**BNULL)
isbn = models.CharField(max_length=255, **BNULL)
pages = models.IntegerField(**BNULL)
language = models.CharField(max_length=4, **BNULL)
first_publish_year = models.IntegerField(**BNULL)
first_sentence = models.CharField(max_length=255, **BNULL)
first_sentence = models.TextField(**BNULL)
openlibrary_id = models.CharField(max_length=255, **BNULL)
locg_slug = models.CharField(max_length=255, **BNULL)
comicvine_data = models.JSONField(**BNULL)
cover = models.ImageField(upload_to="books/covers/", **BNULL)
cover_small = ImageSpecField(
source="cover",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
cover_medium = ImageSpecField(
source="cover",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
)
summary = models.TextField(**BNULL)
genre = TaggableManager(through=ObjectWithGenres)
def __str__(self):
return f"{self.title} by {self.author}"
return f"{self.title}"
@property
def subtitle(self):
return f" by {self.author}"
@property
def logdata_cls(self):
return BookLogData
@property
def primary_image_url(self) -> str:
url = ""
if self.cover:
url = self.cover.url
url = self.cover_medium.url
return url
def get_start_url(self):
@ -96,32 +155,89 @@ class Book(LongPlayScrobblableMixin):
def get_absolute_url(self):
return reverse("books:book_detail", kwargs={"slug": self.uuid})
def fix_metadata(self, force_update=False):
if not self.openlibrary_id or force_update:
def fix_metadata(self, data: dict = {}, force_update=False):
if (not self.openlibrary_id or not self.locg_slug) or force_update:
author_name = ""
if self.author:
author_name = self.author.name
book_dict = lookup_book_from_openlibrary(self.title, author_name)
if not book_dict:
logger.warn(f"Book not found in OL {self.title}")
if not data:
logger.warn(f"Checking openlibrary for {self.title}")
if self.openlibrary_id and force_update:
data = lookup_book_from_openlibrary(
str(self.openlibrary_id)
)
else:
data = lookup_book_from_openlibrary(
str(self.title), author_name
)
if not data:
if self.locg_slug:
logger.warn(
f"Checking LOCG for {self.title} with slug {self.locg_slug}"
)
data = lookup_comic_by_locg_slug(str(self.locg_slug))
else:
logger.warn(f"Checking LOCG for {self.title}")
data = lookup_comic_from_locg(str(self.title))
if not data and COMICVINE_API_KEY:
logger.warn(f"Checking ComicVine for {self.title}")
cv_client = ComicVineClient(api_key=COMICVINE_API_KEY)
data = lookup_comic_from_comicvine(str(self.title))
if not data:
logger.warn(f"Book not found in any sources: {self.title}")
return
cover_url = book_dict.pop("cover_url", "")
ol_author_id = book_dict.pop("ol_author_id", "")
ol_author_name = book_dict.pop("ol_author_name", "")
if book_dict.get("pages") == None:
book_dict.pop("pages")
# We can discard the author name from OL for now, we'll lookup details below
data.pop("ol_author_name", "")
if data.get("ol_author_id"):
self.fix_authors_metadata(data.pop("ol_author_id", ""))
if data.get("locg_writer_slug"):
self.get_author_from_locg(data.pop("locg_writer_slug", ""))
ol_title = book_dict.get("title", "")
ol_title = data.get("title", "")
data.pop("ol_author_id", "")
if ol_title.lower() != self.title.lower():
# Kick out a little warning if we're about to change KoReader's title
if (
fuzz.ratio(ol_title.lower(), str(self.title).lower()) < 80
and not force_update
):
logger.warn(
f"OL and KoReader disagree on this book title {self.title} != {ol_title}"
f"OL and KoReader disagree on this book title {self.title} != {ol_title}, check manually"
)
self.openlibrary_id = data.get("openlibrary_id")
self.save(update_fields=["openlibrary_id"])
return
Book.objects.filter(pk=self.id).update(**book_dict)
# If we don't know pages, don't overwrite existing with None
if "pages" in data.keys() and data.get("pages") == None:
data.pop("pages")
if (
not isinstance(data.get("pages"), int)
and "pages" in data.keys()
):
logger.info(
f"Pages for {self} from OL expected to be int, but got {data.get('pages')}"
)
data.pop("pages")
# Pop this, so we can look it up later
cover_url = data.pop("cover_url", "")
subject_key_list = data.pop("subject_key_list", "")
# Fun trick for updating all fields at once
Book.objects.filter(pk=self.id).update(**data)
self.refresh_from_db()
if subject_key_list:
self.genre.add(*subject_key_list)
if cover_url:
r = requests.get(cover_url)
if r.status_code == 200:
@ -129,12 +245,65 @@ class Book(LongPlayScrobblableMixin):
self.cover.save(fname, ContentFile(r.content), save=True)
if self.pages:
self.run_time_seconds = self.pages * int(
self.run_time_seconds = int(self.pages) * int(
self.AVG_PAGE_READING_SECONDS
)
self.save()
def fix_authors_metadata(self, openlibrary_author_id):
author = Author.objects.filter(
openlibrary_id=openlibrary_author_id
).first()
if not author:
data = lookup_author_from_openlibrary(openlibrary_author_id)
author_image_url = data.pop("author_headshot_url", None)
author = Author.objects.create(**data)
if author_image_url:
r = requests.get(author_image_url)
if r.status_code == 200:
fname = f"{author.name}_{author.uuid}.jpg"
author.headshot.save(
fname, ContentFile(r.content), save=True
)
self.authors.add(author)
def get_author_from_locg(self, locg_slug):
writer = lookup_comic_writer_by_locg_slug(locg_slug)
author, created = Author.objects.get_or_create(
name=writer["name"], locg_slug=writer["locg_slug"]
)
if (created or not author.headshot) and writer["photo_url"]:
r = requests.get(writer["photo_url"])
if r.status_code == 200:
fname = f"{author.name}_{author.uuid}.jpg"
author.headshot.save(fname, ContentFile(r.content), save=True)
self.authors.add(author)
def page_data_for_user(
self, user_id: int, convert_timestamps: bool = True
) -> dict:
scrobbles = self.scrobble_set.filter(user=user_id)
pages = {}
for scrobble in scrobbles:
if scrobble.logdata.page_data:
for page, data in scrobble.logdata.page_data.items():
if convert_timestamps:
data["start_ts"] = datetime.fromtimestamp(
data["start_ts"]
)
data["end_ts"] = datetime.fromtimestamp(data["end_ts"])
pages[page] = data
sorted_pages = OrderedDict(
sorted(pages.items(), key=lambda x: x[1]["start_ts"])
)
return sorted_pages
@property
def author(self):
return self.authors.first()
@ -157,22 +326,35 @@ class Book(LongPlayScrobblableMixin):
last_scrobble = get_scrobbles_for_media(self, user).last()
progress = 0
if last_scrobble:
progress = int((last_scrobble.book_pages_read / self.pages) * 100)
progress = int((last_scrobble.last_page_read / self.pages) * 100)
return progress
@classmethod
def find_or_create(cls, data_dict: dict) -> "Game":
from books.utils import update_or_create_book
def find_or_create(cls, lookup_id: str, author: str = "") -> "Book":
book = cls.objects.filter(openlibrary_id=lookup_id).first()
return update_or_create_book(
data_dict.get("title"), data_dict.get("author")
)
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=data["isbn"])
if book_created:
book.fix_metadata(data=data)
return book
class Page(TimeStampedModel):
user = models.ForeignKey(User, on_delete=models.CASCADE)
"""DEPRECATED, we need to migrate pages into page_data on scrobbles and move on"""
book = models.ForeignKey(Book, on_delete=models.CASCADE)
number = models.IntegerField()
user = models.ForeignKey(User, on_delete=models.CASCADE)
start_time = models.DateTimeField(**BNULL)
end_time = models.DateTimeField(**BNULL)
duration_seconds = models.IntegerField(**BNULL)

View File

@ -1,9 +1,12 @@
import json
import logging
from typing import Optional
import urllib
import requests
from thefuzz import fuzz
logger = logging.getLogger(__name__)
ISBN_URL = "https://openlibrary.org/isbn/{isbn}.json"
@ -35,8 +38,11 @@ def get_author_openlibrary_id(name: str) -> str:
logger.warn(f"No author results found from search for {name}")
return ""
result = results.get("docs", [])
return result[0].get("key")
try:
result = results.get("docs", [])[0]
except IndexError:
result = {"key": ""}
return result.get("key")
def lookup_author_from_openlibrary(olid: str) -> dict:
@ -74,7 +80,9 @@ def lookup_author_from_openlibrary(olid: str) -> dict:
}
def lookup_book_from_openlibrary(title: str, author: str = None) -> dict:
def lookup_book_from_openlibrary(
title: str, author: Optional[str] = None
) -> dict:
title_quoted = urllib.parse.quote(title)
author_quoted = ""
if author:
@ -96,11 +104,22 @@ def lookup_book_from_openlibrary(title: str, author: str = None) -> dict:
top = None
for result in results.get("docs"):
# These Summary things suck and ruin our one-shot search
if "Summary of" not in result.get("title"):
if fuzz.ratio(title.lower(), result.get("title", "").lower()) > 90:
top = result
break
if not top:
for result in results.get("docs"):
# These Summary things suck and ruin our one-shot search
if "Summary of" in result.get("title"):
continue
if title.lower() in result.get("title", "").lower():
top = result
if not top and len(results.get("docs")) > 0:
top = results.get("docs")[0]
if not top:
logger.warn(f"No book found for query {query}")
return {}
@ -126,4 +145,5 @@ def lookup_book_from_openlibrary(title: str, author: str = None) -> dict:
"pages": top.get("number_of_pages_median", None),
"cover_url": COVER_URL.format(id=ol_id),
"ol_author_id": ol_author_id,
"subject_key_list": top.get("subject_key", []),
}

View File

@ -0,0 +1,123 @@
import hashlib
import random
import pytest
from django.contrib.auth import get_user_model
from vrobbler.apps.books.koreader import KoReaderBookColumn
User = get_user_model()
ordinal = lambda n: "%d%s" % (
n,
"tsnrhtdd"[(n // 10 % 10 != 1) * (n % 10 < 4) * n % 10 :: 4],
)
AVERAGE_PAGE_READING_SECONDS = 60
class DummyResponse:
status_code = 200
def status_code(self):
return self.status_code
@pytest.fixture
def valid_response():
return DummyResponse()
class KoReaderBookRows:
id = 1
DEFAULT_STR = "N/A"
DEFAULT_INT = 0
DEFAULT_TIME = 1703800469
BOOK_ROWS = []
PAGE_STATS_ROWS = []
def _gen_random_row(self, i):
wiggle = random.randrange(15)
title = f"Memoirs, Volume {i}"
return [
i,
title,
f"Lord Beaverbrook the {ordinal(i)}",
self.DEFAULT_INT + wiggle / 10,
self.DEFAULT_TIME + i * wiggle,
0,
300 + wiggle,
self.DEFAULT_STR,
self.DEFAULT_STR,
hashlib.md5(title.encode()).hexdigest(),
i * wiggle * 20,
120,
]
def _generate_random_book_rows(self, book_count):
if book_count > 0:
for i in range(1, book_count + 1):
self.BOOK_ROWS.append(self._gen_random_row(i))
def _generate_custom_book_row(self, **kwargs):
title = kwargs.get("title", self.DEFAULT_STR)
if title and title != "N/A":
self.BOOK_ROWS.append(
[
kwargs.get("id", self.id),
kwargs.get("title", self.DEFAULT_STR),
kwargs.get("authors", self.DEFAULT_STR),
kwargs.get("notes", self.DEFAULT_INT),
kwargs.get("last_open", self.DEFAULT_TIME),
kwargs.get("highlights", self.DEFAULT_INT),
kwargs.get("pages", self.DEFAULT_INT),
kwargs.get("series", self.DEFAULT_STR),
kwargs.get("language", self.DEFAULT_STR),
hashlib.md5(title.encode()),
kwargs.get("total_read_time", self.DEFAULT_INT),
kwargs.get("total_read_pages", self.DEFAULT_INT),
]
)
def _generate_random_page_stats_rows(self):
for book in self.BOOK_ROWS:
pages = book[KoReaderBookColumn.PAGES.value]
pages_per_session = 20
start_time = book[KoReaderBookColumn.LAST_OPEN.value]
end_session = False
for page_num in range(
1, book[KoReaderBookColumn.TOTAL_READ_PAGES.value] + 1
):
wiggle = random.randrange(5)
self.PAGE_STATS_ROWS.append(
[
book[KoReaderBookColumn.ID.value],
page_num,
start_time,
AVERAGE_PAGE_READING_SECONDS + wiggle,
pages,
]
)
if end_session:
start_time += 3600 # one second over an hour, marking a new reading session
end_session = False
else:
start_time += AVERAGE_PAGE_READING_SECONDS
if page_num % pages_per_session == 0:
end_session = True
def __init__(self, book_count=0, **kwargs):
self._generate_random_book_rows(book_count)
self._generate_custom_book_row(**kwargs)
self._generate_random_page_stats_rows()
@pytest.fixture
def koreader_rows():
return KoReaderBookRows(book_count=1)
@pytest.fixture
def demo_user():
return User.objects.create(email="demo@example.com")

View File

@ -0,0 +1,54 @@
import pytest
from unittest import mock
from books.koreader import (
KoReaderBookColumn,
build_book_map,
build_page_data,
build_scrobbles_from_book_map,
)
@pytest.mark.django_db
@mock.patch("requests.get")
def test_build_book_map(get_mock, koreader_rows, valid_response):
get_mock.return_value = valid_response
book_map = build_book_map(koreader_rows.BOOK_ROWS)
assert len(book_map) == 1
@pytest.mark.django_db
@mock.patch("requests.get")
def test_load_page_data_to_map(get_mock, koreader_rows, valid_response):
get_mock.return_value = valid_response
book_map = build_page_data(
koreader_rows.PAGE_STATS_ROWS,
build_book_map(koreader_rows.BOOK_ROWS),
)
assert (
len(book_map[1]["pages"])
== koreader_rows.BOOK_ROWS[0][
KoReaderBookColumn.TOTAL_READ_PAGES.value
]
)
@pytest.mark.django_db
@mock.patch("requests.get")
def test_build_scrobbles_from_pages(
get_mock, koreader_rows, demo_user, valid_response
):
get_mock.return_value = valid_response
book_map = build_book_map(koreader_rows.BOOK_ROWS)
book_map = build_page_data(koreader_rows.PAGE_STATS_ROWS, book_map)
scrobbles = build_scrobbles_from_book_map(book_map, demo_user)
# Corresponds to number of sessions per book ( 20 pages per session, 120 +/- 15 pages read )
expected_scrobbles = 6 * len(book_map.keys())
assert len(scrobbles) == expected_scrobbles
assert len(scrobbles[0].logdata.page_data.keys()) == 21
assert len(scrobbles[1].logdata.page_data.keys()) == 20
assert len(scrobbles[2].logdata.page_data.keys()) == 20
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

View File

@ -0,0 +1,36 @@
from unittest import skip
import pytest
from books.openlibrary import lookup_book_from_openlibrary
def test_lookup_modern_book():
book = lookup_book_from_openlibrary("Matrix", "Lauren Groff")
assert book.get("title") == "Matrix"
assert book.get("openlibrary_id") == "OL32170218M"
assert book.get("ol_author_id") == "OL3675729A"
def test_lookup_classic_book():
book = lookup_book_from_openlibrary(
"The Life of Castruccio Castracani", "Machiavelli"
)
assert book.get("title") == "The Life of Castruccio Castracani of Lucca"
assert book.get("openlibrary_id") == "OL8950869M"
assert book.get("ol_author_id") == "OL23135A"
def test_lookup_foreign_book():
book = lookup_book_from_openlibrary("Ravagé", "René Barjavel")
assert book.get("title") == "Ravage"
assert book.get("openlibrary_id") == "OL8837839M"
assert book.get("ol_author_id") == "OL152472A"
@skip("This is rotten in OL, updated but waiting for it to update")
def test_lookup_book():
book = lookup_book_from_openlibrary("Hark! A Vagrant")
assert book.get("title") == "Hark! A Vagrant"
assert book.get("openlibrary_id") == "OL8837839M"
assert book.get("ol_author_id") == "OL152472A"

View File

@ -1,52 +0,0 @@
from django.core.files.base import ContentFile
import requests
from typing import Optional
from books.openlibrary import (
lookup_author_from_openlibrary,
lookup_book_from_openlibrary,
)
from books.models import Author, Book
def update_or_create_book(
title: str, author: Optional[str] = None, force_update=False
) -> Book:
book_dict = lookup_book_from_openlibrary(title, author)
book, book_created = Book.objects.get_or_create(
isbn=book_dict.get("isbn"),
)
if book_created or force_update:
cover_url = book_dict.pop("cover_url")
ol_author_id = book_dict.pop("ol_author_id")
Book.objects.filter(pk=book.id).update(**book_dict)
book.refresh_from_db()
# Process authors
author_dict = lookup_author_from_openlibrary(ol_author_id)
author = Author.objects.filter(openlibrary_id=ol_author_id).first()
if not author:
author_image_url = author_dict.pop("author_headshot_url", None)
author = Author.objects.create(**author_dict)
if author_image_url:
r = requests.get(author_image_url)
if r.status_code == 200:
fname = f"{author.name}_{author.uuid}.jpg"
author.headshot.save(
fname, ContentFile(r.content), save=True
)
book.authors.add(author)
# Process cover URL
r = requests.get(cover_url)
if r.status_code == 200:
fname = f"{book.title}_{book.uuid}.jpg"
book.cover.save(fname, ContentFile(r.content), save=True)
book.fix_metadata()
return book

View File

@ -1,15 +1,15 @@
from django.views import generic
from books.models import Book, Author
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
class BookListView(generic.ListView):
class BookListView(ScrobbleableListView):
model = Book
paginate_by = 20
class BookDetailView(generic.DetailView):
class BookDetailView(ScrobbleableDetailView):
model = Book
slug_field = "uuid"
class AuthorDetailView(generic.DetailView):

View File

View File

@ -0,0 +1,23 @@
from django.contrib import admin
from bricksets.models import BrickSet
from scrobbles.admin import ScrobbleInline
class BrickSetInline(admin.TabularInline):
model = BrickSet
extra = 0
@admin.register(BrickSet)
class BrickSetAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"uuid",
"title",
)
ordering = ("-created",)
search_fields = ("title",)
inlines = [
ScrobbleInline,
]

View File

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

View File

@ -0,0 +1,106 @@
# Generated by Django 4.2.15 on 2024-09-07 05:38
from django.db import migrations, models
import django_extensions.db.fields
import taggit.managers
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
("scrobbles", "0059_remove_scrobble_book_koreader_hash_and_more"),
]
operations = [
migrations.CreateModel(
name="BrickSet",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
(
"uuid",
models.UUIDField(
blank=True,
default=uuid.uuid4,
editable=False,
null=True,
),
),
(
"title",
models.CharField(blank=True, max_length=255, null=True),
),
(
"run_time_seconds",
models.IntegerField(blank=True, null=True),
),
(
"run_time_ticks",
models.PositiveBigIntegerField(blank=True, null=True),
),
(
"number",
models.CharField(blank=True, max_length=10, null=True),
),
("release_year", models.IntegerField(blank=True, null=True)),
("piece_count", models.IntegerField(blank=True, null=True)),
(
"brickset_rating",
models.DecimalField(
blank=True, decimal_places=1, max_digits=3, null=True
),
),
(
"lego_item_number",
models.CharField(blank=True, max_length=10, null=True),
),
(
"box_image",
models.ImageField(
blank=True, null=True, upload_to="brickset/boxes/"
),
),
(
"set_image",
models.ImageField(
blank=True, null=True, upload_to="brickset/sets/"
),
),
(
"genre",
taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="scrobbles.ObjectWithGenres",
to="scrobbles.Genre",
verbose_name="Tags",
),
),
],
options={
"abstract": False,
},
),
]

View File

@ -0,0 +1,63 @@
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 BrickSetLogData
from scrobbles.mixins import LongPlayScrobblableMixin
BNULL = {"blank": True, "null": True}
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)
lego_item_number = models.CharField(max_length=10, **BNULL)
box_image = models.ImageField(upload_to="brickset/boxes/", **BNULL)
box_image_small = ImageSpecField(
source="box_image",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
box_image_medium = ImageSpecField(
source="box_image",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
)
set_image = models.ImageField(upload_to="brickset/sets/", **BNULL)
set_image_small = ImageSpecField(
source="set_image",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
set_image_medium = ImageSpecField(
source="set_image",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
)
def get_absolute_url(self):
return reverse("bricksets:brickset_detail", kwargs={"slug": self.uuid})
@property
def logdata_cls(self):
return BrickSetLogData
@classmethod
def find_or_create(cls, title: str) -> "BrickSet":
return cls.objects.filter(title=title).first()
@property
def primary_image_url(self) -> str:
if self.box_image:
return self.box_image.url
return ""

View File

@ -0,0 +1,14 @@
from django.urls import path
from bricksets import views
app_name = "bricksets"
urlpatterns = [
path("bricksets/", views.BrickSetListView.as_view(), name="brickset_list"),
path(
"bricksets/<slug:slug>/",
views.BrickSetDetailView.as_view(),
name="brickset_detail",
),
]

View File

@ -0,0 +1,10 @@
from bricksets.models import BrickSet
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
class BrickSetListView(ScrobbleableListView):
model = BrickSet
class BrickSetDetailView(ScrobbleableDetailView):
model = BrickSet

View File

@ -0,0 +1,16 @@
from django.contrib import admin
from lifeevents.models import LifeEvent
from scrobbles.admin import ScrobbleInline
@admin.register(LifeEvent)
class EventAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = ("title",)
search_fields = ("title",)
ordering = ("-created",)
inlines = [
ScrobbleInline,
]

View File

@ -0,0 +1,79 @@
# Generated by Django 4.2.11 on 2024-05-07 13:37
from django.db import migrations, models
import django_extensions.db.fields
import taggit.managers
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
("scrobbles", "0055_rename_scrobble_log_scrobble_log"),
]
operations = [
migrations.CreateModel(
name="LifeEvent",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
(
"uuid",
models.UUIDField(
blank=True,
default=uuid.uuid4,
editable=False,
null=True,
),
),
(
"title",
models.CharField(blank=True, max_length=255, null=True),
),
(
"run_time_seconds",
models.IntegerField(blank=True, null=True),
),
(
"run_time_ticks",
models.PositiveBigIntegerField(blank=True, null=True),
),
("description", models.TextField(blank=True, null=True)),
(
"genre",
taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="scrobbles.ObjectWithGenres",
to="scrobbles.Genre",
verbose_name="Tags",
),
),
],
options={
"abstract": False,
},
),
]

View File

@ -0,0 +1,36 @@
from django.apps import apps
from django.db import models
from django.urls import reverse
import pendulum
from scrobbles.dataclasses import LifeEventLogData
from scrobbles.mixins import ScrobblableMixin
BNULL = {"blank": True, "null": True}
class LifeEvent(ScrobblableMixin):
COMPLETION_PERCENT = 100
description = models.TextField(**BNULL)
def __str__(self):
return self.title
def get_absolute_url(self):
return reverse(
"life-events:life-event_detail", kwargs={"slug": self.uuid}
)
@property
def logdata_cls(self):
return LifeEventLogData
@classmethod
def find_or_create(cls, title: str) -> "LifeEvent":
return cls.objects.filter(title=title).first()
def scrobbles(self, user_id):
Scrobble = apps.get_model("scrobbles", "Scrobble")
return Scrobble.objects.filter(
user_id=user_id, life_event=self
).order_by("-timestamp")

View File

@ -0,0 +1,16 @@
from django.urls import path
from lifeevents import views
app_name = "lifeevents"
urlpatterns = [
path(
"lifeevents/", views.LifeEventListView.as_view(), name="lifeevent_list"
),
path(
"lifeevent/<slug:slug>/",
views.LifeEventDetailView.as_view(),
name="life-event_detail",
),
]

View File

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

View File

View File

@ -0,0 +1,21 @@
from django.contrib import admin
from locations.models import GeoLocation
from scrobbles.admin import ScrobbleInline
@admin.register(GeoLocation)
class GeoLocationAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"created",
"lat",
"lon",
"title",
"altitude",
)
ordering = ("-created",)
inlines = [
ScrobbleInline,
]

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class LocationConfig(AppConfig):
name = "locations"

View File

@ -0,0 +1,7 @@
#!/usr/bin/env python3
from collections import defaultdict
LOCATION_PROVIDERS = defaultdict(lambda: "Unknown")
LOCATION_PROVIDERS["gps"] = "GPS"
LOCATION_PROVIDERS["network"] = "Wifi Triangulation"

View File

@ -0,0 +1,77 @@
# Generated by Django 4.1.7 on 2023-11-21 23:29
from django.db import migrations, models
import django_extensions.db.fields
import taggit.managers
import uuid
class Migration(migrations.Migration):
initial = True
operations = [
migrations.CreateModel(
name="GeoLocation",
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"
),
),
(
"title",
models.CharField(blank=True, max_length=255, null=True),
),
(
"run_time_seconds",
models.IntegerField(blank=True, null=True),
),
(
"run_time_ticks",
models.PositiveBigIntegerField(blank=True, null=True),
),
(
"uuid",
models.UUIDField(
blank=True,
default=uuid.uuid4,
editable=False,
null=True,
),
),
("lat", models.FloatField()),
("lon", models.FloatField()),
("altitude", models.FloatField(blank=True, null=True)),
(
"genre",
taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="scrobbles.ObjectWithGenres",
to="scrobbles.Genre",
verbose_name="Tags",
),
),
],
options={
"unique_together": {("lat", "lon", "altitude")},
},
),
]

View File

@ -0,0 +1,58 @@
# Generated by Django 4.1.7 on 2023-11-22 00:13
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("locations", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="RawGeoLocation",
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"
),
),
("lat", models.FloatField()),
("lon", models.FloatField()),
("altitude", models.FloatField(blank=True, null=True)),
("speed", models.FloatField(blank=True, null=True)),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.1.7 on 2023-11-22 23:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("locations", "0002_rawgeolocation"),
]
operations = [
migrations.AddField(
model_name="rawgeolocation",
name="timestamp",
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@ -0,0 +1,17 @@
# Generated by Django 4.1.7 on 2023-11-24 12:45
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("locations", "0003_rawgeolocation_timestamp"),
]
operations = [
migrations.AlterModelOptions(
name="rawgeolocation",
options={},
),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 4.1.7 on 2023-11-24 18:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("locations", "0004_alter_rawgeolocation_options"),
]
operations = [
migrations.AlterModelOptions(
name="rawgeolocation",
options={"get_latest_by": "modified"},
),
migrations.AddField(
model_name="geolocation",
name="truncated_lat",
field=models.FloatField(blank=True, null=True),
),
migrations.AddField(
model_name="geolocation",
name="truncated_lon",
field=models.FloatField(blank=True, null=True),
),
]

View File

@ -0,0 +1,16 @@
# Generated by Django 4.2.9 on 2024-02-20 00:20
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("locations", "0005_alter_rawgeolocation_options_and_more"),
]
operations = [
migrations.DeleteModel(
name="RawGeoLocation",
),
]

View File

@ -0,0 +1,132 @@
from decimal import Decimal, getcontext
import logging
from typing import Dict
from uuid import uuid4
from django.contrib.auth import get_user_model
from django.conf import settings
from django.db import models
from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
from scrobbles.mixins import ScrobblableMixin
logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
User = get_user_model()
GEOLOC_ACCURACY = int(getattr(settings, "GEOLOC_ACCURACY", 4))
GEOLOC_PROXIMITY = Decimal(getattr(settings, "GEOLOC_PROXIMITY", "0.0001"))
class GeoLocation(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, "LOCATION_COMPLETION_PERCENT", 100)
lat = models.FloatField()
lon = models.FloatField()
truncated_lat = models.FloatField(**BNULL)
truncated_lon = models.FloatField(**BNULL)
altitude = models.FloatField(**BNULL)
class Meta:
unique_together = [["lat", "lon", "altitude"]]
def __str__(self):
if self.title:
return self.title
return f"{self.lat} x {self.lon}"
def get_absolute_url(self):
return reverse(
"locations:geo_location_detail", kwargs={"slug": self.uuid}
)
@classmethod
def find_or_create(cls, data_dict: Dict) -> "GeoLocation":
"""Given a data dict from GPSLogger, does the heavy lifting of looking up
the location, creating if if doesn't exist yet.
"""
# TODO Add constants for all these data keys
if "lat" not in data_dict.keys() or "lon" not in data_dict.keys():
logger.error("No lat or lon keys in data dict")
return
int_lat, r_lat = str(data_dict.get("lat", "")).split(".")
int_lon, r_lon = str(data_dict.get("lon", "")).split(".")
try:
trunc_lat = r_lat[0:GEOLOC_ACCURACY]
except IndexError:
trunc_lat = r_lat
try:
trunc_lon = r_lon[0:GEOLOC_ACCURACY]
except IndexError:
trunc_lon = r_lon
data_dict["lat"] = float(f"{int_lat}.{trunc_lat}")
data_dict["lon"] = float(f"{int_lon}.{trunc_lon}")
int_alt, r_alt = str(data_dict.get("alt", "")).split(".")
data_dict["altitude"] = float(int_alt)
location = cls.objects.filter(
lat=data_dict.get("lat"),
lon=data_dict.get("lon"),
).first()
if not location:
location = cls.objects.create(
lat=data_dict.get("lat"),
lon=data_dict.get("lon"),
altitude=data_dict.get("altitude"),
)
return location
@property
def subtitle(self) -> str:
if self.title:
return f"{self.lat} x {self.lon}"
return ""
def loc_diff(self, old_lat_lon: tuple) -> tuple:
return (
abs(Decimal(old_lat_lon[0]) - Decimal(self.lat)),
abs(Decimal(old_lat_lon[1]) - Decimal(self.lon)),
)
def has_moved(self, previous_location: "GeoLocation") -> bool:
has_moved = False
loc_diff = self.loc_diff(
(previous_location.lat, previous_location.lon)
)
if loc_diff[0] > GEOLOC_PROXIMITY or loc_diff[1] > GEOLOC_PROXIMITY:
has_moved = True
logger.debug(
f"[locations] checked whether location has moved against proximity setting",
extra={
"location_id": self.id,
"loc_diff": loc_diff,
"has_moved": has_moved,
"previous_location_id": previous_location.id,
"geoloc_proximity": GEOLOC_PROXIMITY,
},
)
return has_moved
def in_proximity(self, named=False) -> models.QuerySet:
lat_min = Decimal(self.lat) - GEOLOC_PROXIMITY
lat_max = Decimal(self.lat) + GEOLOC_PROXIMITY
lon_min = Decimal(self.lon) - GEOLOC_PROXIMITY
lon_max = Decimal(self.lon) + GEOLOC_PROXIMITY
is_title_null = not named
close_locations = GeoLocation.objects.filter(
title__isnull=is_title_null,
lat__lte=lat_max,
lat__gte=lat_min,
lon__lte=lon_max,
lon__gte=lon_min,
).exclude(id=self.id)
return close_locations

View File

@ -0,0 +1,90 @@
import pytest
import logging
from locations.models import GeoLocation
logger = logging.getLogger(__name__)
def test_find_or_create(caplog):
assert not GeoLocation.find_or_create({})
assert "No lat or lon keys in data dict" in caplog.text
@pytest.mark.django_db
def test_find_or_create_truncation():
loc = GeoLocation.find_or_create(
{"lat": 44.2345, "lon": -68.2345, "alt": 60.356}
)
assert loc.lat == 44.234
assert loc.lon == -68.234
assert loc.altitude == 60
@pytest.mark.django_db
def test_find_or_create_finds_existing():
extant = GeoLocation.objects.create(lat=44.234, lon=-68.234, altitude=50)
loc = GeoLocation.find_or_create(
{"lat": 44.2345, "lon": -68.2345, "alt": 60.356}
)
assert loc.id == extant.id
@pytest.mark.django_db
def test_find_or_create_creates_new():
extant = GeoLocation.objects.create(lat=44.234, lon=-69.234, altitude=60)
loc = GeoLocation.find_or_create(
{"lat": 44.2345, "lon": -68.2345, "alt": 60.356}
)
assert not loc.id == extant.id
@pytest.mark.django_db
def test_found_in_proximity_location():
lat = 44.234
lon = -69.234
loc = GeoLocation.objects.create(lat=lat, lon=lon, altitude=60)
close = GeoLocation.objects.create(
lat=lat + 0.0001, lon=lon - 0.0001, altitude=60
)
assert close not in loc.in_proximity(named=True)
assert close in loc.in_proximity()
@pytest.mark.django_db
def test_not_found_in_proximity_location():
lat = 44.234
lon = -69.234
loc = GeoLocation.objects.create(lat=lat, lon=lon, altitude=60)
far = GeoLocation.objects.create(
lat=lat + 0.0002, lon=lon - 0.0001, altitude=60
)
assert far not in loc.in_proximity()
@pytest.mark.django_db
def test_has_moved(caplog):
lat = 44.234
lon = -69.234
loc = GeoLocation.objects.create(lat=lat, lon=lon, altitude=60)
past = GeoLocation.objects.get_or_create(
lat=lat + 0.0009, lon=lon - 0.002, altitude=60
)[0]
assert loc.has_moved(past)
@pytest.mark.django_db
def test_has_not_moved():
lat = 44.234
lon = -69.234
loc = GeoLocation.objects.create(lat=lat, lon=lon, altitude=60)
past = GeoLocation.objects.get_or_create(
lat=lat + 0.00009, lon=lon - 0.00009, altitude=60
)[0]
assert not loc.has_moved(past)

View File

@ -0,0 +1,18 @@
from django.urls import path
from locations import views
app_name = "locations"
urlpatterns = [
path(
"locations/",
views.GeoLocationListView.as_view(),
name="geo_locations_list",
),
path(
"locations/<slug:slug>/",
views.GeoLocationDetailView.as_view(),
name="geo_location_detail",
),
]

View File

@ -0,0 +1,22 @@
from django.db.models import Count
from django.views import generic
from locations.models import GeoLocation
from scrobbles.stats import get_scrobble_count_qs
class GeoLocationListView(generic.ListView):
model = GeoLocation
paginate_by = 75
def get_queryset(self):
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)
context_data["latest"] = self.get_queryset().first()
return context_data
class GeoLocationDetailView(generic.DetailView):
model = GeoLocation
slug_field = "uuid"

View File

View File

@ -0,0 +1,23 @@
from django.contrib import admin
from moods.models import Mood
from scrobbles.admin import ScrobbleInline
class MoodInline(admin.TabularInline):
model = Mood
extra = 0
@admin.register(Mood)
class MoodAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"uuid",
"title",
)
ordering = ("-created",)
search_fields = ("title",)
inlines = [
ScrobbleInline,
]

View File

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

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,79 @@
# Generated by Django 4.2.13 on 2024-08-10 20:01
from django.db import migrations, models
import django_extensions.db.fields
import taggit.managers
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
("scrobbles", "0056_scrobble_life_event_alter_scrobble_media_type"),
]
operations = [
migrations.CreateModel(
name="Mood",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
(
"uuid",
models.UUIDField(
blank=True,
default=uuid.uuid4,
editable=False,
null=True,
),
),
(
"title",
models.CharField(blank=True, max_length=255, null=True),
),
(
"run_time_seconds",
models.IntegerField(blank=True, null=True),
),
(
"run_time_ticks",
models.PositiveBigIntegerField(blank=True, null=True),
),
("description", models.TextField(blank=True, null=True)),
(
"genre",
taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="scrobbles.ObjectWithGenres",
to="scrobbles.Genre",
verbose_name="Tags",
),
),
],
options={
"abstract": False,
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.13 on 2024-08-10 20:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("moods", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="mood",
name="image",
field=models.ImageField(blank=True, null=True, upload_to="moods/"),
),
]

View File

@ -0,0 +1,56 @@
import logging
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.mixins import ScrobblableMixin
from vrobbler.apps.scrobbles.dataclasses import MoodLogData
logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True}
User = get_user_model()
class Mood(ScrobblableMixin):
description = models.TextField(**BNULL)
image = models.ImageField(upload_to="moods/", **BNULL)
image_small = ImageSpecField(
source="thumbnail",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
image_medium = ImageSpecField(
source="thumbnail",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
)
def __str__(self):
if self.title:
return self.title
return str(self.uuid)
def get_absolute_url(self):
return reverse("moods:mood-detail", kwargs={"slug": self.uuid})
def get_start_url(self):
return reverse("scrobbles:start", kwargs={"uuid": self.uuid})
@property
def subtitle(self) -> str:
return ""
@property
def logdata_cls(self):
return MoodLogData
@property
def primary_image_url(self) -> str:
if self.image:
return self.image.url
return ""

View File

@ -0,0 +1,14 @@
from django.urls import path
from moods import views
app_name = "moods"
urlpatterns = [
path("mood/", views.MoodListView.as_view(), name="mood-list"),
path(
"mood/<slug:slug>/",
views.MoodDetailView.as_view(),
name="mood-detail",
),
]

View File

@ -0,0 +1,10 @@
from moods.models import Mood
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
class MoodListView(ScrobbleableListView):
model = Mood
class MoodDetailView(ScrobbleableDetailView):
model = Mood

View File

@ -26,7 +26,9 @@ def scrobble_counts(user=None):
starting_day_of_current_year = now.date().replace(month=1, day=1)
finished_scrobbles_qs = Scrobble.objects.filter(
user_filter, played_to_completion=True
user_filter,
played_to_completion=True,
media_type=Scrobble.MediaType.TRACK,
)
data = {}
data["today"] = finished_scrobbles_qs.filter(

View File

@ -1,3 +1,78 @@
jellyfin_keys = [
"ServerId",
"ServerName",
"ServerVersion",
"ServerUrl",
"NotificationType",
"Timestamp",
"UtcTimestamp",
"Name",
"Overview",
"Tagline",
"ItemId",
"RunTimeTicks",
"RunTime",
"Year",
"Provider_tmdb",
"Provider_imdb",
"Provider_tmdbcollection",
"Provider_tmdbset",
"Video_0_Title",
"Video_0_Type",
"Video_0_Codec",
"Video_0_Profile",
"Video_0_Level",
"Video_0_Height",
"Video_0_Width",
"Video_0_AspectRatio",
"Video_0_Interlaced",
"Video_0_FrameRate",
"Video_0_VideoRange",
"Video_0_ColorSpace",
"Video_0_ColorTransfer",
"Video_0_ColorPrimaries",
"Video_0_PixelFormat",
"Video_0_RefFrames",
"Audio_0_Title",
"Audio_0_Type",
"Audio_0_Language",
"Audio_0_Codec",
"Audio_0_Channels",
"Audio_0_Bitrate",
"Audio_0_SampleRate",
"Audio_0_Default",
"Subtitle_0_Title",
"Subtitle_0_Type",
"Subtitle_0_Language",
"Subtitle_0_Codec",
"Subtitle_0_Default",
"Subtitle_0_Forced",
"Subtitle_0_External",
"Subtitle_1_Title",
"Subtitle_1_Type",
"Subtitle_1_Language",
"Subtitle_1_Codec",
"Subtitle_1_Default",
"Subtitle_1_Forced",
"Subtitle_1_External",
"Subtitle_2_Title",
"Subtitle_2_Type",
"Subtitle_2_Language",
"Subtitle_2_Codec",
"Subtitle_2_Default",
"Subtitle_2_Forced",
"Subtitle_2_External",
"PlaybackPositionTicks",
"PlaybackPosition",
"MediaSourceId",
"IsPaused",
"IsAutomated",
"DeviceId",
"DeviceName",
"ClientName",
"NotificationUsername",
"UserId",
]
VARIOUS_ARTIST_DICT = {
"name": "Various Artists",
"theaudiodb_id": "113641",
@ -6,10 +81,13 @@ VARIOUS_ARTIST_DICT = {
JELLYFIN_POST_KEYS = {
"ITEM_TYPE": "ItemType",
"ITEM_ID": "ItemId",
"RUN_TIME": "RunTime",
"TITLE": "Name",
"TRACK_TITLE": "Name",
"TIMESTAMP": "UtcTimestamp",
"YEAR": "Year",
"OVERVIEW": "Overview",
"TAGLINE": "TAGLINE",
"PLAYBACK_POSITION_TICKS": "PlaybackPositionTicks",
"PLAYBACK_POSITION": "PlaybackPosition",
"ARTIST_MB_ID": "Provider_musicbrainzartist",
@ -18,4 +96,25 @@ JELLYFIN_POST_KEYS = {
"TRACK_MB_ID": "Provider_musicbrainztrack",
"ALBUM_NAME": "Album",
"ARTIST_NAME": "Artist",
"STATUS": "Status",
"SOURCE": "ClientName",
"VIDEO_TITLE": "Name",
"IMDB_ID": "Provider_imdb",
"TMDB_ID": "Provider_tmdb",
}
MOPIDY_POST_KEYS = {
"ITEM_TYPE": None,
"RUN_TIME": "run_time",
"TRACK_TITLE": "name",
"TIMESTAMP": None,
"YEAR": None,
"PLAYBACK_POSITION_TICKS": "playback_time_ticks",
"ARTIST_MB_ID": "muscibrainz_artist_id",
"ALBUM_MB_ID": "musicbrainz_album_id",
"RELEASEGROUP_MB_ID": None,
"TRACK_MB_ID": "musicbrainz_track_id",
"ALBUM_NAME": "album",
"ARTIST_NAME": "artist",
"STATUS": "status",
}

View File

@ -46,7 +46,6 @@ class LastFM:
new_scrobbles = []
source = "Last.fm"
source_id = ""
lastfm_scrobbles = self.get_last_scrobbles(time_from=last_processed)
for lfm_scrobble in lastfm_scrobbles:
@ -60,14 +59,19 @@ class LastFM:
lfm_scrobble["album"] = album
track = get_or_create_track(**lfm_scrobble)
timezone = settings.TIME_ZONE
if self.vrobbler_user.profile:
timezone = self.vrobbler_user.profile.timezone
new_scrobble = Scrobble(
user=self.vrobbler_user,
timestamp=timestamp,
source=source,
source_id=source_id,
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)
@ -107,6 +111,7 @@ class LastFM:
# 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
@ -121,6 +126,10 @@ class LastFM:
logger.warn(
"LastFM barfed trying to get the track for {scrobble.track}"
)
except pylast.NetworkError as e:
logger.warn(
"LastFM barfed trying to get the track for {scrobble.track}"
)
if not artist:
logger.warn(f"Silly LastFM, no artist found for {scrobble}")

View File

@ -0,0 +1,26 @@
# Generated by Django 4.1.7 on 2023-04-18 02:33
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0040_alter_scrobble_media_type"),
("music", "0022_artist_theaudiodb_id"),
]
operations = [
migrations.AlterField(
model_name="track",
name="genre",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="scrobbles.ObjectWithGenres",
to="scrobbles.Genre",
verbose_name="Tags",
),
),
]

View File

@ -12,6 +12,8 @@ from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django_extensions.db.models import TimeStampedModel
from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit
from music.allmusic import get_allmusic_slug, scrape_data_from_allmusic
from music.bandcamp import get_bandcamp_slug
from music.theaudiodb import lookup_album_from_tadb, lookup_artist_from_tadb
@ -32,6 +34,18 @@ class Artist(TimeStampedModel):
allmusic_id = models.CharField(max_length=100, **BNULL)
bandcamp_id = models.CharField(max_length=100, **BNULL)
thumbnail = models.ImageField(upload_to="artist/", **BNULL)
thumbnail_small = ImageSpecField(
source="thumbnail",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
thumbnail_medium = ImageSpecField(
source="thumbnail",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
)
class Meta:
unique_together = [["name", "musicbrainz_id"]]
@ -39,6 +53,14 @@ class Artist(TimeStampedModel):
def __str__(self):
return self.name
@property
def primary_image_url(self) -> str:
if self.thumbnail:
return self.thumbnail.url
if self.album_set.first().cover_image:
return self.album_set.first().cover_image.url
return ""
@property
def mb_link(self):
return f"https://musicbrainz.org/artist/{self.musicbrainz_id}"
@ -96,10 +118,18 @@ class Artist(TimeStampedModel):
self.bandcamp_id = slug
self.save(update_fields=["bandcamp_id"])
def fix_metadata(self):
tadb_info = lookup_artist_from_tadb(self.name)
def fix_metadata(self, force_update=False):
tadb_info = {}
if self.theaudiodb_id and force_update:
tadb_info = lookup_artist_from_tadb(self.theaudiodb_id)
if not self.theaudiodb_id or (force_update and not tadb_info):
tadb_info = lookup_artist_from_tadb(self.name)
if not tadb_info:
logger.warn(f"No response from TADB for artist {self.name}")
logger.warn(
f"No response from TADB for artist {self.name}, try force_update=True"
)
return
self.biography = tadb_info["biography"]
@ -136,6 +166,18 @@ class Album(TimeStampedModel):
musicbrainz_releasegroup_id = models.CharField(max_length=255, **BNULL)
musicbrainz_albumartist_id = models.CharField(max_length=255, **BNULL)
cover_image = models.ImageField(upload_to="albums/", **BNULL)
cover_image_small = ImageSpecField(
source="cover_image",
processors=[ResizeToFit(100, 100)],
format="JPEG",
options={"quality": 60},
)
cover_image_medium = ImageSpecField(
source="cover_image",
processors=[ResizeToFit(300, 300)],
format="JPEG",
options={"quality": 75},
)
theaudiodb_id = models.CharField(max_length=255, unique=True, **BNULL)
theaudiodb_description = models.TextField(**BNULL)
theaudiodb_year_released = models.IntegerField(**BNULL)
@ -168,6 +210,12 @@ class Album(TimeStampedModel):
track__in=self.track_set.all()
).order_by("-timestamp")
@property
def primary_image_url(self) -> str:
if self.cover_image:
return self.cover_image_medium.url
return ""
@property
def tracks(self):
return (
@ -356,7 +404,7 @@ class Album(TimeStampedModel):
class Track(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, "MUSIC_COMPLETION_PERCENT", 90)
COMPLETION_PERCENT = getattr(settings, "MUSIC_COMPLETION_PERCENT", 100)
class Opinion(models.IntegerChoices):
DOWN = -1, "Thumbs down"
@ -392,9 +440,9 @@ class Track(ScrobblableMixin):
def primary_image_url(self) -> str:
url = ""
if self.artist.thumbnail:
url = self.artist.thumbnail.url
url = self.artist.thumbnail_medium.url
if self.album and self.album.cover_image:
url = self.album.cover_image.url
url = self.album.cover_image_medium.url
return url
@classmethod

View File

@ -1,4 +1,5 @@
import logging
from typing import Iterable
import musicbrainzngs
from dateutil.parser import parse
@ -52,10 +53,18 @@ def lookup_album_from_mb(musicbrainz_id: str) -> dict:
def lookup_album_dict_from_mb(release_name: str, artist_name: str) -> dict:
musicbrainzngs.set_useragent("vrobbler", "0.3.0")
top_result = musicbrainzngs.search_releases(
release_name, artist=artist_name
)["release-list"][0]
score = int(top_result.get("ext:score"))
top_result = {}
try:
top_result = musicbrainzngs.search_releases(
release_name, artist=artist_name
)["release-list"][0]
except IndexError:
logger.info(
f"No release found on MB for {artist_name} and {release_name}"
)
score = int(top_result.get("ext:score", 0))
if score < 85:
logger.debug(
"Album lookup score below 85 threshold",
@ -74,37 +83,58 @@ def lookup_album_dict_from_mb(release_name: str, artist_name: str) -> dict:
}
def lookup_artist_from_mb(artist_name: str) -> str:
def lookup_artist_from_mb(artist_name: str) -> dict:
musicbrainzngs.set_useragent("vrobbler", "0.3.0")
top_result = musicbrainzngs.search_artists(artist=artist_name)[
"artist-list"
][0]
try:
top_result = musicbrainzngs.search_artists(artist=artist_name)[
"artist-list"
][0]
except IndexError:
return {}
score = int(top_result.get("ext:score"))
if score < 85:
logger.debug(
"Artist lookup score below 85 threshold",
extra={"result": top_result},
)
return ""
return {}
return top_result
def lookup_track_from_mb(
track_name: str, artist_mbid: str, album_mbid: str
) -> str:
track_name: str, artist_mb_id: str, album_mb_id: str
) -> dict:
logger.info(
"[lookup_track_from_mb] called",
extra={
"track_name": track_name,
"artist_mb_id": artist_mb_id,
"album_mb_id": album_mb_id,
},
)
musicbrainzngs.set_useragent("vrobbler", "0.3.0")
top_result = musicbrainzngs.search_recordings(
query=track_name, artist=artist_mbid, release=album_mbid
)["recording-list"][0]
try:
results = musicbrainzngs.search_recordings(
query=track_name, artist=artist_mb_id, release=album_mb_id
)["recording-list"]
logger.info(
"[lookup_track_from_mb] musicbrainz recordings search results",
extra={"results": results},
)
top_result = results[0]
except IndexError:
logger.error("[lookup_track_from_mb] no results found")
return {}
score = int(top_result.get("ext:score"))
if score < 85:
logger.debug(
"Track lookup score below 85 threshold",
extra={"result": top_result},
logger.info(
"[lookup_track_from_mb] no results above 85% certainty ",
extra={"results": results},
)
return ""
return {}
return top_result

View File

@ -7,23 +7,48 @@ from django.conf import settings
THEAUDIODB_API_KEY = getattr(settings, "THEAUDIODB_API_KEY")
ARTIST_SEARCH_URL = f"https://www.theaudiodb.com/api/v1/json/{THEAUDIODB_API_KEY}/search.php?s="
ARTIST_FETCH_URL = f"https://www.theaudiodb.com/api/v1/json/{THEAUDIODB_API_KEY}/artist.php?i="
ALBUM_SEARCH_URL = f"https://www.theaudiodb.com/api/v1/json/{THEAUDIODB_API_KEY}/searchalbum.php?s="
logger = logging.getLogger(__name__)
def lookup_artist_from_tadb(name: str) -> dict:
def lookup_artist_from_tadb(name_or_id: str) -> dict:
artist_info = {}
name = urllib.parse.quote(name)
response = requests.get(ARTIST_SEARCH_URL + name)
response = None
# Look for an ID as an integer
try:
tadb_id = int(name_or_id)
except ValueError:
tadb_id = None
if tadb_id:
response = requests.get(ARTIST_FETCH_URL + str(tadb_id))
if response.status_code != 200:
logger.warn(f"Bad response from TADB: {response.status_code}")
return artist_info
if not response.content:
logger.warn(f"Bad content from TADB: {response.content}")
return artist_info
if not response:
name = urllib.parse.quote(name_or_id)
response = requests.get(ARTIST_SEARCH_URL + name)
if response.status_code != 200:
logger.warn(f"Bad response from TADB: {response.status_code}")
return {}
return artist_info
if not response.content:
logger.warn(f"Bad content from TADB: {response.content}")
return {}
return artist_info
if '{"artists": null}' in str(response.content):
logger.warn(f"Bad content from TADB: {response.content}")
return artist_info
results = json.loads(response.content)
if results["artists"]:

View File

@ -8,6 +8,7 @@ from music.musicbrainz import (
lookup_track_from_mb,
)
from music.constants import VARIOUS_ARTIST_DICT
from scrobbles.utils import convert_to_seconds
logger = logging.getLogger(__name__)
@ -26,9 +27,10 @@ def get_or_create_artist(name: str, mbid: str = None) -> Artist:
name = re.split("&", name, flags=re.IGNORECASE)[0].strip()
artist_dict = lookup_artist_from_mb(name)
mbid = mbid or artist_dict["id"]
mbid = mbid or artist_dict.get("id", None)
artist = Artist.objects.filter(musicbrainz_id=mbid).first()
if mbid:
artist = Artist.objects.filter(musicbrainz_id=mbid).first()
if not artist:
artist = Artist.objects.create(name=name, musicbrainz_id=mbid)
artist.fix_metadata()
@ -42,20 +44,34 @@ def get_or_create_album(
album = None
album_dict = lookup_album_dict_from_mb(name, artist_name=artist.name)
name = name or album_dict["title"]
album = Album.objects.filter(artists__in=[artist], name=name).first()
name = name or album_dict.get("title", None)
if not name:
logger.debug(
f"Cannot get or create album by {artist} with no name ({name})"
)
return
album = Album.objects.filter(
musicbrainz_id=mbid, name=name, artists__in=[artist]
).first()
if not album:
mbid_group = album_dict.get("mb_group_id")
album = Album.objects.filter(
musicbrainz_releasegroup_id=mbid_group
).first()
if not album and name:
mbid = mbid or album_dict["mb_id"]
album, album_created = Album.objects.get_or_create(
name=name, musicbrainz_id=mbid
)
album, album_created = Album.objects.get_or_create(musicbrainz_id=mbid)
if album_created:
album.name = name
album.year = album_dict["year"]
album.musicbrainz_releasegroup_id = album_dict["mb_group_id"]
album.musicbrainz_albumartist_id = artist.musicbrainz_id
album.save(
update_fields=[
"name",
"musicbrainz_id",
"year",
"musicbrainz_releasegroup_id",
@ -74,32 +90,55 @@ def get_or_create_album(
return album
def get_or_create_track(
title: str,
artist: Artist,
album: Album,
mbid: str = None,
run_time_seconds=None,
) -> Track:
track = None
if not mbid:
mbid = lookup_track_from_mb(
title,
artist.musicbrainz_id,
album.musicbrainz_id,
)["id"]
def get_or_create_track(post_data: dict, post_keys: dict) -> Track:
try:
track_run_time_seconds = int(
post_data.get(post_keys.get("RUN_TIME"), 0)
)
except ValueError: # Sometimes we get run time as a string like "01:35"
track_run_time_seconds = convert_to_seconds(
post_data.get(post_keys.get("RUN_TIME"), 0)
)
track = Track.objects.filter(musicbrainz_id=mbid).first()
artist_name = post_data.get(post_keys.get("ARTIST_NAME"), "")
artist_mb_id = post_data.get(post_keys.get("ARTIST_MB_ID"), "")
album_title = post_data.get(post_keys.get("ALBUM_NAME"), "")
album_mb_id = post_data.get(post_keys.get("ALBUM_MB_ID"), "")
track_title = post_data.get(post_keys.get("TRACK_TITLE"), "")
track_mb_id = post_data.get(post_keys.get("TRACK_MB_ID"), "")
artist = get_or_create_artist(
artist_name,
mbid=artist_mb_id,
)
album = get_or_create_album(
album_title,
artist=artist,
mbid=album_mb_id,
)
track = None
if not track_mb_id and album:
try:
track_mb_id = lookup_track_from_mb(
track_title,
artist.musicbrainz_id,
album.musicbrainz_id,
).get("id", 0)
except TypeError:
pass
if track_mb_id:
track = Track.objects.filter(musicbrainz_id=track_mb_id).first()
if not track:
track = Track.objects.create(
title=title,
title=track_title,
artist=artist,
album=album,
musicbrainz_id=mbid,
run_time_seconds=run_time_seconds,
musicbrainz_id=track_mb_id,
run_time_seconds=track_run_time_seconds,
)
return track

View File

@ -4,20 +4,15 @@ from music.models import Album, Artist, Track
from scrobbles.models import ChartRecord
from scrobbles.stats import get_scrobble_count_qs
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
class TrackListView(generic.ListView):
class TrackListView(ScrobbleableListView):
model = Track
paginate_by = 200
def get_queryset(self):
return get_scrobble_count_qs(user=self.request.user).order_by(
"-scrobble_count"
)
class TrackDetailView(generic.DetailView):
class TrackDetailView(ScrobbleableDetailView):
model = Track
slug_field = "uuid"
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)

View File

@ -1,5 +1,5 @@
from django.contrib import admin
from podcasts.models import Episode, Podcast, Producer
from podcasts.models import PodcastEpisode, Podcast, Producer
from scrobbles.admin import ScrobbleInline
@ -21,8 +21,8 @@ class PodcastAdmin(admin.ModelAdmin):
ordering = ("name",)
@admin.register(Episode)
class EpisodeAdmin(admin.ModelAdmin):
@admin.register(PodcastEpisode)
class PodcastEpisodeAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"title",

View File

@ -0,0 +1,26 @@
# Generated by Django 4.1.7 on 2023-04-18 02:33
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0040_alter_scrobble_media_type"),
("podcasts", "0011_rename_url_podcast_feed_url_and_more"),
]
operations = [
migrations.AlterField(
model_name="episode",
name="genre",
field=taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="scrobbles.ObjectWithGenres",
to="scrobbles.Genre",
verbose_name="Tags",
),
),
]

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