Compare commits

...

158 Commits

Author SHA1 Message Date
ec73e5151e [release] Bump to version 55.0
All checks were successful
build / test (push) Successful in 1m58s
deploy / test (push) Successful in 1m57s
deploy / build-and-deploy (push) Successful in 37s
- Use pk ID for scrobble detail view, not uuid
- Display videogame screenshots on scrobble detail if they exist
- Add autotagging to webpages based on domain, title
2026-06-18 12:15:16 -04:00
2c90dd38b5 [project] Add todos 2026-06-18 12:14:58 -04:00
c6b1e42d7a [scrobbles] Use IDs not UUIDs in URLs
All checks were successful
build / test (push) Successful in 1m59s
2026-06-18 11:25:57 -04:00
fcf86d5b3f [scrobbles] Add screenshots to templates
All checks were successful
build / test (push) Successful in 2m6s
2026-06-18 10:54:28 -04:00
6fde9ec8d2 [webpages] Add autotagging to webpages
All checks were successful
build / test (push) Successful in 1m59s
2026-06-18 10:43:21 -04:00
0f1882b21f [release] Bump to version 54.5
All checks were successful
build / test (push) Successful in 2m7s
deploy / build-and-deploy (push) Has been skipped
deploy / test (push) Successful in 1m58s
- Fix bug in generating mood trends
2026-06-17 21:46:21 -04:00
e819a2db0d [trends] Fix bug in mood trend generation 2026-06-17 21:45:45 -04:00
e03cf6c9b1 [release] Bump to version 54.4
All checks were successful
build / test (push) Successful in 2m0s
deploy / test (push) Successful in 1m58s
deploy / build-and-deploy (push) Successful in 29s
- Remove all-time trends
- Add a trend around moods
2026-06-17 17:09:30 -04:00
471e70ff7f [trends] Remove all time trends
Some checks failed
build / test (push) Has been cancelled
2026-06-17 17:09:11 -04:00
255e335d7a [trends] Add some mood related trends 2026-06-17 17:07:16 -04:00
c8cf80b513 [release] Bump to version 54.3
All checks were successful
deploy / test (push) Successful in 2m1s
build / test (push) Successful in 1m52s
deploy / build-and-deploy (push) Successful in 29s
- Fix bug in series metadata cleanup script
2026-06-17 12:05:50 -04:00
b4180afbed [videos] Fix metadata for series script
Some checks failed
build / test (push) Has been cancelled
2026-06-17 12:05:22 -04:00
37112babbb [release] Bump to version 54.2
All checks were successful
build / test (push) Successful in 1m59s
deploy / test (push) Successful in 1m56s
deploy / build-and-deploy (push) Successful in 30s
- Add script to clean up TV series metadata
- Update youtube video detail pages with links to channel
- Concurrent reading trend does not consolidate on single book
- Trends dont seem to look very far back
2026-06-17 11:06:11 -04:00
fb775f2f58 [videos] Add cmd to cleanup series metadata
Some checks failed
build / test (push) Has been cancelled
2026-06-17 11:05:38 -04:00
b26470c279 [videos] Fix channel templates
All checks were successful
build / test (push) Successful in 2m0s
2026-06-17 10:59:42 -04:00
d3b9ec815b [trends] Fix concurrent reading trend
All checks were successful
build / test (push) Successful in 2m14s
2026-06-17 10:50:59 -04:00
19f2b5e801 [trends] Add time periods 2026-06-17 10:50:16 -04:00
9e3288a5ff [release] Bump to version 54.1
All checks were successful
build / test (push) Successful in 2m0s
deploy / test (push) Successful in 1m58s
deploy / build-and-deploy (push) Successful in 35s
- Concurrent listening trend is inefficient and should be disabled
2026-06-17 09:21:20 -04:00
06465919dd [trends] Disable concurrent listening
Some checks failed
build / test (push) Has been cancelled
2026-06-17 09:20:29 -04:00
253e58eb48 [release] Bump to version 54.0
All checks were successful
build / test (push) Successful in 1m50s
deploy / test (push) Successful in 1m57s
deploy / build-and-deploy (push) Successful in 49s
- Add peak hour, weekly rhythm and activity dist trends
- Implement YouTube channel info scraping
- Fix Amazon book scraper
2026-06-16 16:42:44 -04:00
5393996e47 [trends] Add peak hours, weekly rhtyhms and activity dist trends
Some checks failed
build / test (push) Has been cancelled
2026-06-16 16:42:14 -04:00
1624f01e11 [videos] Increase metdata for Youtube
All checks were successful
build / test (push) Successful in 2m1s
2026-06-16 15:54:16 -04:00
535dead7e8 [videos] Clean up channel metadata
All checks were successful
build / test (push) Successful in 1m54s
2026-06-16 13:49:32 -04:00
3b97d49227 [books] Fix amazon scrapper to use actual AWS endpoints
All checks were successful
build / test (push) Successful in 2m2s
2026-06-16 13:18:20 -04:00
ea7b0946bb [release] Bump to version 53.1
All checks were successful
deploy / test (push) Successful in 1m58s
build / test (push) Successful in 1m56s
deploy / build-and-deploy (push) Successful in 34s
- Error with loading logdict
2026-06-16 11:31:14 -04:00
b8384166de [music] Fix logdata lookup for music
Some checks failed
build / test (push) Has been cancelled
2026-06-16 11:30:54 -04:00
d2705758c6 [release] Bump to version 53.0
All checks were successful
build / test (push) Successful in 1m57s
deploy / test (push) Successful in 1m59s
deploy / build-and-deploy (push) Successful in 52s
- Add a /trends/ page that shows trends based on scrobble data
- Notify users when Last.fm import completes
- Cleaner =GeoLocationLogData= deserialization
- Webpage scrobbles should diff existing webpages content
- Make ArchiveBox push asynchronous
2026-06-16 11:13:07 -04:00
f4368c31f3 [project] Update todos
Some checks failed
build / test (push) Has been cancelled
2026-06-16 11:12:50 -04:00
57f273b0cc [trends] Initial trends app
Some checks failed
build / test (push) Has been cancelled
2026-06-16 11:12:13 -04:00
ac82292200 [importers] Add last.fm import notifications 2026-06-16 10:26:08 -04:00
6a8432c08f [locations] Handle log data cleaner
All checks were successful
build / test (push) Successful in 1m54s
2026-06-16 10:12:58 -04:00
5a2c41155c [webpages] Add historical extract stashing
All checks were successful
build / test (push) Successful in 1m50s
2026-06-16 09:52:56 -04:00
83a046111b [webpages] Async pushing to archivebox
All checks were successful
build / test (push) Successful in 1m52s
2026-06-16 09:27:40 -04:00
ab10758f40 [release] Bump to version 52.2
All checks were successful
build / test (push) Successful in 1m57s
deploy / test (push) Successful in 1m56s
deploy / build-and-deploy (push) Successful in 50s
- Fix bug in recomputing long play seconds taking forever
2026-06-15 17:27:38 -04:00
88f16f0aaa [longplay] Fix recompute script
All checks were successful
build / test (push) Successful in 2m0s
2026-06-15 17:20:34 -04:00
c1744fab37 [release] Bump to version 52.1
All checks were successful
build / test (push) Successful in 1m56s
deploy / test (push) Successful in 2m1s
deploy / build-and-deploy (push) Successful in 29s
- Show time per scrobble in long play lists and total time playing
2026-06-15 15:27:10 -04:00
042a3eb737 [templates] Add aggregate data
Some checks failed
build / test (push) Has been cancelled
2026-06-15 15:26:46 -04:00
01d25e1b55 [release] Bump to version 52.0
All checks were successful
build / test (push) Successful in 1m55s
deploy / test (push) Successful in 2m1s
deploy / build-and-deploy (push) Successful in 31s
- Allow marking media as long play complete from detail page
- Fix how long play scrobbles are tracked
- Paginate or limite scrobbles on media admin pages
- Clean up books admin
- Clean up favorites admin
2026-06-15 14:56:27 -04:00
c0be131e3d [longplay] Add ability to undo finishes
Some checks failed
build / test (push) Has been cancelled
2026-06-15 14:55:58 -04:00
7d3f615ed7 [longplay] Make sure they're marked status is correct
All checks were successful
build / test (push) Successful in 2m2s
2026-06-15 14:37:20 -04:00
c2138b3ac6 [longplay] Add finish long play button
All checks were successful
build / test (push) Successful in 1m53s
2026-06-15 14:15:34 -04:00
947713d44a [longplay] Fix how we store long plays
All checks were successful
build / test (push) Successful in 2m3s
2026-06-15 14:12:21 -04:00
12b76837a3 [project] Update todos
All checks were successful
build / test (push) Successful in 2m11s
2026-06-15 13:43:58 -04:00
102494ede7 [admin] Use raw ids where possible and simplify scrobble inlines
All checks were successful
build / test (push) Successful in 1m55s
2026-06-15 13:25:10 -04:00
96bda8d4ad [data] Add example data 2026-06-15 12:38:21 -04:00
46956d06d8 [books] Clean up admin a little 2026-06-15 12:37:32 -04:00
8a28d0675b [release] Bump to version 51.4
All checks were successful
build / test (push) Successful in 1m56s
deploy / test (push) Successful in 1m57s
deploy / build-and-deploy (push) Successful in 38s
- Clean up metadata comicbook enrichment
2026-06-15 12:20:18 -04:00
5f6e75b14e [books] Fix comic book metadata importing
Some checks failed
build / test (push) Has been cancelled
2026-06-15 12:19:54 -04:00
a96a42cdbf [release] Bump to version 51.3
All checks were successful
build / test (push) Successful in 2m12s
deploy / test (push) Successful in 2m6s
deploy / build-and-deploy (push) Successful in 1m5s
- Improve speed of index and chart pages
2026-06-12 13:35:01 -04:00
c7f5d7d384 [charts] Add index to speed things up 2026-06-12 13:34:41 -04:00
d5830f5cd1 [release] Bump to version 51.2
All checks were successful
build / test (push) Successful in 2m14s
deploy / test (push) Successful in 2m15s
deploy / build-and-deploy (push) Successful in 1m27s
- Fix bug where last page of book gets separate scrobble
- Fix metadata scraping for books
2026-06-12 09:52:56 -04:00
c71b51fdb8 [books] Fix one-extra-scrobble bug on Koreader imports
Some checks failed
build / test (push) Has been cancelled
2026-06-12 09:52:33 -04:00
935d059a20 [books] Fix metadata scrapping
All checks were successful
build / test (push) Successful in 2m30s
It's not perfect, but at least we get covers now
2026-06-12 09:36:21 -04:00
25776eb495 [release] Bump to version 51.1
All checks were successful
build / test (push) Successful in 2m55s
deploy / test (push) Successful in 2m38s
deploy / build-and-deploy (push) Successful in 44s
- Fix scrobbling comic books
2026-06-11 19:07:00 -04:00
5ac4625af9 [books] Fix bug in scrobbling comic books
Some checks failed
build / test (push) Has been cancelled
2026-06-11 19:06:37 -04:00
a731427f6e [release] Bump to version 51.0
All checks were successful
build / test (push) Successful in 2m52s
deploy / test (push) Successful in 2m25s
deploy / build-and-deploy (push) Successful in 49s
- Fix koreader scrobble imports to use DST properly
- Fix book scrobbles where page_data is a list
- Lichess imports do not set default visbility
2026-06-11 18:39:31 -04:00
410da163fe [books] Fix koreader imports, maybe forever 2026-06-11 18:38:51 -04:00
a171192a6f [books] Fix list page_data for old book scrobbles
All checks were successful
build / test (push) Successful in 2m2s
2026-06-11 11:00:04 -04:00
c16b61db40 [importers] Fix strange bug where celery version mismatches
All checks were successful
build / test (push) Successful in 2m9s
2026-06-11 10:48:38 -04:00
29cb6a4991 [books] Really fix the subtitle bug 2026-06-11 10:48:21 -04:00
25c28e8335 [books] Fix bug in subtitle gen
All checks were successful
build / test (push) Successful in 2m5s
2026-06-11 10:34:20 -04:00
25626be3b6 [boardgames] Add visibility to lichess imports 2026-06-11 10:02:37 -04:00
0a880a2f2f [release] Bump to version 50.2
All checks were successful
build / test (push) Successful in 2m6s
deploy / test (push) Successful in 2m7s
deploy / build-and-deploy (push) Successful in 35s
- Koreader imports only import single-page scrobbles the next day
- Fix bugs in celery tasks causing imports to fail
2026-06-11 09:58:56 -04:00
248d3f2d3e [settings] Check webdav every two minuts
All checks were successful
build / test (push) Successful in 2m13s
2026-06-11 09:41:50 -04:00
e243fec679 [books] Try fixing the one-off import issue 2026-06-11 09:41:19 -04:00
de9b4ee9c1 [release] Bump to version 50.1
All checks were successful
build / test (push) Successful in 2m2s
deploy / test (push) Successful in 1m57s
deploy / build-and-deploy (push) Successful in 32s
- Fix bug in charts where only #1 is displayed
2026-06-09 22:08:48 -04:00
bf9a6a9679 [charts] Fix only seeing first top media instance 2026-06-09 22:08:15 -04:00
709fed5cfe [release] Bump to version 50.0
All checks were successful
build / test (push) Successful in 2m7s
deploy / test (push) Successful in 2m6s
deploy / build-and-deploy (push) Successful in 31s
- Allow updating all a user's scrobble visibility at once
- Replace columsn of Top Artists, Tracks and Series with Maloja widget
2026-06-09 17:19:10 -04:00
b7df6299d0 [sharing] Add bulk scrobble share management 2026-06-09 17:18:51 -04:00
be16d513ef [charts] Add better chart views per Maloja
All checks were successful
build / test (push) Successful in 2m3s
2026-06-09 17:04:59 -04:00
15d27f6d94 [release] Bump to version 49.1
All checks were successful
build / test (push) Successful in 2m7s
deploy / test (push) Successful in 2m3s
deploy / build-and-deploy (push) Successful in 33s
- Fix bug with missing default visbility for scrobbles
2026-06-09 13:16:53 -04:00
c8292d1c06 [scrobbles] Back fill visibility field
Some checks failed
build / test (push) Has been cancelled
2026-06-09 13:15:57 -04:00
68f821fce1 [release] Bump to version 49.0
Some checks failed
build / test (push) Successful in 1m59s
deploy / test (push) Successful in 2m9s
deploy / build-and-deploy (push) Failing after 2m20s
- Fix broken tests with new sharing and add tests
2026-06-09 12:48:22 -04:00
ed2ed59f65 [scrobbles] Fix tests around visbility 2026-06-09 12:48:01 -04:00
17a7bb52fa [release] Bump to version 48.3
Some checks failed
build / test (push) Failing after 1m39s
deploy / test (push) Failing after 1m39s
deploy / build-and-deploy (push) Has been skipped
- Fix bug in missing sqids dep
2026-06-09 12:37:40 -04:00
bbac142b40 [deps] Add sqids
Some checks failed
build / test (push) Has been cancelled
2026-06-09 12:37:19 -04:00
5f55ec557f [release] Bump to version 48.2
Some checks failed
build / test (push) Failing after 1m21s
deploy / test (push) Failing after 1m18s
deploy / build-and-deploy (push) Has been skipped
- Lock down scrobbles and use sqids to share them
2026-06-09 12:33:50 -04:00
7f3076608f [scrobbles] Add sharing of scrobbles
Some checks failed
build / test (push) Has been cancelled
2026-06-09 12:33:25 -04:00
568772a0e6 [release] Bump to version 48.1
All checks were successful
deploy / test (push) Successful in 2m0s
build / test (push) Successful in 1m57s
deploy / build-and-deploy (push) Successful in 35s
- Generate a report of tracks with mistmatched metadata
- Date parsing failing in eBird imports
2026-06-08 11:18:08 -04:00
91c3376256 [music] Add mgmt command to see mismatched metadata
Some checks failed
build / test (push) Has been cancelled
2026-06-08 11:17:36 -04:00
58639c6fc1 [importers] Add error_log to surface import errors
All checks were successful
build / test (push) Successful in 2m2s
2026-06-08 10:58:02 -04:00
228441ddc5 [importers] Fix parsing of dates in ebird files 2026-06-08 10:52:50 -04:00
6341075f07 [agent] Add some notes about how we track tasks 2026-06-08 10:52:32 -04:00
a135b9f5f2 [project] Update format for PROJECT file
All checks were successful
build / test (push) Successful in 1m58s
2026-06-07 11:10:30 -04:00
9088412d1e [release] Bump to version 48.0
All checks were successful
build / test (push) Successful in 2m1s
deploy / test (push) Successful in 2m2s
deploy / build-and-deploy (push) Successful in 33s
- Show team or player images on sport detail and scrobble detail
- Add fix_metadta method to Video instances
2026-06-07 11:06:58 -04:00
c7339fbe31 [templates] Clean up how str and subtitles work 2026-06-07 11:06:30 -04:00
4ce3dc03c5 [videos] Add fix_metadata for videos 2026-06-07 10:13:55 -04:00
5a4ef678a8 [release] Bump to version 47.2
All checks were successful
build / test (push) Successful in 1m58s
deploy / test (push) Successful in 1m58s
deploy / build-and-deploy (push) Successful in 53s
- Add OMDB source as backup when TMDB returns nothing
2026-06-07 09:59:05 -04:00
5ca22efeaa [videos] Fix full metadata from OMDB
We were missing the series and episode number info.
2026-06-07 09:58:28 -04:00
912ea8bfac [videos] Add OMDB enrichment when TMDb fails 2026-06-07 09:47:52 -04:00
b541e1084d [release] Bump to version 47.1
All checks were successful
build / test (push) Successful in 2m0s
deploy / test (push) Successful in 2m1s
deploy / build-and-deploy (push) Successful in 32s
- Untangle the sports migrations errors
2026-06-06 23:47:25 -04:00
c9b9da4abc [sports] Fix migrations 2026-06-06 23:47:10 -04:00
8236f43026 [release] Bump to version 47.0
Some checks failed
build / test (push) Successful in 2m4s
deploy / test (push) Successful in 2m7s
deploy / build-and-deploy (push) Failing after 46s
- Change sports scrobbling a bit
2026-06-06 23:35:30 -04:00
ea1b43d1b8 [sports] Big sports structure revamp
Some checks failed
build / test (push) Has been cancelled
This should make scrobbling sports more like tasks.

The root scrobbled items are a little more generic, but
it's easier to see viewing patterns.
2026-06-06 23:32:21 -04:00
4bf22c96e9 [release] Bump to version 46.0
All checks were successful
build / test (push) Successful in 1m57s
deploy / test (push) Successful in 2m9s
deploy / build-and-deploy (push) Successful in 43s
- Add sentiment parsing for Scrobbles with notes
2026-06-05 19:42:21 -04:00
dec7a79509 [scrobbles] Add basic sentiment analysis
All checks were successful
build / test (push) Successful in 2m4s
2026-06-05 19:35:45 -04:00
371e1d654c [tooling] Release in one step
All checks were successful
build / test (push) Successful in 1m57s
2026-06-05 14:58:35 -04:00
bef7e683c5 [release] Bump to version 45.1
All checks were successful
build / test (push) Successful in 1m58s
deploy / test (push) Successful in 2m2s
deploy / build-and-deploy (push) Successful in 31s
- Mopidy favorites or monthly playlist adds should look at all scrobbles
2026-06-05 14:42:11 -04:00
ec219ef3ea [tracks] Fix adding tracks without mopidy_uri 2026-06-05 14:41:37 -04:00
dcc7229e90 [tooling] Just release does it all now
All checks were successful
build / test (push) Successful in 1m57s
2026-06-05 14:06:45 -04:00
73665ef19e [release] Bump to version 45.0
All checks were successful
build / test (push) Successful in 2m2s
deploy / test (push) Successful in 2m7s
deploy / build-and-deploy (push) Successful in 33s
- Add ability to add mopidy tracks to Monthly playlists
2026-06-05 13:57:06 -04:00
2536e330af [tracks] Use todays date for creating monthly playlists
All checks were successful
build / test (push) Successful in 2m3s
2026-06-05 13:41:30 -04:00
99c056adeb [tracks] Allow adding tracks to monthly playlists 2026-06-05 13:29:49 -04:00
7a504e45de [release] Bump to version 44.0
All checks were successful
build / test (push) Successful in 1m56s
deploy / test (push) Successful in 2m1s
deploy / build-and-deploy (push) Successful in 44s
- Add favorite feature for scrobbles
2026-06-05 11:26:39 -04:00
7618d0ba30 [tooling] Add full push back to justfile
Some checks failed
build / test (push) Has been cancelled
2026-06-05 11:25:48 -04:00
ce4dc40033 [favorites] Add ability to favorite and add to Mopidy
Some checks failed
build / test (push) Has been cancelled
2026-06-05 11:24:26 -04:00
b0b22b79dc [release] Bump to version 43.0
All checks were successful
build / test (push) Successful in 1m56s
deploy / test (push) Successful in 1m56s
deploy / build-and-deploy (push) Successful in 33s
- Can we show a graph of all past Weigh-in tasks
- When viewing scrobbles by tag, sum the total time
- Orgmode tasks are not updated if in progress
- Ignore tag 'inprogress' for Tasks
- Deploys are now throwing an unknown version error
2026-06-04 22:13:36 -04:00
6471413681 [tasks] Add weigh in graph 2026-06-04 22:13:14 -04:00
50b10689fc [scrobbles] Add total time to tag views 2026-06-04 22:01:35 -04:00
85bddb6cba [tasks] Better updating of org mode tasks 2026-06-04 15:16:44 -04:00
c285b0d3b3 [tasks] Exclude inpgrogress tag, they're always inprogress
All checks were successful
build / test (push) Successful in 2m14s
2026-06-04 14:45:10 -04:00
671fe8d86f [tooling] Fix releases once and for all 2026-06-04 14:44:19 -04:00
89817110de [release] Bump to version 42.0
Some checks failed
deploy / test (push) Successful in 2m6s
build / test (push) Successful in 2m1s
deploy / build-and-deploy (push) Failing after 26s
- Add ability to add track to current Mopidy queue
2026-06-04 13:13:21 -04:00
ee01e3d8df [tracks] Add mopidy queue button
All checks were successful
build / test (push) Successful in 2m2s
2026-06-04 13:11:21 -04:00
a70343d6f3 [release] Bump to version 41.0
Some checks failed
build / test (push) Successful in 1m56s
deploy / test (push) Successful in 1m58s
deploy / build-and-deploy (push) Failing after 34s
- For any scrobble detail page with notes display them better
- Imports should send notifications
- Board game imports send duplicate ntfy message
- Too many geolocation notifications go out
- Fix bug where Weigh-in imports do not set title
2026-06-04 11:22:48 -04:00
3e72042c24 [justfile] push for release should only push tags
Some checks failed
build / test (push) Has been cancelled
2026-06-04 11:22:14 -04:00
087c7775ae [templates] Fix note parsing
Some checks failed
build / test (push) Has been cancelled
2026-06-04 11:15:58 -04:00
3f71065ad6 [notifications] Send ntfy on more imports
All checks were successful
build / test (push) Successful in 2m6s
2026-06-04 10:50:32 -04:00
801672124f [notifications] Fix duplicate ntfy for board games
All checks were successful
build / test (push) Successful in 1m58s
2026-06-04 10:47:22 -04:00
811e9c1ce9 [notfications] Dont send ntfy on non-titled geolocations
All checks were successful
build / test (push) Successful in 2m7s
2026-06-04 10:44:21 -04:00
415b32bdc7 [tasks] Scale task sets title properly
All checks were successful
build / test (push) Successful in 1m57s
2026-06-03 16:49:11 -04:00
22319c807a [tooling] Fix deploys for realz
All checks were successful
build / test (push) Successful in 2m0s
2026-06-03 16:38:10 -04:00
f9ba6fec14 [release] Bump to version 40.2
Some checks failed
build / test (push) Successful in 2m0s
deploy / test (push) Successful in 1m59s
deploy / build-and-deploy (push) Failing after 25s
- Try fixing deploy bugs again
2026-06-03 16:32:09 -04:00
5f55163147 [tooling] Try fixing deploys one more time 2026-06-03 16:31:09 -04:00
a6ef34623e [release] Bump to version 40.1
Some checks failed
deploy / test (push) Successful in 2m5s
build / test (push) Successful in 2m6s
deploy / build-and-deploy (push) Failing after 28s
- Releases are still broken
- Fix bug on chart pages where trail titles missing
2026-06-03 16:13:54 -04:00
7cb48d20f6 [tooling] Try to fix deploy issues
Some checks failed
build / test (push) Has been cancelled
2026-06-03 16:13:16 -04:00
445103a878 [trails] Fix display of trails in charts
All checks were successful
build / test (push) Successful in 2m7s
2026-06-03 15:47:56 -04:00
579da8c44e [release] Bump to version 40.0
Some checks failed
build / test (push) Successful in 1m59s
deploy / test (push) Successful in 2m3s
deploy / build-and-deploy (push) Failing after 19s
- Fix error in org-mode task sync
- Adjust how similar artists are shown
2026-06-01 11:12:31 -04:00
daabd2f37f [tasks] Fix bug in syncing orgmode tasks without notes
All checks were successful
build / test (push) Successful in 1m50s
2026-06-01 11:02:56 -04:00
039c58cf89 [tooling] Try fixing deploys again
All checks were successful
build / test (push) Successful in 1m53s
2026-06-01 10:50:38 -04:00
410c033f12 [music] Fix slow loading artist page 2026-06-01 10:50:22 -04:00
ce302e4d45 [release] Bump to version 39.3
Some checks failed
build / test (push) Successful in 1m53s
deploy / test (push) Successful in 1m55s
deploy / build-and-deploy (push) Failing after 19s
- Issue found when doing a release
- Fix deploy actions running twice
2026-06-01 10:35:51 -04:00
19589c9463 [tooling] Fix failing deploys 2026-06-01 10:35:10 -04:00
3d9506b14e [tooling] Actulaly fix the release problems
All checks were successful
build / test (push) Successful in 2m1s
Turns out we need build and deploy in separate files to trigger at
different times. Now we build on all pushes, but only deploy on tag pushes.
2026-06-01 10:21:56 -04:00
23b87278b2 [tooling] Stop running release task twice 2026-06-01 10:12:00 -04:00
0b8e027c30 [release] Bump to version 39.2
Some checks failed
build & deploy / test (push) Successful in 1m59s
build & deploy / build-and-deploy (push) Failing after 24s
- Releases do not pin commit to the repo for display
- Fix the way timestamps are stored for notes on tasks
2026-06-01 10:11:02 -04:00
1bd9f0d942 [tooling] Fix commit stamping on release
All checks were successful
build & deploy / test (push) Successful in 1m56s
build & deploy / build-and-deploy (push) Has been skipped
2026-06-01 09:58:59 -04:00
fa7890cb21 [tasks] Fix timestamps for notes and syncing from org-mode (32973bb3)
All checks were successful
build & deploy / test (push) Successful in 1m54s
build & deploy / build-and-deploy (push) Has been skipped
2026-06-01 09:37:41 -04:00
957c32e3a7 [release] Bump to version 39.1
All checks were successful
build & deploy / test (push) Successful in 1m56s
build & deploy / build-and-deploy (push) Has been skipped
- Fix bug in tests for notes saving
2026-05-31 23:25:34 -04:00
8d069df9d1 [scrobbles] Fix log saving tests 2026-05-31 23:24:22 -04:00
96d1d7ac6b [release] Bump to version 39.0
Some checks failed
build & deploy / test (push) Failing after 1m43s
build & deploy / build-and-deploy (push) Has been skipped
- Clean up org-mode tasks metadata
- Actually push branches up and add a just command to do it
- Try to fix deploy failing with bad release plan
2026-05-31 23:17:52 -04:00
009b2ba243 [scrobbles] Big update for notes in tasks and boardgames
Some checks failed
build & deploy / build-and-deploy (push) Has been cancelled
build & deploy / test (push) Has been cancelled
2026-05-31 23:17:16 -04:00
4f051ae250 [tooling] Add push command to just
All checks were successful
build & deploy / test (push) Successful in 2m7s
build & deploy / build-and-deploy (push) Has been skipped
2026-05-31 13:56:57 -04:00
7e9fbb1bf6 [tooling] Try to fix CI script
All checks were successful
build & deploy / test (push) Successful in 1m58s
build & deploy / build-and-deploy (push) Has been skipped
2026-05-31 13:10:31 -04:00
ec1a54f623 [release] Bump to version 38.0
All checks were successful
build & deploy / test (push) Successful in 2m41s
build & deploy / build-and-deploy (push) Successful in 21s
- Fix release flow to be easier to trigger
- Move imported retroarch lrtl files to processed/ directory on WebDAV
- Add listenbrainz support for similar tracks
- Consolidate albums in the same musicbrainz_releasegroup_id
- Clean up metadata on music tracks
- Make artists_m2m field source of artist truth for albums
- Fix various artist album problem with Superwolves (track with multiple artists)
- Move imported eBird CSV files to processed/ directory on WebDAV
- Move imported Board Game CSV files to processed/ directory on WebDAV
- Move imported Scale CSV files to processed/ directory on WebDAV
- Allow special parameter to re-import already processed GPX files
- Move imported GPX files to processed/ directory on WebDAV
- Add CSS Grid calendar view for scrobbles
- Come up with a possible flow using WebDAV and super-productivity for tasks
- Fix PuzzleLogData has no attribute form
- Add PuzzleLogData class with with_people and completed
- Add weather lookup to the mood check-in flow
- Add importing of openScale CSV files to Tasks
- Add ability to track Birding sessions via BirdingLocation scrobbles
- List only the last 20 scrobbles per category on the home page
- Fix display of notes so they look like stickies
- Add searching to scrobbles
- Fix uniqueness of imdb_id messing up youtube videos
- Fix genearting chart records
- Save raw scrobble request data to every scrobble log
- Clean up follow up notifications for if you're still scrobbling
- Fix lookup of music tracks from Musicbrainz
- Check opencode about a way to present stats like movies per month
- Fix bug in Jellyfin audio track playback
- Auto calc duration if no playback time seconds present
- Fix bug in video find_or_create
- Update admin page to be easier to use
- Fix migrations and update repo
- Add recipe parsing for food lookups
- Videos are scrobbling duplicates again
- Fix board games not saving BGG id on lookup
- Fix board game lookup with name like Unmatched Game System
- Fix raw text webpage title not truncating to 254 chars
2026-05-31 12:58:31 -04:00
b502667ca6 [importers] Retroarch webdav imports oldest first 2026-05-31 12:35:39 -04:00
263874288a [importers] WebDAV for retroarch should stash in procesesd now
All checks were successful
build & deploy / test (push) Successful in 2m18s
build & deploy / build-and-deploy (push) Successful in 49s
2026-05-31 12:09:56 -04:00
bca90c97ae [music] Add listenbrainz support 2026-05-31 11:21:11 -04:00
12f49a6cee [project] Update tasks
All checks were successful
build & deploy / test (push) Successful in 2m0s
build & deploy / build-and-deploy (push) Successful in 29s
2026-05-29 19:47:52 -04:00
034c7cb413 [music] Add better track fetching 2026-05-29 19:47:09 -04:00
e737870733 [music] Add metadata cleanup command
All checks were successful
build & deploy / test (push) Successful in 2m2s
build & deploy / build-and-deploy (push) Successful in 31s
2026-05-29 17:58:23 -04:00
95757650f6 [project] Add metadata clean up tasks 2026-05-29 08:37:10 -04:00
1928acf8a6 [music] Make artists_m2m field source of artist truth (05a24455)
All checks were successful
build & deploy / test (push) Successful in 2m0s
build & deploy / build-and-deploy (push) Successful in 32s
2026-05-29 08:26:30 -04:00
22956c7c7f [music] Tracks can have multiple artists
All checks were successful
build & deploy / test (push) Successful in 1m54s
build & deploy / build-and-deploy (push) Successful in 33s
2026-05-28 23:37:46 -04:00
17aed1191d [videos] Remove unused source
All checks were successful
build & deploy / test (push) Successful in 2m2s
build & deploy / build-and-deploy (push) Successful in 30s
2026-05-28 23:26:49 -04:00
0b1fb8667c [deps] Pin lxml version and upgrade python
Some checks failed
build & deploy / test (push) Successful in 1m52s
build & deploy / build-and-deploy (push) Failing after 2m20s
2026-05-28 23:16:27 -04:00
13dd5b67d0 [importers] Add processed stashing to eBird imports
All checks were successful
build & deploy / test (push) Successful in 1m55s
build & deploy / build-and-deploy (push) Successful in 28s
2026-05-28 17:40:19 -04:00
20c7874466 [importers] Add processed directory flow to board games
All checks were successful
build & deploy / test (push) Successful in 1m56s
build & deploy / build-and-deploy (push) Successful in 30s
2026-05-28 17:22:56 -04:00
216 changed files with 12715 additions and 1404 deletions

View File

@ -0,0 +1,68 @@
name: build
on:
push:
branches: ["**"]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
env:
VROBBLER_DATABASE_URL: sqlite:///test.db
VROBBLER_USDA_API_KEY: ${{ vars.VROBBLER_USDA_API_KEY }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Cache pip/poetry
uses: actions/cache@v4
with:
path: |
~/.cache/pip
~/.cache/pypoetry
key: ${{ runner.os }}-py311-${{ hashFiles('**/poetry.lock') }}
restore-keys: |
${{ runner.os }}-py311-
- name: Install Poetry
run: |
python -m pip install --upgrade pip
pip install poetry
- name: Install deps
run: |
cp vrobbler.conf.test vrobbler.conf
poetry install --with test
- name: Pytest with coverage
run: |
poetry run pytest -n 5 --cov-report term:skip-covered --cov=vrobbler tests
- name: Notify success (ntfy)
if: success()
run: |
curl -fsS \
-H "Title: vrobbler CI success" \
-H "Priority: low" \
-H "Tags: success,vrobbler" \
-H "Actions: view, Changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, Build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
-d "✅ Build succeeded: ${{ gitea.repository }} @ ${{ gitea.sha }}" \
https://ntfy.unbl.ink/drone
- name: Notify failure (ntfy)
if: failure()
run: |
curl -fsS \
-H "Title: vrobbler CI failure" \
-H "Priority: high" \
-H "Tags: failure,vrobbler" \
-H "Actions: view, Changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, Build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
-d "❌ Build failed: ${{ gitea.repository }} @ ${{ gitea.sha }}" \
https://ntfy.unbl.ink/drone

View File

@ -1,10 +1,8 @@
name: build & deploy
name: deploy
on:
push:
branches: ["**"]
tags: ["*"]
pull_request:
jobs:
test:
@ -22,7 +20,6 @@ jobs:
with:
python-version: "3.11"
# Cache pip + Poetry caches (rough equivalent to your mounted pip_cache)
- name: Cache pip/poetry
uses: actions/cache@v4
with:
@ -47,7 +44,6 @@ jobs:
run: |
poetry run pytest -n 5 --cov-report term:skip-covered --cov=vrobbler tests
# Notifications (success/failure) for the test job
- name: Notify success (ntfy)
if: success()
run: |
@ -71,8 +67,6 @@ jobs:
https://ntfy.unbl.ink/drone
build-and-deploy:
# Only deploy on tags (equivalent to Drone when: ref: refs/tags/*)
# if: startsWith(gitea.ref, 'refs/tags/')
needs: [test]
runs-on: ubuntu-latest
@ -97,12 +91,19 @@ jobs:
- name: Build package with commit info
run: |
# Write commit to _commit.py before build
echo "commit = '$(echo ${{ gitea.sha }} | cut -c1-8)'" > vrobbler/_commit.py
poetry build
# Restore original _commit.py
git checkout vrobbler/_commit.py
- name: Clean old wheels from server
uses: appleboy/ssh-action@v1.0.3
with:
host: vrobbler.service
username: root
key: ${{ secrets.JAIL_KEY }}
script: |
rm -f /var/lib/vrobbler/dist/*.whl
- name: Copy wheel to server and deploy
uses: appleboy/scp-action@v1.0.0
with:
@ -120,10 +121,13 @@ jobs:
key: ${{ secrets.JAIL_KEY }}
command_timeout: 2m
script: |
set -e
mkdir -p /var/lib/vrobbler
echo "${{ gitea.sha }}" | cut -c1-8 > /var/lib/vrobbler/commit.txt
pip uninstall -y vrobbler
pip install /var/lib/vrobbler/dist/*.whl
rm -f /var/lib/vrobbler/dist/*.whl
python3 -c "import vrobbler; print(f'vrobbler {vrobbler.__version__} installed OK')"
vrobbler migrate
vrobbler collectstatic --noinput
immortalctl restart vrobbler-celery && immortalctl restart vrobbler-celerybeat && immortalctl restart vrobbler

View File

@ -14,3 +14,13 @@ ro class method should call the utility function.
Be sure to check pyproject.toml for project defaults. Specifically for black and
isort expectations.
Imports in python files should always be top level if possible.
All tasks live in the PROJECT.org file and include an org ID that is a uuid to make them unique.
In local development, environment variables for various sensitive values live in a .envrc file
The .envrc file can be loaded into a shell environment to allow access to most third party services
Care should be taken when using .envrc that we do not spam services we use in production with requests

File diff suppressed because it is too large Load Diff

96
data/play-example.json Normal file
View File

@ -0,0 +1,96 @@
{
"about": "This is a Play file that can be read by Board Game Stats. If you see this text, try to use a share, export or open-in function to open it with Board Game Stats.",
"players": [
{
"uuid": "31f8b92e-11d8-4162-88b1-fd9c79eea249",
"id": 2,
"name": "Colin Powell",
"isAnonymous": false,
"modificationDate": "2025-10-18 08:32:40",
"metaData": "{\"isNpc\":0,\"playerAvatar\":{\"image\":\"AnnikaHeller_Capybara.webp\",\"shape\":[1,1,1,1],\"color\":[0.6,0.20000000298023224,0.9803921580314636]}}"
},
{
"uuid": "dd2d1881-84ab-474c-a6b2-3045d034dc40",
"id": 3,
"name": "Silas Sewell",
"isAnonymous": false,
"modificationDate": "2026-01-18 12:27:12",
"metaData": "{\"isNpc\":0,\"playerAvatar\":{\"image\":\"RoryMuldoon_07.webp\",\"shape\":[1,1,1,1],\"color\":[0,0,0.1835034190722739]}}"
}
],
"locations": [
{
"uuid": "14f7389c-767f-4725-9b35-906c407b293c",
"id": 3,
"name": "Timberwyck Farm",
"modificationDate": "2025-07-01 18:03:38"
}
],
"games": [
{
"uuid": "9e431cdd-b325-4061-a875-d415d46342c0",
"id": 1046,
"name": "Sweet Takes",
"modificationDate": "2026-04-11 16:25:35",
"cooperative": false,
"highestWins": true,
"noPoints": false,
"usesTeams": false,
"urlThumb": "https://cf.geekdo-images.com/l4HILZn5iLbceQeDph4G5A__small/img/gmGqlmwe9fniqYpniGwhg5RUgVQ=/fit-in/200x150/filters:strip_icc()/pic8784202.jpg",
"urlImage": "https://cf.geekdo-images.com/l4HILZn5iLbceQeDph4G5A__original/img/b4IU8WIEWRpacbXp0FHG9HfFRpw=/0x0/filters:format(jpeg)/pic8784202.jpg",
"bggName": "Sweet Takes",
"bggYear": 2023,
"bggId": 407581,
"designers": "Hisashi Hayashi",
"isBaseGame": 1,
"isExpansion": 0,
"rating": 67,
"minPlayerCount": 2,
"maxPlayerCount": 5,
"minPlayTime": 15,
"maxPlayTime": 15,
"minAge": 8
}
],
"plays": [
{
"uuid": "7b2fd583-e8f2-40fe-9565-90178390b87e",
"modificationDate": "2026-04-16 20:18:03",
"entryDate": "2026-04-16 20:13:33",
"playDate": "2026-04-16 20:13:33",
"usesTeams": false,
"durationMin": 4,
"ignored": false,
"manualWinner": false,
"rounds": 0,
"locationRefId": 3,
"gameRefId": 1046,
"board": "",
"scoringSetting": 1,
"metaData": "{\"playerRefId\":2,\"playGameBggVersion\":\"{\\\"versionId\\\":0,\\\"versionName\\\":\\\"\\\",\\\"imageUrl\\\":\\\"https:\\\\\\/\\\\\\/cf.geekdo-images.com\\\\\\/l4HILZn5iLbceQeDph4G5A__small\\\\\\/img\\\\\\/gmGqlmwe9fniqYpniGwhg5RUgVQ=\\\\\\/fit-in\\\\\\/200x150\\\\\\/filters:strip_icc()\\\\\\/pic8784202.jpg\\\",\\\"thumbUrl\\\":\\\"https:\\\\\\/\\\\\\/cf.geekdo-images.com\\\\\\/l4HILZn5iLbceQeDph4G5A__small\\\\\\/img\\\\\\/gmGqlmwe9fniqYpniGwhg5RUgVQ=\\\\\\/fit-in\\\\\\/200x150\\\\\\/filters:strip_icc()\\\\\\/pic8784202.jpg\\\",\\\"yearPublished\\\":0}\",\"playUsedGameCopy\":2}",
"playerScores": [
{
"score": "27",
"winner": false,
"newPlayer": false,
"startPlayer": false,
"playerRefId": 2,
"role": "",
"rank": 0,
"seatOrder": 0
},
{
"score": "36",
"winner": true,
"newPlayer": true,
"startPlayer": false,
"playerRefId": 3,
"rank": 0,
"seatOrder": 0
}
],
"expansionPlays": []
}
],
"userInfo": { "meRefId": 2 }
}

BIN
data/statistics.sqlite3 Normal file

Binary file not shown.

View File

@ -14,3 +14,12 @@ celery:
celery-beat:
poetry run celery -A vrobbler beat -l info
push:
git push && git push gitea
git push --tags && git push --tags gitea
release kind="minor":
poetry run python scripts/release.py {{kind}}
just push

325
poetry.lock generated
View File

@ -2509,144 +2509,146 @@ zookeeper = ["kazoo (>=2.8.0)"]
[[package]]
name = "lxml"
version = "5.4.0"
version = "6.1.1"
description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API."
optional = false
python-versions = ">=3.6"
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "lxml-5.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e7bc6df34d42322c5289e37e9971d6ed114e3776b45fa879f734bded9d1fea9c"},
{file = "lxml-5.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6854f8bd8a1536f8a1d9a3655e6354faa6406621cf857dc27b681b69860645c7"},
{file = "lxml-5.4.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:696ea9e87442467819ac22394ca36cb3d01848dad1be6fac3fb612d3bd5a12cf"},
{file = "lxml-5.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ef80aeac414f33c24b3815ecd560cee272786c3adfa5f31316d8b349bfade28"},
{file = "lxml-5.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b9c2754cef6963f3408ab381ea55f47dabc6f78f4b8ebb0f0b25cf1ac1f7609"},
{file = "lxml-5.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a62cc23d754bb449d63ff35334acc9f5c02e6dae830d78dab4dd12b78a524f4"},
{file = "lxml-5.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f82125bc7203c5ae8633a7d5d20bcfdff0ba33e436e4ab0abc026a53a8960b7"},
{file = "lxml-5.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:b67319b4aef1a6c56576ff544b67a2a6fbd7eaee485b241cabf53115e8908b8f"},
{file = "lxml-5.4.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:a8ef956fce64c8551221f395ba21d0724fed6b9b6242ca4f2f7beb4ce2f41997"},
{file = "lxml-5.4.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:0a01ce7d8479dce84fc03324e3b0c9c90b1ece9a9bb6a1b6c9025e7e4520e78c"},
{file = "lxml-5.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:91505d3ddebf268bb1588eb0f63821f738d20e1e7f05d3c647a5ca900288760b"},
{file = "lxml-5.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a3bcdde35d82ff385f4ede021df801b5c4a5bcdfb61ea87caabcebfc4945dc1b"},
{file = "lxml-5.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:aea7c06667b987787c7d1f5e1dfcd70419b711cdb47d6b4bb4ad4b76777a0563"},
{file = "lxml-5.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a7fb111eef4d05909b82152721a59c1b14d0f365e2be4c742a473c5d7372f4f5"},
{file = "lxml-5.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:43d549b876ce64aa18b2328faff70f5877f8c6dede415f80a2f799d31644d776"},
{file = "lxml-5.4.0-cp310-cp310-win32.whl", hash = "sha256:75133890e40d229d6c5837b0312abbe5bac1c342452cf0e12523477cd3aa21e7"},
{file = "lxml-5.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:de5b4e1088523e2b6f730d0509a9a813355b7f5659d70eb4f319c76beea2e250"},
{file = "lxml-5.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:98a3912194c079ef37e716ed228ae0dcb960992100461b704aea4e93af6b0bb9"},
{file = "lxml-5.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0ea0252b51d296a75f6118ed0d8696888e7403408ad42345d7dfd0d1e93309a7"},
{file = "lxml-5.4.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b92b69441d1bd39f4940f9eadfa417a25862242ca2c396b406f9272ef09cdcaa"},
{file = "lxml-5.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20e16c08254b9b6466526bc1828d9370ee6c0d60a4b64836bc3ac2917d1e16df"},
{file = "lxml-5.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7605c1c32c3d6e8c990dd28a0970a3cbbf1429d5b92279e37fda05fb0c92190e"},
{file = "lxml-5.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ecf4c4b83f1ab3d5a7ace10bafcb6f11df6156857a3c418244cef41ca9fa3e44"},
{file = "lxml-5.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cef4feae82709eed352cd7e97ae062ef6ae9c7b5dbe3663f104cd2c0e8d94ba"},
{file = "lxml-5.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:df53330a3bff250f10472ce96a9af28628ff1f4efc51ccba351a8820bca2a8ba"},
{file = "lxml-5.4.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:aefe1a7cb852fa61150fcb21a8c8fcea7b58c4cb11fbe59c97a0a4b31cae3c8c"},
{file = "lxml-5.4.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:ef5a7178fcc73b7d8c07229e89f8eb45b2908a9238eb90dcfc46571ccf0383b8"},
{file = "lxml-5.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d2ed1b3cb9ff1c10e6e8b00941bb2e5bb568b307bfc6b17dffbbe8be5eecba86"},
{file = "lxml-5.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:72ac9762a9f8ce74c9eed4a4e74306f2f18613a6b71fa065495a67ac227b3056"},
{file = "lxml-5.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f5cb182f6396706dc6cc1896dd02b1c889d644c081b0cdec38747573db88a7d7"},
{file = "lxml-5.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:3a3178b4873df8ef9457a4875703488eb1622632a9cee6d76464b60e90adbfcd"},
{file = "lxml-5.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e094ec83694b59d263802ed03a8384594fcce477ce484b0cbcd0008a211ca751"},
{file = "lxml-5.4.0-cp311-cp311-win32.whl", hash = "sha256:4329422de653cdb2b72afa39b0aa04252fca9071550044904b2e7036d9d97fe4"},
{file = "lxml-5.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd3be6481ef54b8cfd0e1e953323b7aa9d9789b94842d0e5b142ef4bb7999539"},
{file = "lxml-5.4.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b5aff6f3e818e6bdbbb38e5967520f174b18f539c2b9de867b1e7fde6f8d95a4"},
{file = "lxml-5.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:942a5d73f739ad7c452bf739a62a0f83e2578afd6b8e5406308731f4ce78b16d"},
{file = "lxml-5.4.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:460508a4b07364d6abf53acaa0a90b6d370fafde5693ef37602566613a9b0779"},
{file = "lxml-5.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529024ab3a505fed78fe3cc5ddc079464e709f6c892733e3f5842007cec8ac6e"},
{file = "lxml-5.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ca56ebc2c474e8f3d5761debfd9283b8b18c76c4fc0967b74aeafba1f5647f9"},
{file = "lxml-5.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a81e1196f0a5b4167a8dafe3a66aa67c4addac1b22dc47947abd5d5c7a3f24b5"},
{file = "lxml-5.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00b8686694423ddae324cf614e1b9659c2edb754de617703c3d29ff568448df5"},
{file = "lxml-5.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c5681160758d3f6ac5b4fea370495c48aac0989d6a0f01bb9a72ad8ef5ab75c4"},
{file = "lxml-5.4.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:2dc191e60425ad70e75a68c9fd90ab284df64d9cd410ba8d2b641c0c45bc006e"},
{file = "lxml-5.4.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:67f779374c6b9753ae0a0195a892a1c234ce8416e4448fe1e9f34746482070a7"},
{file = "lxml-5.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:79d5bfa9c1b455336f52343130b2067164040604e41f6dc4d8313867ed540079"},
{file = "lxml-5.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3d3c30ba1c9b48c68489dc1829a6eede9873f52edca1dda900066542528d6b20"},
{file = "lxml-5.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1af80c6316ae68aded77e91cd9d80648f7dd40406cef73df841aa3c36f6907c8"},
{file = "lxml-5.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4d885698f5019abe0de3d352caf9466d5de2baded00a06ef3f1216c1a58ae78f"},
{file = "lxml-5.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aea53d51859b6c64e7c51d522c03cc2c48b9b5d6172126854cc7f01aa11f52bc"},
{file = "lxml-5.4.0-cp312-cp312-win32.whl", hash = "sha256:d90b729fd2732df28130c064aac9bb8aff14ba20baa4aee7bd0795ff1187545f"},
{file = "lxml-5.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1dc4ca99e89c335a7ed47d38964abcb36c5910790f9bd106f2a8fa2ee0b909d2"},
{file = "lxml-5.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:773e27b62920199c6197130632c18fb7ead3257fce1ffb7d286912e56ddb79e0"},
{file = "lxml-5.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ce9c671845de9699904b1e9df95acfe8dfc183f2310f163cdaa91a3535af95de"},
{file = "lxml-5.4.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9454b8d8200ec99a224df8854786262b1bd6461f4280064c807303c642c05e76"},
{file = "lxml-5.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cccd007d5c95279e529c146d095f1d39ac05139de26c098166c4beb9374b0f4d"},
{file = "lxml-5.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fce1294a0497edb034cb416ad3e77ecc89b313cff7adbee5334e4dc0d11f422"},
{file = "lxml-5.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:24974f774f3a78ac12b95e3a20ef0931795ff04dbb16db81a90c37f589819551"},
{file = "lxml-5.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:497cab4d8254c2a90bf988f162ace2ddbfdd806fce3bda3f581b9d24c852e03c"},
{file = "lxml-5.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e794f698ae4c5084414efea0f5cc9f4ac562ec02d66e1484ff822ef97c2cadff"},
{file = "lxml-5.4.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2c62891b1ea3094bb12097822b3d44b93fc6c325f2043c4d2736a8ff09e65f60"},
{file = "lxml-5.4.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:142accb3e4d1edae4b392bd165a9abdee8a3c432a2cca193df995bc3886249c8"},
{file = "lxml-5.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1a42b3a19346e5601d1b8296ff6ef3d76038058f311902edd574461e9c036982"},
{file = "lxml-5.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4291d3c409a17febf817259cb37bc62cb7eb398bcc95c1356947e2871911ae61"},
{file = "lxml-5.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4f5322cf38fe0e21c2d73901abf68e6329dc02a4994e483adbcf92b568a09a54"},
{file = "lxml-5.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0be91891bdb06ebe65122aa6bf3fc94489960cf7e03033c6f83a90863b23c58b"},
{file = "lxml-5.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:15a665ad90054a3d4f397bc40f73948d48e36e4c09f9bcffc7d90c87410e478a"},
{file = "lxml-5.4.0-cp313-cp313-win32.whl", hash = "sha256:d5663bc1b471c79f5c833cffbc9b87d7bf13f87e055a5c86c363ccd2348d7e82"},
{file = "lxml-5.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:bcb7a1096b4b6b24ce1ac24d4942ad98f983cd3810f9711bcd0293f43a9d8b9f"},
{file = "lxml-5.4.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:7be701c24e7f843e6788353c055d806e8bd8466b52907bafe5d13ec6a6dbaecd"},
{file = "lxml-5.4.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb54f7c6bafaa808f27166569b1511fc42701a7713858dddc08afdde9746849e"},
{file = "lxml-5.4.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97dac543661e84a284502e0cf8a67b5c711b0ad5fb661d1bd505c02f8cf716d7"},
{file = "lxml-5.4.0-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:c70e93fba207106cb16bf852e421c37bbded92acd5964390aad07cb50d60f5cf"},
{file = "lxml-5.4.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:9c886b481aefdf818ad44846145f6eaf373a20d200b5ce1a5c8e1bc2d8745410"},
{file = "lxml-5.4.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:fa0e294046de09acd6146be0ed6727d1f42ded4ce3ea1e9a19c11b6774eea27c"},
{file = "lxml-5.4.0-cp36-cp36m-win32.whl", hash = "sha256:61c7bbf432f09ee44b1ccaa24896d21075e533cd01477966a5ff5a71d88b2f56"},
{file = "lxml-5.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:7ce1a171ec325192c6a636b64c94418e71a1964f56d002cc28122fceff0b6121"},
{file = "lxml-5.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:795f61bcaf8770e1b37eec24edf9771b307df3af74d1d6f27d812e15a9ff3872"},
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29f451a4b614a7b5b6c2e043d7b64a15bd8304d7e767055e8ab68387a8cacf4e"},
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:891f7f991a68d20c75cb13c5c9142b2a3f9eb161f1f12a9489c82172d1f133c0"},
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4aa412a82e460571fad592d0f93ce9935a20090029ba08eca05c614f99b0cc92"},
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:ac7ba71f9561cd7d7b55e1ea5511543c0282e2b6450f122672a2694621d63b7e"},
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:c5d32f5284012deaccd37da1e2cd42f081feaa76981f0eaa474351b68df813c5"},
{file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:ce31158630a6ac85bddd6b830cffd46085ff90498b397bd0a259f59d27a12188"},
{file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:31e63621e073e04697c1b2d23fcb89991790eef370ec37ce4d5d469f40924ed6"},
{file = "lxml-5.4.0-cp37-cp37m-win32.whl", hash = "sha256:be2ba4c3c5b7900246a8f866580700ef0d538f2ca32535e991027bdaba944063"},
{file = "lxml-5.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:09846782b1ef650b321484ad429217f5154da4d6e786636c38e434fa32e94e49"},
{file = "lxml-5.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:eaf24066ad0b30917186420d51e2e3edf4b0e2ea68d8cd885b14dc8afdcf6556"},
{file = "lxml-5.4.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b31a3a77501d86d8ade128abb01082724c0dfd9524f542f2f07d693c9f1175f"},
{file = "lxml-5.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e108352e203c7afd0eb91d782582f00a0b16a948d204d4dec8565024fafeea5"},
{file = "lxml-5.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a11a96c3b3f7551c8a8109aa65e8594e551d5a84c76bf950da33d0fb6dfafab7"},
{file = "lxml-5.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:ca755eebf0d9e62d6cb013f1261e510317a41bf4650f22963474a663fdfe02aa"},
{file = "lxml-5.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:4cd915c0fb1bed47b5e6d6edd424ac25856252f09120e3e8ba5154b6b921860e"},
{file = "lxml-5.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:226046e386556a45ebc787871d6d2467b32c37ce76c2680f5c608e25823ffc84"},
{file = "lxml-5.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:b108134b9667bcd71236c5a02aad5ddd073e372fb5d48ea74853e009fe38acb6"},
{file = "lxml-5.4.0-cp38-cp38-win32.whl", hash = "sha256:1320091caa89805df7dcb9e908add28166113dcd062590668514dbd510798c88"},
{file = "lxml-5.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:073eb6dcdf1f587d9b88c8c93528b57eccda40209cf9be549d469b942b41d70b"},
{file = "lxml-5.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bda3ea44c39eb74e2488297bb39d47186ed01342f0022c8ff407c250ac3f498e"},
{file = "lxml-5.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9ceaf423b50ecfc23ca00b7f50b64baba85fb3fb91c53e2c9d00bc86150c7e40"},
{file = "lxml-5.4.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:664cdc733bc87449fe781dbb1f309090966c11cc0c0cd7b84af956a02a8a4729"},
{file = "lxml-5.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67ed8a40665b84d161bae3181aa2763beea3747f748bca5874b4af4d75998f87"},
{file = "lxml-5.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b4a3bd174cc9cdaa1afbc4620c049038b441d6ba07629d89a83b408e54c35cd"},
{file = "lxml-5.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:b0989737a3ba6cf2a16efb857fb0dfa20bc5c542737fddb6d893fde48be45433"},
{file = "lxml-5.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:dc0af80267edc68adf85f2a5d9be1cdf062f973db6790c1d065e45025fa26140"},
{file = "lxml-5.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:639978bccb04c42677db43c79bdaa23785dc7f9b83bfd87570da8207872f1ce5"},
{file = "lxml-5.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5a99d86351f9c15e4a901fc56404b485b1462039db59288b203f8c629260a142"},
{file = "lxml-5.4.0-cp39-cp39-win32.whl", hash = "sha256:3e6d5557989cdc3ebb5302bbdc42b439733a841891762ded9514e74f60319ad6"},
{file = "lxml-5.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:a8c9b7f16b63e65bbba889acb436a1034a82d34fa09752d754f88d708eca80e1"},
{file = "lxml-5.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1b717b00a71b901b4667226bba282dd462c42ccf618ade12f9ba3674e1fabc55"},
{file = "lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27a9ded0f0b52098ff89dd4c418325b987feed2ea5cc86e8860b0f844285d740"},
{file = "lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b7ce10634113651d6f383aa712a194179dcd496bd8c41e191cec2099fa09de5"},
{file = "lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53370c26500d22b45182f98847243efb518d268374a9570409d2e2276232fd37"},
{file = "lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c6364038c519dffdbe07e3cf42e6a7f8b90c275d4d1617a69bb59734c1a2d571"},
{file = "lxml-5.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b12cb6527599808ada9eb2cd6e0e7d3d8f13fe7bbb01c6311255a15ded4c7ab4"},
{file = "lxml-5.4.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5f11a1526ebd0dee85e7b1e39e39a0cc0d9d03fb527f56d8457f6df48a10dc0c"},
{file = "lxml-5.4.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48b4afaf38bf79109bb060d9016fad014a9a48fb244e11b94f74ae366a64d252"},
{file = "lxml-5.4.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de6f6bb8a7840c7bf216fb83eec4e2f79f7325eca8858167b68708b929ab2172"},
{file = "lxml-5.4.0-pp37-pypy37_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5cca36a194a4eb4e2ed6be36923d3cffd03dcdf477515dea687185506583d4c9"},
{file = "lxml-5.4.0-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b7c86884ad23d61b025989d99bfdd92a7351de956e01c61307cb87035960bcb1"},
{file = "lxml-5.4.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:53d9469ab5460402c19553b56c3648746774ecd0681b1b27ea74d5d8a3ef5590"},
{file = "lxml-5.4.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:56dbdbab0551532bb26c19c914848d7251d73edb507c3079d6805fa8bba5b706"},
{file = "lxml-5.4.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14479c2ad1cb08b62bb941ba8e0e05938524ee3c3114644df905d2331c76cd57"},
{file = "lxml-5.4.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32697d2ea994e0db19c1df9e40275ffe84973e4232b5c274f47e7c1ec9763cdd"},
{file = "lxml-5.4.0-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:24f6df5f24fc3385f622c0c9d63fe34604893bc1a5bdbb2dbf5870f85f9a404a"},
{file = "lxml-5.4.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:151d6c40bc9db11e960619d2bf2ec5829f0aaffb10b41dcf6ad2ce0f3c0b2325"},
{file = "lxml-5.4.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:4025bf2884ac4370a3243c5aa8d66d3cb9e15d3ddd0af2d796eccc5f0244390e"},
{file = "lxml-5.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9459e6892f59ecea2e2584ee1058f5d8f629446eab52ba2305ae13a32a059530"},
{file = "lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47fb24cc0f052f0576ea382872b3fc7e1f7e3028e53299ea751839418ade92a6"},
{file = "lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50441c9de951a153c698b9b99992e806b71c1f36d14b154592580ff4a9d0d877"},
{file = "lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ab339536aa798b1e17750733663d272038bf28069761d5be57cb4a9b0137b4f8"},
{file = "lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9776af1aad5a4b4a1317242ee2bea51da54b2a7b7b48674be736d463c999f37d"},
{file = "lxml-5.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:63e7968ff83da2eb6fdda967483a7a023aa497d85ad8f05c3ad9b1f2e8c84987"},
{file = "lxml-5.4.0.tar.gz", hash = "sha256:d12832e1dbea4be280b22fd0ea7c9b87f0d8fc51ba06e92dc62d52f804f78ebd"},
{file = "lxml-6.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:09dd5b7075dc2f7709654a46543ba1ea3c2e217b2ed8fbd413a8a945a0f40f60"},
{file = "lxml-6.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f6ac4ef4d82dff54670227a69c67782ae0b811b5cf6b17954f1e8f7502fc0d1d"},
{file = "lxml-6.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:556e94a63c9b04716f8e4de2abb65775061f846e89331b6c5be79183a24f98ea"},
{file = "lxml-6.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6bf403fbb3b3e348a561a5f4f0b9961835657981c802a1df03653eef8a9074"},
{file = "lxml-6.1.1-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dde6131244bba38a17c745836ba190bc753fd73c9291666287fd0a3fa3dcf30"},
{file = "lxml-6.1.1-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98fc784c2c1440667aeedf8465bdfe10208acf0ead656a2c68627299f546b315"},
{file = "lxml-6.1.1-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:add8cf6ddf9a65116119a28ece0f7886e30af27ba724a7594305f1d1b58a92a1"},
{file = "lxml-6.1.1-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cf9d57306d848218f3601fee7601fab1a327c942d56e2e97610583cb4dd74206"},
{file = "lxml-6.1.1-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88136950da4d13c318bde414ce10219931937851327f44328f2df4d2c4614067"},
{file = "lxml-6.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cecdd5dfdc87b1fd87dbf81d4b037a544f47f4c744200a67013771682d67686a"},
{file = "lxml-6.1.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cd312b9692e831d2ffcad61eab31d91d4b4655a962e61de8fb410472cbcd37aa"},
{file = "lxml-6.1.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5b7328b46d49fc9477d91ae8f6d55340347d827b7734ba3ea33faae0efef1383"},
{file = "lxml-6.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37a58976370f36d9329d118ad0b953c5aeb9119ac9c6a4e258942a225d0573a1"},
{file = "lxml-6.1.1-cp310-cp310-win32.whl", hash = "sha256:cea3f4c1af79af13cdb2da0c028111d8f8522d4f22a000c82385535f24e5cf3a"},
{file = "lxml-6.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:3abf332af33a74288675d936fe861fd4344da0dd6622193fbc4f2bfbb35536b5"},
{file = "lxml-6.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:8dadbe5b217ff35b6a8d16610dd710219b59b76d13f0e3f0d9f36786206e4485"},
{file = "lxml-6.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2"},
{file = "lxml-6.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d"},
{file = "lxml-6.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510"},
{file = "lxml-6.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a"},
{file = "lxml-6.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d"},
{file = "lxml-6.1.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8"},
{file = "lxml-6.1.1-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009"},
{file = "lxml-6.1.1-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6"},
{file = "lxml-6.1.1-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8"},
{file = "lxml-6.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83"},
{file = "lxml-6.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6"},
{file = "lxml-6.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c"},
{file = "lxml-6.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08"},
{file = "lxml-6.1.1-cp311-cp311-win32.whl", hash = "sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621"},
{file = "lxml-6.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28"},
{file = "lxml-6.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b"},
{file = "lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7"},
{file = "lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1"},
{file = "lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc"},
{file = "lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc"},
{file = "lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383"},
{file = "lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b"},
{file = "lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818"},
{file = "lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740"},
{file = "lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f"},
{file = "lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2"},
{file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635"},
{file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf"},
{file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc"},
{file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955"},
{file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a"},
{file = "lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a"},
{file = "lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77"},
{file = "lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f"},
{file = "lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736"},
{file = "lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9"},
{file = "lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354"},
{file = "lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca"},
{file = "lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099"},
{file = "lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6"},
{file = "lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085"},
{file = "lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e"},
{file = "lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f"},
{file = "lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c"},
{file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b"},
{file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2"},
{file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5"},
{file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785"},
{file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947"},
{file = "lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca"},
{file = "lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660"},
{file = "lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc"},
{file = "lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0"},
{file = "lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840"},
{file = "lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14"},
{file = "lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909"},
{file = "lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00"},
{file = "lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955"},
{file = "lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13"},
{file = "lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7"},
{file = "lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245"},
{file = "lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5"},
{file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462"},
{file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465"},
{file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a"},
{file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590"},
{file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb"},
{file = "lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603"},
{file = "lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137"},
{file = "lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf"},
{file = "lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee"},
{file = "lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c"},
{file = "lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef"},
{file = "lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a"},
{file = "lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c"},
{file = "lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525"},
{file = "lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca"},
{file = "lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e"},
{file = "lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038"},
{file = "lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e"},
{file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072"},
{file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52"},
{file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b"},
{file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2"},
{file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e"},
{file = "lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1"},
{file = "lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e"},
{file = "lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c"},
{file = "lxml-6.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6689e828a94eee4f139408c337bb198e014724bb8a8c26d3cfac49d119ed69a6"},
{file = "lxml-6.1.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bdebcc8a75d38c7598dfb2c9ed852d7a9eb4a10d6e2d0764b919b802bf32ac88"},
{file = "lxml-6.1.1-cp38-cp38-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8be8ad51249698103d24b0571df35a10990fbe93dd043b6c024172189485f5e3"},
{file = "lxml-6.1.1-cp38-cp38-manylinux_2_28_i686.whl", hash = "sha256:76447f65250ed2501ead1a1552f5ce8edff159a86f308348e6a9c4acb5e1f1b4"},
{file = "lxml-6.1.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ffecec8eb889b58ba9be5b95fb1cc78e22ea8eedea38e8736a1568fe1979250e"},
{file = "lxml-6.1.1-cp38-cp38-win32.whl", hash = "sha256:c674693f055fa2495de12292cb45e9944199d8eaef5a2dec45175c7c61cb73e3"},
{file = "lxml-6.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:55b03549819867ea141c0202242c4816c82e52ec36e7e648db9d8da5a3dc3ed6"},
{file = "lxml-6.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c9f79d5325907f13e1be0b3e4dacc1049d1dffc4aeee3c995284bea5fe0fab7d"},
{file = "lxml-6.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:83b6b30eb131da7a75b601f28c5d6971e6ed3e887919bf6b6a1ad3c2df289080"},
{file = "lxml-6.1.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:441dd227fa0690eb9fc81edabc63cdcefc212bba99b906dcf6e32cc1a9d3e533"},
{file = "lxml-6.1.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e07c65f443c887bbcf31cc1771d932ecc192a5273943589b3c7572b749f1ffb2"},
{file = "lxml-6.1.1-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5bec7d03d78d853597d6107854c2310ce3f761fd218fe9fe91d5101fcf6c2efe"},
{file = "lxml-6.1.1-cp39-cp39-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f76acfb5f68ba982635a53fd985a8044be98a35b43232c2a1ee235ffab3e1dd"},
{file = "lxml-6.1.1-cp39-cp39-manylinux_2_28_i686.whl", hash = "sha256:8d43ca737b20e106e4aebc42b2f3ae19f00ba63d7eb731698ee083d72d15646f"},
{file = "lxml-6.1.1-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:32ab449a5486f6c758e849bb86710d0e45edc24a04e250c01555f8f5653958f8"},
{file = "lxml-6.1.1-cp39-cp39-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53c909b62a0532183542fed00c5a7218258c56292d409bc789886fe1cb04c438"},
{file = "lxml-6.1.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:640f97d43d867bcb9c75b3af013b64850756b746cb6bce8ace83b70da3abba9d"},
{file = "lxml-6.1.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:469e3618338bd7ab5beb412d2439825479fcf0dab99e394ca563dbc4eaf6c834"},
{file = "lxml-6.1.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:aae97dfdb60715c164419ac2532a76d013c3918a665eb6cb7288098b5f349aaf"},
{file = "lxml-6.1.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c9a4b821dc7055bf9e05ff5719e18ec501f75c0f0bbfabd573b277559780833d"},
{file = "lxml-6.1.1-cp39-cp39-win32.whl", hash = "sha256:639f6c857d91d9be29bd7502348d6736dab168b54b5158cd899abf11684dc186"},
{file = "lxml-6.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:34c2d737beabfe35baada43941ed519251e9a12e779031496bcd5d539fcfd730"},
{file = "lxml-6.1.1-cp39-cp39-win_arm64.whl", hash = "sha256:07a4a68e286ee7a1ed7dfb8af83e615757c0ccfe9f18c6b4ea6771388d9ba8c9"},
{file = "lxml-6.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e"},
{file = "lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004"},
{file = "lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e"},
{file = "lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2"},
{file = "lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf"},
{file = "lxml-6.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84"},
{file = "lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40"},
]
[package.dependencies]
@ -2657,7 +2659,6 @@ cssselect = ["cssselect (>=0.7)"]
html-clean = ["lxml_html_clean"]
html5 = ["html5lib"]
htmlsoup = ["BeautifulSoup4"]
source = ["Cython (>=3.0.11,<3.1.0)"]
[[package]]
name = "lxml-html-clean"
@ -4269,6 +4270,29 @@ six = "*"
[package.extras]
testing = ["filelock"]
[[package]]
name = "python-amazon-paapi"
version = "6.3.0"
description = "Amazon Product Advertising API 5.0 wrapper for Python"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "python_amazon_paapi-6.3.0-py3-none-any.whl", hash = "sha256:b7cd852084a49d53c3ba2195531fccbc8c7f4124b2e82e2fda02b53d3b8de521"},
{file = "python_amazon_paapi-6.3.0.tar.gz", hash = "sha256:e525d69efcbe4f9566ec2b9b43fa3183c484d166d3852edb38b4df9c0b19cf1f"},
]
[package.dependencies]
certifi = ">=2023.0.0"
pydantic = ">=2.0.0"
python-dateutil = ">=2.8.0"
requests = ">=2.28.0"
six = ">=1.16.0"
urllib3 = ">=1.26.0,<3"
[package.extras]
async = ["httpx (>=0.27.0)", "typing-extensions (>=4.15.0)"]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@ -4965,6 +4989,18 @@ files = [
{file = "soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349"},
]
[[package]]
name = "sqids"
version = "0.5.2"
description = "Generate YouTube-like ids from numbers."
optional = false
python-versions = ">=3.6"
groups = ["main"]
files = [
{file = "sqids-0.5.2-py3-none-any.whl", hash = "sha256:0089ba823e21fd44290c7225f02fb0b5140c36e41959c04d86d3f6f2513799be"},
{file = "sqids-0.5.2.tar.gz", hash = "sha256:5ac08f0c5c9b6814bc2e7c79ee5931e0849d25d95c50e415771b022a44f58af9"},
]
[[package]]
name = "sqlparse"
version = "0.5.5"
@ -5438,6 +5474,21 @@ brotli = ["brotli (==1.0.9) ; os_name != \"nt\" and python_version < \"3\" and p
secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress ; python_version == \"2.7\"", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"]
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
[[package]]
name = "vadersentiment"
version = "3.3.2"
description = "VADER Sentiment Analysis. VADER (Valence Aware Dictionary and sEntiment Reasoner) is a lexicon and rule-based sentiment analysis tool that is specifically attuned to sentiments expressed in social media, and works well on texts from other domains."
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "vaderSentiment-3.3.2-py2.py3-none-any.whl", hash = "sha256:3bf1d243b98b1afad575b9f22bc2cb1e212b94ff89ca74f8a23a588d024ea311"},
{file = "vaderSentiment-3.3.2.tar.gz", hash = "sha256:5d7c06e027fc8b99238edb0d53d970cf97066ef97654009890b83703849632f9"},
]
[package.dependencies]
requests = "*"
[[package]]
name = "vine"
version = "5.1.0"
@ -6003,5 +6054,5 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt
[metadata]
lock-version = "2.1"
python-versions = ">=3.11,<3.14"
content-hash = "395bc627d9e2fa0434082fc574693e349cf0c604c47e8554b9a50a28bdd273d0"
python-versions = ">=3.11,<3.15"
content-hash = "aafab54d3c3d674b917782bf449b7d6324ca2259fb58bff13a08caabe110c342"

View File

@ -1,11 +1,11 @@
[tool.poetry]
name = "vrobbler"
version = "0.16.1"
version = "55.0"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]
[tool.poetry.dependencies]
python = ">=3.11,<3.14"
python = ">=3.11,<3.15"
Django = "^4.0.3"
django-extensions = "^3.1.5"
python-dateutil = "^2.8.2"
@ -61,6 +61,10 @@ bgg-api = "^1.1.13"
recipe-scrapers = "^15.11.0"
gpxpy = "^1.6.2"
fitparse = "^1.2.0"
lxml = ">=5.5.0"
vaderSentiment = "^3.3.2"
sqids = "^0.5.2"
python-amazon-paapi = "^6.3.0"
[tool.poetry.group.test]
optional = true
@ -106,6 +110,8 @@ exclude_dirs = ["*/tests/*", "*/migrations/*"]
[tool.poetry.scripts]
vrobbler = "vrobbler.cli:main"
[tool.poetry_bumpversion.file."vrobbler/__init__.py"]
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

217
scripts/release.py Executable file
View File

@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""Cut a new release: collect DONE items from Backlog into a new Version section.
Usage:
poetry run python scripts/release.py major
poetry run python scripts/release.py minor
"""
import re
import subprocess
import sys
from pathlib import Path
PROJECT_FILE = Path("PROJECT.org")
PYPROJECT_FILE = Path("pyproject.toml")
BACKLOG_RE = re.compile(r"^\* Backlog\s+\[(\d+)/(\d+)\](.*)$")
VERSION_RE = re.compile(r"^\* Version\s+(\d+\.\d+)\s+\[\d+/\d+\]")
DONE_HEADER_RE = re.compile(r"^(\*\* DONE\s+)(.*)$")
ITEM_HEADER_RE = re.compile(r"^\*\* ")
def parse_done_line(line):
"""Extract a clean title from a ** DONE line, stripping priority and tags."""
rest = line[8:].strip() # remove "** DONE "
# strip priority marker like [#A]
rest = re.sub(r"^\[#[A-C]\]\s+", "", rest, count=1)
# strip org-mode tags at end (space-colon-tags)
rest = re.sub(r"\s+:\S.*:\s*$", "", rest)
return rest
def bump_version(current_major, current_minor, kind):
if kind == "major":
return current_major + 1, 0
elif kind == "minor":
return current_major, current_minor + 1
else:
raise ValueError(f"Unknown bump kind: {kind}")
def main():
if len(sys.argv) < 2 or sys.argv[1] not in ("major", "minor"):
print(f"Usage: {sys.argv[0]} <major|minor>", file=sys.stderr)
sys.exit(1)
kind = sys.argv[1]
lines = PROJECT_FILE.read_text().splitlines(keepends=True)
# ---------------------------------------------------------------
# 1. Identify top-level sections
# ---------------------------------------------------------------
section_starts = []
for i, line in enumerate(lines):
if line.startswith("* ") and not line.startswith("** "):
section_starts.append(i)
section_starts.append(len(lines))
backlog_idx = None
version_idx = None
for idx, start in enumerate(section_starts[:-1]):
header = lines[start].strip()
if header.startswith("* Backlog"):
backlog_idx = idx
if header.startswith("* Version"):
version_idx = idx # last occurrence wins
if backlog_idx is None:
print("ERROR: no Backlog section found", file=sys.stderr)
sys.exit(1)
if version_idx is None:
print("ERROR: no Version section found", file=sys.stderr)
sys.exit(1)
backlog_start = section_starts[backlog_idx]
backlog_end = section_starts[backlog_idx + 1]
# Find the newest Version section (first after Backlog) that matches
# our expected format (e.g. "37.0" not "0.11.4").
version_start = None
for idx in range(backlog_idx + 1, version_idx + 1):
header = lines[section_starts[idx]].strip()
if VERSION_RE.match(header):
version_start = section_starts[idx]
break
if version_start is None:
print("ERROR: no parseable Version header found", file=sys.stderr)
sys.exit(1)
version_header = lines[version_start].strip()
# ---------------------------------------------------------------
# 2. Parse current version from the newest * Version header
# ---------------------------------------------------------------
vm = VERSION_RE.match(version_header)
current_version = vm.group(1)
major_str, minor_str = current_version.split(".")
current_major = int(major_str)
current_minor = int(minor_str)
new_major, new_minor = bump_version(current_major, current_minor, kind)
new_version = f"{new_major}.{new_minor}"
# ---------------------------------------------------------------
# 3. Collect ** DONE items from the Backlog section
# ---------------------------------------------------------------
backlog_lines = lines[backlog_start:backlog_end]
# Split Backlog into items at each ** line (skip the section header)
items = [] # list of (start_idx, end_idx, is_done)
item_start = None
for i in range(1, len(backlog_lines)):
if ITEM_HEADER_RE.match(backlog_lines[i]):
if item_start is not None:
items.append((item_start, i, backlog_lines[item_start].startswith("** DONE")))
item_start = i
if item_start is not None:
items.append((item_start, len(backlog_lines), backlog_lines[item_start].startswith("** DONE")))
done_items = [(s, e) for s, e, is_done in items if is_done]
kept_items = [(s, e) for s, e, is_done in items if not is_done]
if not done_items:
print("No DONE items found in Backlog — nothing to release.")
sys.exit(1)
# ---------------------------------------------------------------
# 4. Build the new Version section text
# ---------------------------------------------------------------
version_section_lines = [f"* Version {new_version} [{len(done_items)}/{len(done_items)}]\n"]
for s, e in done_items:
version_section_lines.extend(backlog_lines[s:e])
# ---------------------------------------------------------------
# 5. Build updated Backlog section
# ---------------------------------------------------------------
backlog_header_line = backlog_lines[0]
bm = BACKLOG_RE.match(backlog_header_line.strip())
if not bm:
print(f"ERROR: could not parse backlog header: {backlog_header_line!r}", file=sys.stderr)
sys.exit(1)
done_count = int(bm.group(1))
total_count = int(bm.group(2))
tags = bm.group(3)
new_done = done_count - len(done_items)
new_total = total_count - len(done_items)
new_backlog_header = f"* Backlog [{new_done}/{new_total}]{tags}\n"
backlog_body = []
for s, e in kept_items:
backlog_body.extend(backlog_lines[s:e])
# ---------------------------------------------------------------
# 6. Assemble the new file
# ---------------------------------------------------------------
before_backlog = lines[:backlog_start]
after_backlog = lines[backlog_end:version_start]
# Everything from the first Version section onwards
from_version = lines[version_start:]
output = (
before_backlog
+ [new_backlog_header]
+ backlog_body
+ version_section_lines
+ ["\n"]
+ after_backlog
+ from_version
)
# ---------------------------------------------------------------
# 7. Update pyproject.toml
# ---------------------------------------------------------------
pyproject = PYPROJECT_FILE.read_text()
pyproject = re.sub(
r'^version = "[\d.]+"',
f'version = "{new_version}"',
pyproject,
count=1,
flags=re.MULTILINE,
)
# ---------------------------------------------------------------
# 8. Write files
# ---------------------------------------------------------------
PROJECT_FILE.write_text("".join(output))
PYPROJECT_FILE.write_text(pyproject)
# ---------------------------------------------------------------
# 9. Build commit body from done item titles
# ---------------------------------------------------------------
commit_lines = []
for s, e in done_items:
title = parse_done_line(backlog_lines[s])
if title:
commit_lines.append(f"- {title}")
commit_body = "\n".join(commit_lines)
commit_message = f"[release] Bump to version {new_version}\n\n{commit_body}"
# ---------------------------------------------------------------
# 10. Git commit + tag
# ---------------------------------------------------------------
subprocess.run(["git", "add", str(PROJECT_FILE), str(PYPROJECT_FILE)], check=True)
subprocess.run(["git", "commit", "-m", commit_message], check=True)
subprocess.run(["git", "tag", new_version], check=True)
print(f"\nReleased v{new_version} — tag {new_version} created.")
print(f"Moved {len(done_items)} DONE item(s) from Backlog to Version section.")
if __name__ == "__main__":
main()

View File

@ -1,3 +1,4 @@
import tempfile
from datetime import timedelta
import pytest
@ -128,6 +129,23 @@ class TestBirdingCSVImportModel:
assert imp.import_type == "Birding CSV"
assert "Birding" in str(imp)
def test_record_error(self, db, user):
imp = BirdingCSVImport.objects.create(user=user)
assert imp.error_log is None
imp.record_error("test error")
imp.refresh_from_db()
assert imp.error_log is not None
assert "test error" in imp.error_log
def test_record_error_appends(self, db, user):
imp = BirdingCSVImport.objects.create(user=user)
imp.record_error("first error")
imp.record_error("second error")
imp.refresh_from_db()
assert imp.error_log.count("\n") == 1
assert "first error" in imp.error_log
assert "second error" in imp.error_log
@pytest.mark.django_db(transaction=True)
def test_process_via_model(self, user, birding_csv_file):
imp = BirdingCSVImport.objects.create(user=user)
@ -137,3 +155,35 @@ class TestBirdingCSVImportModel:
imp.refresh_from_db()
assert imp.process_count == 1
assert imp.processed_finished is not None
def test_record_error_on_bad_csv(self, user, db):
content = """Species,Count,Location,Observation Type,Observation Date,Start Time,Duration,Distance,Area,Party Size,Complete Checklist,# of species,Details
Canada Goose,6,Test Park,Stationary,"Bad Date",4:15 PM,9 minute(s),,,4,true,4 species,
"""
with tempfile.NamedTemporaryFile(
mode="w", suffix=".csv", delete=False, encoding="utf-8"
) as f:
f.write(content)
file_path = f.name
errors = []
scrobbles = import_birding_csv(file_path, user.id, record_error=errors.append)
assert len(scrobbles) == 0
assert len(errors) == 1
assert "Could not parse date/time" in errors[0]
def test_record_error_on_bad_location(self, user, db):
content = """Species,Count,Location,Observation Type,Observation Date,Start Time,Duration,Distance,Area,Party Size,Complete Checklist,# of species,Details
Canada Goose,6,,Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,
"""
with tempfile.NamedTemporaryFile(
mode="w", suffix=".csv", delete=False, encoding="utf-8"
) as f:
f.write(content)
file_path = f.name
errors = []
scrobbles = import_birding_csv(file_path, user.id, record_error=errors.append)
assert len(scrobbles) == 0
assert len(errors) == 1
assert "Skipping rows with no location" in errors[0]

View File

@ -8,7 +8,8 @@ from django.urls import reverse
from django.utils import timezone
from music.models import Album, Artist, Track
from podcasts.models import PodcastEpisode
from scrobbles.models import Scrobble
from scrobbles.models import Scrobble, ShareViewLog
from scrobbles.sqids import encode_scrobble_share
from tasks.models import Task
@ -143,9 +144,10 @@ def test_mopidy_track_webhook_creates_track_and_scrobble(
album = Album.objects.create(name="Sublime", album_artist=artist)
track = Track.objects.create(
title="Same in the End",
artist=artist,
artist_fk=artist,
base_run_time_seconds=60,
)
track.artists.add(artist)
track.albums.add(album)
mock_artist_fc.return_value = artist
@ -181,9 +183,10 @@ def test_jellyfin_track_webhook_creates_track_and_scrobble(
album = Album.objects.create(name="Emotion", album_artist=artist)
track = Track.objects.create(
title="Emotion",
artist=artist,
artist_fk=artist,
base_run_time_seconds=60,
)
track.artists.add(artist)
track.albums.add(album)
mock_artist_fc.return_value = artist
@ -225,9 +228,10 @@ def test_mopidy_track_webhook_stores_raw_data(
album = Album.objects.create(name="Sublime", album_artist=artist)
track = Track.objects.create(
title="Same in the End",
artist=artist,
artist_fk=artist,
base_run_time_seconds=60,
)
track.artists.add(artist)
track.albums.add(album)
mock_artist_fc.return_value = artist
@ -265,10 +269,11 @@ def test_mopidy_track_webhook_stores_album_id(
album = Album.objects.create(name="Sublime", album_artist=artist)
track = Track.objects.create(
title="Same in the End",
artist=artist,
artist_fk=artist,
album=album,
base_run_time_seconds=60,
)
track.artists.add(artist)
mock_artist_fc.return_value = artist
mock_track_fc.return_value = track
@ -303,9 +308,10 @@ def test_jellyfin_track_webhook_stores_raw_data(
album = Album.objects.create(name="Emotion", album_artist=artist)
track = Track.objects.create(
title="Emotion",
artist=artist,
artist_fk=artist,
base_run_time_seconds=60,
)
track.artists.add(artist)
track.albums.add(album)
mock_artist_fc.return_value = artist
@ -347,11 +353,11 @@ def test_jellyfin_track_webhook_stores_album_id(
album = Album.objects.create(name="Emotion", album_artist=artist)
track = Track.objects.create(
title="Emotion",
artist=artist,
artist_fk=artist,
album=album,
base_run_time_seconds=60,
)
track.albums.add(album)
track.artists.add(artist)
mock_artist_fc.return_value = artist
mock_track_fc.return_value = track
@ -507,12 +513,13 @@ def test_scrobble_detail_view_with_notes_as_flat_list(client):
task=task,
media_type="Task",
user=user,
visibility="public",
log={
"notes": ["First note", "Second note"],
"description": "Test description",
},
)
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
url = reverse("scrobbles:detail", kwargs={"pk": scrobble.id})
response = client.get(url)
assert response.status_code == 200
assert "First note" in response.content.decode()
@ -529,6 +536,7 @@ def test_scrobble_detail_view_with_notes_as_dict_timestamps(client):
task=task,
media_type="Task",
user=user,
visibility="public",
log={
"notes": [
{"2024-01-01 10:00:00": "Note at first timestamp"},
@ -537,7 +545,7 @@ def test_scrobble_detail_view_with_notes_as_dict_timestamps(client):
"description": "Test description",
},
)
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
url = reverse("scrobbles:detail", kwargs={"pk": scrobble.id})
response = client.get(url)
assert response.status_code == 200
content = response.content.decode()
@ -557,6 +565,7 @@ def test_scrobble_detail_view_with_notes_and_labels(client):
task=task,
media_type="Task",
user=user,
visibility="public",
log={
"notes": [
{"2024-01-01 10:00:00": "Note with label"},
@ -565,7 +574,7 @@ def test_scrobble_detail_view_with_notes_and_labels(client):
"description": "Test description",
},
)
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
url = reverse("scrobbles:detail", kwargs={"pk": scrobble.id})
response = client.get(url)
assert response.status_code == 200
content = response.content.decode()
@ -588,7 +597,7 @@ def test_scrobble_detail_view_post_updates_log(client):
"description": "Original description",
},
)
url = reverse("scrobbles:detail", kwargs={"uuid": scrobble.uuid})
url = reverse("scrobbles:detail", kwargs={"pk": scrobble.id})
client.force_login(user)
response = client.post(
@ -602,7 +611,8 @@ def test_scrobble_detail_view_post_updates_log(client):
scrobble.refresh_from_db()
assert scrobble.log["description"] == "Updated description"
assert scrobble.log["notes"] == ["Updated note"]
assert isinstance(scrobble.log["notes"], dict)
assert list(scrobble.log["notes"].values()) == ["Updated note"]
@pytest.mark.skip("Need to refactor")
@ -733,3 +743,293 @@ def test_gps_webhook_creates_location(client, valid_auth_token):
)
assert response.status_code == 200
assert "scrobble_id" in response.data
@pytest.mark.django_db
def test_share_view_shared_visibility(client):
user = get_user_model().objects.create_user(
username="shareuser", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="shared",
timestamp=timezone.now(),
)
url = reverse(
"scrobbles:shared-detail",
kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)},
)
response = client.get(url)
assert response.status_code == 200
@pytest.mark.django_db
def test_share_view_public_visibility(client):
user = get_user_model().objects.create_user(
username="shareuser2", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="public",
timestamp=timezone.now(),
)
url = reverse(
"scrobbles:shared-detail",
kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)},
)
response = client.get(url)
assert response.status_code == 200
@pytest.mark.django_db
def test_share_view_private_visibility_returns_404(client):
user = get_user_model().objects.create_user(
username="shareuser3", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="private",
timestamp=timezone.now(),
)
url = reverse(
"scrobbles:shared-detail",
kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)},
)
response = client.get(url)
assert response.status_code == 404
@pytest.mark.django_db
def test_share_view_invalid_sqid_returns_404(client):
url = reverse("scrobbles:shared-detail", kwargs={"sqid": "InvalidSqid123"})
response = client.get(url)
assert response.status_code == 404
@pytest.mark.django_db
def test_share_view_expired_token_returns_404(client):
user = get_user_model().objects.create_user(
username="shareuser4", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="shared",
timestamp=timezone.now(),
)
old_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
scrobble.regenerate_share_token()
url = reverse("scrobbles:shared-detail", kwargs={"sqid": old_sqid})
response = client.get(url)
assert response.status_code == 404
@pytest.mark.django_db
def test_share_view_increments_count_and_logs_view(client):
user = get_user_model().objects.create_user(
username="shareuser5", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="shared",
timestamp=timezone.now(),
)
assert scrobble.share_view_count == 0
url = reverse(
"scrobbles:shared-detail",
kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)},
)
response = client.get(url)
assert response.status_code == 200
scrobble.refresh_from_db()
assert scrobble.share_view_count == 1
assert ShareViewLog.objects.filter(scrobble=scrobble).count() == 1
log_entry = ShareViewLog.objects.filter(scrobble=scrobble).first()
assert log_entry.ip_address == "127.0.0.1"
assert log_entry.user_agent == ""
assert log_entry.referrer == ""
@pytest.mark.django_db
def test_explore_view_shows_only_public_scrobbles(client):
user = get_user_model().objects.create_user(
username="exploreuser", password="testpass"
)
public_task = Task.objects.create(title="Public Task Title")
shared_task = Task.objects.create(title="Shared Task Title")
private_task = Task.objects.create(title="Private Task Title")
ts = timezone.now()
public_scrobble = Scrobble.objects.create(
task=public_task, media_type="Task", user=user, visibility="public",
timestamp=ts,
)
Scrobble.objects.create(
task=shared_task, media_type="Task", user=user, visibility="shared",
timestamp=ts,
)
Scrobble.objects.create(
task=private_task, media_type="Task", user=user, visibility="private",
timestamp=ts,
)
url = reverse("scrobbles:explore")
response = client.get(url)
assert response.status_code == 200
content = response.content.decode()
assert "Public Task Title" in content
assert "Shared Task Title" not in content
assert "Private Task Title" not in content
@pytest.mark.django_db
def test_change_visibility_owner_can_change(client):
user = get_user_model().objects.create_user(
username="visuser", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="private",
timestamp=timezone.now(),
)
client.force_login(user)
url = reverse("scrobbles:change-visibility", kwargs={"pk": scrobble.id})
response = client.post(url, {"visibility": "shared"})
assert response.status_code == 302
scrobble.refresh_from_db()
assert scrobble.visibility == "shared"
@pytest.mark.django_db
def test_change_visibility_non_owner_gets_404(client):
owner = get_user_model().objects.create_user(
username="owner", password="testpass"
)
other = get_user_model().objects.create_user(
username="other", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=owner, visibility="private",
timestamp=timezone.now(),
)
client.force_login(other)
url = reverse("scrobbles:change-visibility", kwargs={"pk": scrobble.id})
response = client.post(url, {"visibility": "shared"})
assert response.status_code == 404
scrobble.refresh_from_db()
assert scrobble.visibility == "private"
@pytest.mark.django_db
def test_change_visibility_anonymous_redirects_to_login(client):
user = get_user_model().objects.create_user(
username="anontest", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="private",
timestamp=timezone.now(),
)
url = reverse("scrobbles:change-visibility", kwargs={"pk": scrobble.id})
response = client.post(url, {"visibility": "shared"})
assert response.status_code == 302
assert "/login/" in response.url
@pytest.mark.django_db
def test_regenerate_share_token_invalidates_old_sqid(client):
user = get_user_model().objects.create_user(
username="regentest", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="shared",
timestamp=timezone.now(),
)
old_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
client.force_login(user)
url = reverse("scrobbles:regenerate-share-token", kwargs={"pk": scrobble.id})
response = client.post(url)
assert response.status_code == 302
scrobble.refresh_from_db()
assert scrobble.share_token_version == 1
old_url = reverse("scrobbles:shared-detail", kwargs={"sqid": old_sqid})
old_response = client.get(old_url)
assert old_response.status_code == 404
new_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
new_url = reverse("scrobbles:shared-detail", kwargs={"sqid": new_sqid})
new_response = client.get(new_url)
assert new_response.status_code == 200
@pytest.mark.django_db
def test_share_analytics_owner_can_view(client):
user = get_user_model().objects.create_user(
username="analyticsuser", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="shared",
timestamp=timezone.now(),
)
client.force_login(user)
url = reverse("scrobbles:share-analytics", kwargs={"pk": scrobble.id})
response = client.get(url)
assert response.status_code == 200
@pytest.mark.django_db
def test_share_analytics_non_owner_gets_404(client):
owner = get_user_model().objects.create_user(
username="analyticsowner", password="testpass"
)
other = get_user_model().objects.create_user(
username="analyticsother", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=owner, visibility="shared",
timestamp=timezone.now(),
)
client.force_login(other)
url = reverse("scrobbles:share-analytics", kwargs={"pk": scrobble.id})
response = client.get(url)
assert response.status_code == 404
@pytest.mark.django_db
def test_share_analytics_shows_view_logs(client):
user = get_user_model().objects.create_user(
username="analyticsviews", password="testpass"
)
task = Task.objects.create(title="Test Task")
scrobble = Scrobble.objects.create(
task=task, media_type="Task", user=user, visibility="shared",
timestamp=timezone.now(),
)
sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
share_url = reverse("scrobbles:shared-detail", kwargs={"sqid": sqid})
client.get(share_url)
client.get(share_url)
client.force_login(user)
url = reverse("scrobbles:share-analytics", kwargs={"pk": scrobble.id})
response = client.get(url)
assert response.status_code == 200
content = response.content.decode()
assert "127.0.0.1" in content
assert ShareViewLog.objects.filter(scrobble=scrobble).count() == 2

View File

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

View File

@ -27,6 +27,7 @@ class BeerAdmin(admin.ModelAdmin):
"uuid",
"title",
)
raw_id_fields = ("styles", "producer")
ordering = ("-created",)
search_fields = ("title",)
inlines = [

View File

@ -26,5 +26,6 @@ class BirdingLocationAdmin(admin.ModelAdmin):
@admin.register(BirdingCSVImport)
class BirdingCSVImportAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = ("uuid", "process_count", "processed_finished", "processing_started")
list_display = ("uuid", "process_count", "processed_finished", "processing_started", "error_log")
raw_id_fields = ("user",)
ordering = ("-created",)

View File

@ -2,12 +2,15 @@ import csv
import logging
import re
from collections import defaultdict
from datetime import datetime, timedelta
from datetime import timedelta
from dateutil import parser
from django.contrib.auth import get_user_model
from birds.models import Bird, BirdSightingEntry, BirdSightingLogData, BirdingLocation
from scrobbles.models import Scrobble
from scrobbles.notifications import ScrobbleNtfyNotification
logger = logging.getLogger(__name__)
User = get_user_model()
@ -34,11 +37,12 @@ def parse_coords(location_str):
def parse_timestamp(date_str, time_str):
try:
dt = datetime.strptime(f"{date_str} {time_str}", "%B %d, %Y %I:%M %p")
dt_str = f"{date_str} {time_str}".strip()
dt = parser.parse(dt_str)
return dt
except (ValueError, TypeError):
try:
dt = datetime.strptime(date_str, "%B %d, %Y")
dt = parser.parse(date_str)
return dt
except (ValueError, TypeError):
logger.warning(f"Could not parse date/time: {date_str} {time_str}")
@ -60,7 +64,7 @@ def parse_int(value):
return None
def import_birding_csv(file_path, user_id):
def import_birding_csv(file_path, user_id, record_error=None):
user = User.objects.get(id=user_id)
new_scrobbles = []
@ -79,11 +83,17 @@ def import_birding_csv(file_path, user_id):
for (location_str, date_str, time_str), sighting_rows in groups.items():
if not location_str:
logger.warning("Skipping rows with no location")
msg = "Skipping rows with no location"
logger.warning(msg)
if record_error:
record_error(msg)
continue
timestamp = parse_timestamp(date_str, time_str)
if not timestamp:
msg = f"Could not parse date/time: {date_str} {time_str}"
if record_error:
record_error(msg)
continue
timestamp = user.profile.get_timestamp_with_tz(timestamp)
@ -183,4 +193,6 @@ def import_birding_csv(file_path, user_id):
created = Scrobble.objects.bulk_create(new_scrobbles)
logger.info(f"Created {len(created)} birding scrobbles")
for scrobble in created:
ScrobbleNtfyNotification(scrobble).send()
return created

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-06-08 14:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("birds", "0002_birdingcsvimport"),
]
operations = [
migrations.AddField(
model_name="birdingcsvimport",
name="error_log",
field=models.TextField(blank=True, null=True),
),
]

View File

@ -61,9 +61,7 @@ class BirdSightingLogData(BaseLogData, WithPeopleLogData):
@cached_property
def bird_list(self) -> str:
if self.birds:
return ", ".join(
[BirdSightingEntry(**b).__str__() for b in self.birds]
)
return ", ".join([BirdSightingEntry(**b).__str__() for b in self.birds])
return ""
def as_html(self) -> str:
@ -80,9 +78,7 @@ class BirdSightingLogData(BaseLogData, WithPeopleLogData):
)
if self.area:
html_parts.append(
f'<div class="birding-area">Area: {self.area}</div>'
)
html_parts.append(f'<div class="birding-area">Area: {self.area}</div>')
if self.party_size:
html_parts.append(
@ -105,9 +101,7 @@ class BirdSightingLogData(BaseLogData, WithPeopleLogData):
)
if self.guide:
html_parts.append(
f'<div class="birding-guide">Guide: {self.guide}</div>'
)
html_parts.append(f'<div class="birding-guide">Guide: {self.guide}</div>')
if self.duration_minutes:
html_parts.append(
@ -183,9 +177,7 @@ class Bird(TimeStampedModel):
class BirdingLocation(ScrobblableMixin):
description = models.TextField(**BNULL)
geo_location = models.ForeignKey(
GeoLocation, **BNULL, on_delete=models.DO_NOTHING
)
geo_location = models.ForeignKey(GeoLocation, **BNULL, on_delete=models.DO_NOTHING)
ebird_hotspot_id = models.CharField(max_length=255, **BNULL)
def get_absolute_url(self):
@ -193,7 +185,7 @@ class BirdingLocation(ScrobblableMixin):
@property
def subtitle(self):
return ""
return self.geo_location
@property
def strings(self) -> ScrobblableConstants:
@ -224,6 +216,7 @@ class BirdingCSVImport(TimeStampedModel):
processed_finished = models.DateTimeField(**BNULL)
process_log = models.TextField(**BNULL)
process_count = models.IntegerField(**BNULL)
error_log = models.TextField(**BNULL)
csv_file = models.FileField(upload_to="birding-csv-uploads/", **BNULL)
class Meta:
@ -269,9 +262,7 @@ class BirdingCSVImport(TimeStampedModel):
self.save(update_fields=["process_log", "process_count"])
return
for count, scrobble in enumerate(scrobbles):
scrobble_str = (
f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.media_obj}"
)
scrobble_str = f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.media_obj}"
log_line = f"{scrobble_str}"
if count > 0:
log_line = "\n" + log_line
@ -279,6 +270,14 @@ class BirdingCSVImport(TimeStampedModel):
self.process_count = len(scrobbles)
self.save(update_fields=["process_log", "process_count"])
def record_error(self, error_message):
log_line = f"{timezone.now().isoformat()}: {error_message}"
if self.error_log:
self.error_log += "\n" + log_line
else:
self.error_log = log_line
self.save(update_fields=["error_log"])
def scrobbles(self):
from scrobbles.models import Scrobble
@ -297,6 +296,13 @@ class BirdingCSVImport(TimeStampedModel):
from birds.importer import import_birding_csv
self.mark_started()
scrobbles = import_birding_csv(self.upload_file_path, self.user_id)
self.record_log(scrobbles)
self.mark_finished()
try:
scrobbles = import_birding_csv(
self.upload_file_path, self.user_id, record_error=self.record_error
)
self.record_log(scrobbles)
except Exception as e:
self.record_error(f"Import failed: {e}")
logger.exception(f"Import failed for {self}")
finally:
self.mark_finished()

View File

@ -38,6 +38,7 @@ class BoardGameLocationAdmin(admin.ModelAdmin):
"uuid",
"geo_location",
)
raw_id_fields = ("geo_location",)
ordering = ("-created",)
@ -49,6 +50,7 @@ class BoardGameAdmin(admin.ModelAdmin):
"title",
"published_year",
)
raw_id_fields = ("publisher", "publishers", "designers", "expansion_for_boardgame")
search_fields = ("title",)
ordering = ("-created",)
inlines = [

View File

@ -90,6 +90,26 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
"variant",
}
@classmethod
def override_fields(cls) -> dict:
from scrobbles.forms import NotesDictField
fields = {}
for base in cls.mro()[1:]:
if hasattr(base, "override_fields"):
base_fields = base.override_fields()
fields.update(base_fields)
custom_fields = {
"notes": NotesDictField(required=False),
"location_id": forms.ModelChoiceField(
queryset=BoardGameLocation.objects.all(),
required=False,
widget=forms.Select(),
),
}
fields.update(custom_fields)
return fields
@cached_property
def location(self):
if not self.location_id:
@ -133,23 +153,6 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
return "".join(html_parts)
@classmethod
def override_fields(cls) -> dict:
fields = {}
for base in cls.mro()[1:]:
if hasattr(base, "override_fields"):
base_fields = base.override_fields()
fields.update(base_fields)
custom_fields = {
"location_id": forms.ModelChoiceField(
queryset=BoardGameLocation.objects.all(),
required=False,
widget=forms.Select(),
)
}
fields.update(custom_fields)
return fields
class BoardGamePublisher(TimeStampedModel):
name = models.CharField(max_length=255)
@ -263,8 +266,9 @@ class BoardGame(ScrobblableMixin):
"self", **BNULL, on_delete=models.DO_NOTHING
)
def __str__(self):
return self.title
@property
def subtitle(self) -> str:
return self.publisher
def get_absolute_url(self):
return reverse("boardgames:boardgame_detail", kwargs={"slug": self.uuid})

View File

@ -103,6 +103,7 @@ def import_chess_games_for_user_id(user_id: int, commit: bool = False) -> dict:
"source": "Lichess",
"timezone": user.profile.timezone,
"log": log_data,
"visibility": "private",
}
if commit:
Scrobble.objects.create(**scrobble_dict)

View File

@ -27,6 +27,7 @@ class BookAdmin(admin.ModelAdmin):
"first_publish_year",
"pages",
)
raw_id_fields = ("authors",)
search_fields = ("name",)
ordering = ("-created",)
inlines = [
@ -34,11 +35,11 @@ class BookAdmin(admin.ModelAdmin):
]
def issue_or_volume(self, obj):
return obj.issue_number or obj.volume_number
return obj.subtitle
@admin.register(Paper)
class BookAdmin(admin.ModelAdmin):
class PaperAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"title",
@ -47,6 +48,7 @@ class BookAdmin(admin.ModelAdmin):
"first_publish_year",
"pages",
)
raw_id_fields = ("authors",)
search_fields = ("name",)
ordering = ("-created",)
inlines = [

View File

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

@ -17,6 +17,7 @@ class MediaSourceTag(str, Enum):
LOCG = "source_locg"
KOREADER = "source_koreader"
SEMANTIC_SCHOLAR = "source_semantic_scholar"
AMAZON = "source_amazon"
@classmethod
def choices(cls):

View File

@ -1,9 +1,8 @@
import logging
import re
import sqlite3
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from enum import Enum
from zoneinfo import ZoneInfo
import requests
from books.constants import BOOKS_TITLES_TO_IGNORE
@ -174,7 +173,7 @@ def build_book_map(rows) -> dict:
return book_id_map
def build_page_data(page_rows: list, book_map: dict, user_tz=None) -> dict:
def build_page_data(page_rows: list, book_map: dict) -> 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.
@ -187,18 +186,20 @@ def build_page_data(page_rows: list, book_map: dict, user_tz=None) -> dict:
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"] = {}
book_map[koreader_book_id].setdefault("pages", [])
page_number = page_row[KoReaderPageStatColumn.PAGE.value]
duration = page_row[KoReaderPageStatColumn.DURATION.value]
start_ts = page_row[KoReaderPageStatColumn.START_TIME.value]
book_map[koreader_book_id]["pages"][page_number] = {
"duration": duration,
"start_ts": start_ts,
"end_ts": start_ts + duration,
}
book_map[koreader_book_id]["pages"].append(
{
"page_number": 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)}")
return book_map
@ -216,7 +217,6 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
pages_not_found.append(book_id)
continue
should_create_scrobble = False
scrobble_page_data = {}
playback_position_seconds = 0
prev_page_stats = {}
@ -225,11 +225,12 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
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"],
book_map[koreader_book_id]["pages"],
key=lambda x: x["start_ts"],
)
for cur_page_number, stats in ordered_pages:
for stats in ordered_pages:
page_number = stats["page_number"]
pages_processed += 1
seconds_from_last_page = 0
@ -243,46 +244,52 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
)
end_of_reading = pages_processed == total_pages_read
big_jump_to_this_page = (cur_page_number - last_page_number) > 10
big_jump_to_this_page = (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
should_create_scrobble = (
is_session_gap and not big_jump_to_this_page
) or end_of_reading
# Always accumulate the current page first
scrobble_page_data[page_number] = stats
if should_create_scrobble:
# For end-of-reading, the current page is already accumulated
# and belongs in this scrobble. For a session gap, remove the
# current page from this scrobble — it starts a new session.
if is_session_gap and not end_of_reading:
del scrobble_page_data[page_number]
scrobble_page_data = dict(
sorted(
scrobble_page_data.items(),
key=lambda x: x[1]["start_ts"],
)
)
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:
if not scrobble_page_data:
logger.error(
"Could not process book, no page data found",
extra={"scrobble_page_data": scrobble_page_data},
)
continue
first_page = next(iter(scrobble_page_data.values()))
last_page = scrobble_page_data[
list(scrobble_page_data.keys())[-1]
]
timestamp = user.profile.get_timestamp_with_tz(
datetime.fromtimestamp(int(first_page.get("start_ts")))
datetime.fromtimestamp(
int(first_page.get("start_ts"))
)
)
stop_timestamp = user.profile.get_timestamp_with_tz(
datetime.fromtimestamp(int(last_page.get("end_ts")))
datetime.fromtimestamp(
int(last_page.get("end_ts"))
)
)
# Adjust for Daylight Saving Time
# if timestamp.dst() == timedelta(
# 0
# ) or stop_timestamp.dst() == timedelta(0):
# timestamp = timestamp - timedelta(hours=1)
# stop_timestamp = stop_timestamp - timedelta(hours=1)
scrobble = Scrobble.objects.filter(
timestamp=timestamp,
book_id=book_id,
@ -291,7 +298,7 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
if not scrobble:
logger.info(
f"Queueing scrobble for {book_id}, page {cur_page_number}"
f"Queueing scrobble for {book_id}, page {page_number}"
)
log_data = {
"koreader_hash": book_dict.get("hash"),
@ -318,16 +325,18 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
timezone=tz,
)
)
# Then start over
should_create_scrobble = False
# Then start over for the next session
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
# For session gaps, re-add the current page as the
# beginning of the next session's accumulation
if is_session_gap and not end_of_reading:
scrobble_page_data[page_number] = stats
last_page_number = cur_page_number
last_page_number = 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
@ -335,13 +344,16 @@ def build_scrobbles_from_book_map(book_map: dict, user: "User") -> list["Scrobbl
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 media completion.
Uses the long_play_last_scrobble FK chain to accumulate time.
Consider using the recompute_long_play_seconds management command instead.
"""
for scrobble in scrobbles:
# But if there's a next scrobble, set pages read to their starting page
if scrobble.previous and not scrobble.previous.long_play_complete:
if scrobble.long_play_last_scrobble and not scrobble.long_play_last_scrobble.long_play_complete:
scrobble.long_play_seconds = scrobble.playback_position_seconds + (
scrobble.previous.long_play_seconds or 0
scrobble.long_play_last_scrobble.long_play_seconds or 0
)
else:
scrobble.long_play_seconds = scrobble.playback_position_seconds
@ -357,9 +369,6 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
new_scrobbles = []
user = User.objects.filter(id=user_id).first()
tz = ZoneInfo("UTC")
if user:
tz = user.profile.tzinfo
is_os_file = "https://" not in file_path
if is_os_file:
@ -375,7 +384,6 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
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:
@ -392,7 +400,7 @@ def process_koreader_sqlite_file(file_path, user_id) -> list:
_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)
book_map = build_page_data(rows, book_map)
new_scrobbles = build_scrobbles_from_book_map(book_map, user)
logger.info(f"Creating {len(new_scrobbles)} new scrobbles")

View File

@ -0,0 +1,301 @@
import logging
import time
from django.core.management.base import BaseCommand
from django.db import transaction
from books.constants import READCOMICSONLINE_URL
logger = logging.getLogger(__name__)
MISSING_ALL = [
"cover",
"summary",
"isbn",
"pages",
"language",
"publisher",
"publish_year",
]
MISSING_GROUPS = {
"cover": lambda b: not bool(b.cover),
"summary": lambda b: not b.summary,
"isbn": lambda b: not b.isbn_13 and not b.isbn_10,
"pages": lambda b: b.pages is None,
"language": lambda b: not b.language,
"publisher": lambda b: not b.publisher,
"publish_year": lambda b: b.first_publish_year is None,
}
def _book_matches(book, flags):
if not flags:
return False
for flag in flags:
fn = MISSING_GROUPS.get(flag)
if fn and fn(book):
return True
return False
class Command(BaseCommand):
help = "Backfill missing metadata on books from Google Books, OpenLibrary, and ComicVine"
def add_arguments(self, parser):
parser.add_argument(
"--commit",
action="store_true",
help="Commit changes to the database",
)
parser.add_argument(
"--batch-size",
type=int,
default=100,
help="Number of books to process per batch (default: 100)",
)
parser.add_argument(
"--sleep",
type=float,
default=0.5,
help="Seconds to sleep between API calls (default: 0.5)",
)
for flag in MISSING_ALL:
parser.add_argument(
f"--missing-{flag}",
dest="missing_flags",
action="append_const",
const=flag,
help=f"Process books missing {flag}",
)
parser.add_argument(
"--comics-only",
action="store_true",
help="Only process books with a readcomicsonline.ru URL",
)
parser.add_argument(
"--all",
action="store_true",
dest="all_missing",
help="Process books missing any metadata field",
)
def handle(self, *args, **options):
from books.models import Book
commit = options["commit"]
batch_size = options["batch_size"]
sleep_secs = options["sleep"]
flags = options.get("missing_flags") or []
comics_only = options["comics_only"]
all_missing = options["all_missing"]
if all_missing:
flags = MISSING_ALL
if not flags and not comics_only:
self.stdout.write(
"No filters specified. Use --all, --missing-*, or --comics-only."
)
return
qs = Book.objects.all()
if comics_only:
qs = qs.filter(readcomics_url__isnull=False)
if flags:
qs = [b for b in qs.iterator() if _book_matches(b, flags)]
else:
qs = list(qs)
total = len(qs)
self.stdout.write(f"Found {total} books to process")
if not commit:
self.stdout.write(
"Dry run — no API calls will be made. Use --commit to run lookups."
)
return
enriched = 0
skipped = 0
stats = {
"cover_fixed": 0,
"summary_fixed": 0,
"isbn_fixed": 0,
"pages_fixed": 0,
"language_fixed": 0,
"publisher_fixed": 0,
"publish_year_fixed": 0,
}
for batch_num, offset in enumerate(range(0, len(qs), batch_size)):
batch = qs[offset : offset + batch_size]
for book in batch:
result = self._enrich_book(book, sleep_secs)
if result:
enriched += 1
for key in stats:
if result.get(key):
stats[key] += 1
else:
skipped += 1
self.stdout.write(
f" Batch {batch_num + 1}: {offset + len(batch)}/{total}"
f"enriched: {enriched}, skipped: {skipped}"
)
self.stdout.write(
f"\nResults (commit={commit}):\n"
f" Books enriched: {enriched}\n"
f" Books skipped: {skipped}\n"
f" Covers fixed: {stats['cover_fixed']}\n"
f" Summaries fixed:{stats['summary_fixed']}\n"
f" ISBNs fixed: {stats['isbn_fixed']}\n"
f" Pages fixed: {stats['pages_fixed']}\n"
f" Languages fixed:{stats['language_fixed']}\n"
f" Publishers fixed:{stats['publisher_fixed']}\n"
f" Publish yrs fixed: {stats['publish_year_fixed']}"
)
def _enrich_book(self, book, sleep_secs):
from books.sources.comicvine import (
lookup_comic_from_comicvine,
lookup_issue_by_comicvine_id,
)
from books.sources.google import lookup_book_from_google
from books.sources.openlibrary import lookup_book_from_openlibrary as lookup_book_from_ol
title = book.original_title or book.title
author_name = book.author.name if book.author else None
book_dict = {}
cv_data = None
if book.comicvine_id:
cv_data = lookup_issue_by_comicvine_id(str(book.comicvine_id))
if not cv_data:
cv_data = lookup_comic_from_comicvine(title)
if cv_data:
book_dict.update(cv_data)
ol_data = lookup_book_from_ol(title, author=author_name)
time.sleep(sleep_secs)
google_data = lookup_book_from_google(title)
if ol_data:
for k, v in ol_data.items():
book_dict.setdefault(k, v)
if google_data:
for k, v in google_data.items():
if v:
book_dict.setdefault(k, v)
if not book_dict:
return None
changed = self._apply(book, book_dict, title)
return changed
def _apply(self, book, data, title):
changed = {
"cover_fixed": False,
"summary_fixed": False,
"isbn_fixed": False,
"pages_fixed": False,
"language_fixed": False,
"publisher_fixed": False,
"publish_year_fixed": False,
}
update_fields = []
cover_url = data.pop("cover_url", "")
if data.get("summary") and not book.summary:
book.summary = data["summary"]
update_fields.append("summary")
changed["summary_fixed"] = True
if data.get("isbn_13") and not book.isbn_13:
book.isbn_13 = data["isbn_13"]
update_fields.append("isbn_13")
changed["isbn_fixed"] = True
if data.get("isbn_10") and not book.isbn_10:
book.isbn_10 = data["isbn_10"]
update_fields.append("isbn_10")
changed["isbn_fixed"] = True
if data.get("pages") and book.pages is None:
book.pages = data["pages"]
update_fields.append("pages")
changed["pages_fixed"] = True
if data.get("language") and not book.language:
book.language = data["language"]
update_fields.append("language")
changed["language_fixed"] = True
if data.get("publisher") and not book.publisher:
book.publisher = data["publisher"]
update_fields.append("publisher")
changed["publisher_fixed"] = True
if data.get("first_publish_year") and book.first_publish_year is None:
book.first_publish_year = data["first_publish_year"]
update_fields.append("first_publish_year")
changed["publish_year_fixed"] = True
if data.get("openlibrary_id") and not book.openlibrary_id:
book.openlibrary_id = data["openlibrary_id"]
update_fields.append("openlibrary_id")
if data.get("comicvine_id") and not book.comicvine_id:
book.comicvine_id = data["comicvine_id"]
update_fields.append("comicvine_id")
if data.get("issue_number") and book.issue_number is None:
book.issue_number = data["issue_number"]
update_fields.append("issue_number")
if data.get("volume_number") and book.volume_number is None:
book.volume_number = data["volume_number"]
update_fields.append("volume_number")
if data.get("volume") and not book.volume:
book.volume = data["volume"]
update_fields.append("volume")
if data.get("volume_comicvine_id") and not book.volume_comicvine_id:
book.volume_comicvine_id = data["volume_comicvine_id"]
update_fields.append("volume_comicvine_id")
if update_fields:
book.save(update_fields=update_fields)
self.stdout.write(f" [ENRICHED] {book}{', '.join(update_fields)}")
if cover_url and not book.cover:
book.save_image_from_url(cover_url)
if book.cover:
changed["cover_fixed"] = True
self.stdout.write(f" [COVER] {book} — cover saved from source")
genres = data.pop("genres", data.pop("generes", []))
if genres:
existing = set(book.genre.names())
new_genres = [g for g in genres if g not in existing]
if new_genres:
book.genre.add(*new_genres)
self.stdout.write(f" [GENRES] {book} — added {len(new_genres)} genres")
tags = data.pop("tags", [])
if tags:
existing_tags = set(book.tags.names())
new_tags = [t for t in tags if t not in existing_tags]
if new_tags:
book.tags.add(*new_tags)
self.stdout.write(f" [TAGS] {book} — added {', '.join(new_tags)}")
return changed if any(changed.values()) else None

View File

@ -0,0 +1,130 @@
import logging
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.db import transaction
from books.koreader import SESSION_GAP_SECONDS, fix_long_play_stats_for_scrobbles
logger = logging.getLogger(__name__)
SESSION_GAP = timedelta(seconds=SESSION_GAP_SECONDS)
def _page_data_keys(pages):
return sorted(int(k) for k in (pages or {}))
class Command(BaseCommand):
help = "Merge orphaned 1-page KOReader scrobbles into the preceding scrobble"
def add_arguments(self, parser):
parser.add_argument(
"--commit",
action="store_true",
help="Commit changes to the database",
)
def handle(self, *args, **options):
from scrobbles.models import Scrobble
commit = options["commit"]
qs = Scrobble.objects.filter(
media_type="Book", source="KOReader"
).order_by("book_id", "timestamp")
if not qs.exists():
self.stdout.write("No KOReader book scrobbles found.")
return
merged = 0
affected_books = set()
# Group by book_id manually since we're iterating in order
book_scrobbles = {}
for s in qs:
book_scrobbles.setdefault(s.book_id, []).append(s)
if not commit:
self.stdout.write("Dry run — no changes will be saved. Use --commit to apply.")
for book_id, scrobbles in book_scrobbles.items():
batch_merged = 0
i = 0
while i < len(scrobbles) - 1:
current = scrobbles[i]
orphan = scrobbles[i + 1]
orphan_pages = orphan.logdata.page_data if orphan.logdata else {}
orphan_keys = _page_data_keys(orphan_pages)
if len(orphan_keys) != 1:
i += 1
continue
current_pages = current.logdata.page_data if current.logdata else {}
current_keys = _page_data_keys(current_pages)
if not current_keys:
i += 1
continue
orphan_page_num = orphan_keys[0]
current_last_page = current_keys[-1]
if orphan_page_num != current_last_page + 1:
i += 1
continue
# Check that the orphan is close enough in time
gap = orphan.timestamp - current.stop_timestamp
if gap > SESSION_GAP:
i += 1
continue
# Merge orphan into current
current_pages[str(orphan_page_num)] = orphan_pages[str(orphan_page_num)]
current.log["page_data"] = current_pages
current.log["pages_read"] = len(current_pages)
current.stop_timestamp = orphan.stop_timestamp
current.playback_position_seconds += orphan.playback_position_seconds
affected_books.add(book_id)
if commit:
with transaction.atomic():
current.save(
update_fields=[
"log",
"stop_timestamp",
"playback_position_seconds",
]
)
orphan.delete()
merged += 1
batch_merged += 1
scrobbles.pop(i + 1)
if batch_merged:
self.stdout.write(
f" Book {book_id}: merged {batch_merged} orphan scrobble(s)"
)
self.stdout.write(f"\nTotal orphans merged: {merged}")
if commit and affected_books:
self.stdout.write("Recalculating long_play_stats for affected books...")
for book_id in affected_books:
scrobbles_to_fix = (
Scrobble.objects.filter(book_id=book_id, source="KOReader")
.order_by("timestamp")
)
fix_long_play_stats_for_scrobbles(list(scrobbles_to_fix))
self.stdout.write(f"Fixed stats for {len(affected_books)} books.")
if not commit:
self.stdout.write(
f"\nWould merge {merged} orphan scrobble(s) across "
f"{len(affected_books)} book(s)."
)

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.29 on 2026-06-15 16:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("books", "0036_alter_book_genre_alter_paper_genre"),
]
operations = [
migrations.AddField(
model_name="book",
name="volume",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="book",
name="volume_comicvine_id",
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -4,6 +4,7 @@ from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from uuid import uuid4
from zoneinfo import ZoneInfo
import requests
from books.constants import MediaSourceTag, READCOMICSONLINE_URL
@ -19,8 +20,10 @@ from books.openlibrary import (
from books.sources.comicvine import (
ComicVineClient,
lookup_comic_from_comicvine,
lookup_issue_by_comicvine_id,
)
from books.sources.google import lookup_book_from_google
from books.sources.amazon import lookup_book_from_amazon
from books.sources.openlibrary import (
lookup_book_from_openlibrary as lookup_book_from_ol,
)
@ -71,7 +74,7 @@ class BookLogData(BaseLogData, LongPlayLogData):
_excluded_fields = {"koreader_hash", "page_data"}
def avg_seconds_per_page(self):
if self.page_data:
if self.page_data and isinstance(self.page_data, dict):
total_duration = 0
for page_num, stats in self.page_data.items():
total_duration += stats.get("duration", 0)
@ -149,6 +152,8 @@ class Book(LongPlayScrobblableMixin):
first_sentence = models.TextField(**BNULL)
# ComicVine
comicvine_id = models.CharField(max_length=255, **BNULL)
volume = models.CharField(max_length=255, **BNULL)
volume_comicvine_id = models.CharField(max_length=255, **BNULL)
readcomics_url = models.CharField(max_length=255, **BNULL)
next_readcomics_url = models.CharField(max_length=255, **BNULL)
issue_number = models.IntegerField(**BNULL)
@ -173,11 +178,10 @@ class Book(LongPlayScrobblableMixin):
genre = TaggableManager(through=ObjectWithGenres, blank=True, verbose_name="Genre")
def __str__(self) -> str:
if self.issue_number and "Issue" not in str(self.title):
return f"{self.title} - Issue {self.issue_number}"
if self.volume_number and "Volume" not in str(self.title):
return f"{self.title} - Volume {self.volume_number}"
return f"{self.title}"
if not self.subtitle:
return self.title
return f"{self.title} - {self.subtitle}"
def save(self, *args, **kwargs):
if self.pages:
@ -188,7 +192,18 @@ class Book(LongPlayScrobblableMixin):
@property
def subtitle(self):
return f" by {self.author}"
subtitle_parts = []
if self.author:
subtitle_parts.append(self.author.name)
if self.issue_number and "Issue" not in str(self.title):
subtitle_parts.append(f"Issue {self.issue_number}")
if self.volume_number and "Volume" not in str(self.title):
subtitle_parts.append(f"Volume {self.volume_number}")
if len(subtitle_parts) > 1:
return " / ".join(subtitle_parts)
if len(subtitle_parts) == 1:
return subtitle_parts[0]
return ""
@property
def strings(self) -> ScrobblableConstants:
@ -210,7 +225,7 @@ class Book(LongPlayScrobblableMixin):
@property
def resume_start_url(self):
return reverse("scrobbles:start", kwargs={"uuid": self.uuid}) + "?resume=1"
return reverse("scrobbles:start", kwargs={"media_uuid": self.uuid}) + "?resume=1"
@classmethod
def get_from_comicvine(
@ -218,36 +233,24 @@ class Book(LongPlayScrobblableMixin):
) -> "Book":
book, created = cls.objects.get_or_create(title=title)
if not created:
if not created and not overwrite:
return book
book_dict = lookup_comic_from_comicvine(title)
if not book_dict:
return book
if created or overwrite:
author_list = []
author_dicts = book_dict.pop("author_dicts")
if author_dicts:
for author_dict in author_dicts:
if author_dict.get("authorId"):
author, a_created = Author.objects.get_or_create(
semantic_id=author_dict.get("authorId")
)
author_list.append(author)
if a_created:
author.name = author_dict.get("name")
author.save()
# TODO enrich author?
...
tags = book_dict.pop("tags", [])
genres = book_dict.pop("genres", book_dict.pop("generes", []))
for k, v in book_dict.items():
setattr(book, k, v)
book.save()
for k, v in book_dict.items():
setattr(book, k, v)
book.save()
if author_list:
book.authors.add(*author_list)
genres = book_dict.pop("genres", [])
if genres:
book.genre.add(*genres)
if genres:
book.genre.add(*genres)
if tags:
book.tags.add(*tags)
return book
@classmethod
@ -258,6 +261,7 @@ class Book(LongPlayScrobblableMixin):
url: str = "",
enrich: bool = True,
commit: bool = True,
amazon_id: str | None = None,
):
"""Given a title, get a Book instance.
@ -281,24 +285,50 @@ class Book(LongPlayScrobblableMixin):
book_dict = None
source_tag = None
tried_comicvine = False
if READCOMICSONLINE_URL in url:
book_dict = lookup_comic_from_comicvine(title)
tried_comicvine = True
if book_dict:
source_tag = MediaSourceTag.COMICVINE
book_dict["readcomics_url"] = get_comic_issue_url(url)
book_dict["next_readcomics_url"] = next_url_if_exists(
book_dict["readcomics_url"]
)
book_dict["readcomics_url"] = get_comic_issue_url(url)
book_dict["next_readcomics_url"] = next_url_if_exists(
book_dict["readcomics_url"]
)
if not book_dict:
book_dict = lookup_book_from_ol(title, author=author)
if book_dict:
book_dict = {}
ol_data = lookup_book_from_ol(title, author=author)
google_data = lookup_book_from_google(title)
if ol_data:
book_dict.update(ol_data)
source_tag = MediaSourceTag.OPENLIBRARY
if not book_dict:
book_dict = lookup_book_from_google(title)
if book_dict:
if google_data:
for k, v in google_data.items():
if v:
book_dict.setdefault(k, v)
source_tag = MediaSourceTag.GOOGLE_BOOKS
if ol_data and ol_data.get("cover_url"):
book_dict["cover_url"] = ol_data["cover_url"]
# Always try ComicVine as a fallback — it may recognize books that
# OL/Google don't flag as comics
if not tried_comicvine:
cv_data = lookup_comic_from_comicvine(title)
if cv_data:
for k, v in cv_data.items():
if v:
book_dict.setdefault(k, v)
source_tag = MediaSourceTag.COMICVINE
# Try Amazon PAAPI as a fallback when given an ASIN
if amazon_id and not book_dict:
amazon_data = lookup_book_from_amazon(amazon_id)
if amazon_data:
book_dict.update(amazon_data)
source_tag = MediaSourceTag.AMAZON
if not book_dict:
logger.warning(
@ -310,6 +340,7 @@ class Book(LongPlayScrobblableMixin):
authors = book_dict.pop("authors", [])
cover_url = book_dict.pop("cover_url", "")
genres = book_dict.pop("genres", book_dict.pop("generes", []))
tags = book_dict.pop("tags", [])
if authors:
for author_str in authors:
@ -329,6 +360,8 @@ class Book(LongPlayScrobblableMixin):
book.save_image_from_url(cover_url)
if genres:
book.genre.add(*genres)
if tags:
book.tags.add(*tags)
book.authors.add(*author_list)
if source_tag:
book.tags.add(source_tag.value)
@ -366,9 +399,14 @@ class Book(LongPlayScrobblableMixin):
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 self.comicvine_id:
logger.warn(
f"Checking ComicVine by ID for {self.title}"
)
data = lookup_issue_by_comicvine_id(str(self.comicvine_id))
if not data:
logger.warn(f"Checking ComicVine for {self.title}")
data = lookup_comic_from_comicvine(str(self.title))
if not data:
logger.warn(f"Book not found in any sources: {self.title}")
@ -406,10 +444,10 @@ class Book(LongPlayScrobblableMixin):
)
data.pop("pages")
# Pop this, so we can look it up later
# Pop these so they don't get passed to update()
cover_url = data.pop("cover_url", "")
subject_key_list = data.pop("subject_key_list", [])
tags = data.pop("tags", [])
# Fun trick for updating all fields at once
Book.objects.filter(pk=self.id).update(**data)
@ -417,6 +455,8 @@ class Book(LongPlayScrobblableMixin):
if subject_key_list:
self.genre.add(*subject_key_list)
if tags:
self.tags.add(*tags)
if cover_url:
r = requests.get(cover_url)
@ -462,8 +502,11 @@ class Book(LongPlayScrobblableMixin):
if scrobble.logdata.page_data:
for page, data in scrobble.logdata.page_data.items():
if convert_timestamps:
data["start_ts"] = datetime.fromtimestamp(data["start_ts"])
data["end_ts"] = datetime.fromtimestamp(data["end_ts"])
tz = None
if scrobble.timezone:
tz = ZoneInfo(scrobble.timezone)
data["start_ts"] = datetime.fromtimestamp(data["start_ts"], tz=tz)
data["end_ts"] = datetime.fromtimestamp(data["end_ts"], tz=tz)
pages[page] = data
sorted_pages = OrderedDict(
sorted(pages.items(), key=lambda x: x[1]["start_ts"])

View File

@ -0,0 +1,123 @@
import logging
import warnings
from django.conf import settings
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
from amazon_paapi import AmazonApi
logger = logging.getLogger(__name__)
_amazon_client = None
def _get_client() -> AmazonApi | None:
global _amazon_client
if _amazon_client is not None:
return _amazon_client
key = settings.AMAZON_PAAPI_ACCESS_KEY
secret = settings.AMAZON_PAAPI_SECRET_KEY
tag = settings.AMAZON_PAAPI_ASSOCIATE_TAG
country = settings.AMAZON_PAAPI_COUNTRY
if not all([key, secret, tag]):
logger.warning("Amazon PAAPI credentials not configured")
return None
_amazon_client = AmazonApi(key, secret, tag, country)
return _amazon_client
def lookup_book_from_amazon(asin: str) -> dict:
book_dict: dict = {}
client = _get_client()
if not client:
return book_dict
try:
items = client.get_items(
items=[asin],
Condition="New",
LanguagesOfPreference=["en_US"],
)
except Exception as e:
logger.warning(f"Amazon PAAPI lookup failed for {asin}: {e}")
return book_dict
if not items:
logger.info(f"No Amazon item found for {asin}")
return book_dict
item = items[0]
raw = item.to_dict()
item_info = raw.get("item_info", {}) or {}
book_dict["title"] = _get_nested(item_info, "title", "display_value")
if not book_dict.get("title"):
book_dict["title"] = _get_nested(item_info, "title", "value")
contributors = _get_nested(item_info, "by_line_info", "contributors") or []
authors = [
c["name"]
for c in contributors
if c.get("role", "").lower() in ("author", "artist", "writer")
]
if authors:
book_dict["authors"] = authors
publisher = _get_nested(item_info, "by_line_info", "manufacturer")
if publisher:
book_dict["publisher"] = publisher
isb_ns = _get_nested(item_info, "external_ids", "isb_ns")
if isb_ns and isinstance(isb_ns, list):
for isb in isb_ns:
if isinstance(isb, dict):
if isb.get("type") == "ISBN_13":
book_dict["isbn_13"] = isb.get("value")
elif isb.get("type") == "ISBN_10":
book_dict["isbn_10"] = isb.get("value")
pages_count = _get_nested(item_info, "content_info", "pages_count")
if pages_count and isinstance(pages_count, dict):
book_dict["pages"] = pages_count.get("value") or pages_count.get("display_value")
languages = _get_nested(item_info, "content_info", "languages") or []
if languages and isinstance(languages, list):
lang = languages[0]
if isinstance(lang, dict):
book_dict["language"] = lang.get("display_value") or lang.get("value")
pub_date = _get_nested(item_info, "content_info", "publication_date")
if not pub_date:
pub_date = _get_nested(item_info, "product_info", "release_date")
if pub_date and isinstance(pub_date, dict):
book_dict["publish_date"] = pub_date.get("display_value") or pub_date.get("value")
features = item_info.get("features") or []
if features and isinstance(features, list):
book_dict["summary"] = " ".join(features[:5])
images = raw.get("images", {}) or {}
primary = images.get("primary", {}) or {}
for size in ("large", "hi_res", "medium"):
candidate = primary.get(size, {}) or {}
url = candidate.get("url")
if url:
book_dict["cover_url"] = url
break
book_dict["detail_page_url"] = raw.get("detail_page_url")
return book_dict
def _get_nested(d: dict, *keys):
for key in keys:
if not isinstance(d, dict):
return None
d = d.get(key)
return d

View File

@ -17,8 +17,10 @@ class ComicVineClient(object):
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/"
# All API requests made by this client will be made to these URLs.
API_URL = "https://comicvine.gamespot.com/api/search/"
ISSUE_API_URL = "https://comicvine.gamespot.com/api/issue/4000-{issue_id}/"
VOLUME_API_URL = "https://comicvine.gamespot.com/api/volume/4050-{volume_id}/"
# 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
@ -41,15 +43,12 @@ class ComicVineClient(object):
"volume",
}
def __init__(self, api_key, expire_after=300):
def __init__(self, api_key):
"""
Store the API key in a class variable, and install the requests cache,
configuring it using the ``expire_after`` parameter.
Store the API key in a class variable.
: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
@ -109,14 +108,17 @@ class ComicVineClient(object):
:rtype: dict
"""
return {
params = {
"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),
}
validated = self._validate_resources(resources)
if validated:
params["resources"] = validated
return params
def _validate_resources(self, resources):
"""
@ -141,33 +143,35 @@ class ComicVineClient(object):
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.
headers and parameters with the request.
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)
response = requests.get(self.API_URL, headers=self.HEADERS, params=params)
if not response.ok:
self._handle_http_error(response)
if not response.ok:
self._handle_http_error(response)
return response.json()
json_data = response.json()
return __httpget()
if json_data.get("status_code") != 1:
error_msg = json_data.get("error", "Unknown ComicVine API error")
logger.error(
"ComicVine API returned status_code %s: %s",
json_data.get("status_code"),
error_msg,
)
return {}
return json_data
def _handle_http_error(self, response):
"""
@ -195,15 +199,81 @@ class ComicVineClient(object):
raise exception(message)
def get_issue(self, issue_id: str) -> dict:
"""
Fetch a single issue by its ComicVine ID directly from the issue detail
endpoint, which returns richer data than the search endpoint.
:param issue_id: The ComicVine numeric ID for the issue (e.g. "538480")
:type issue_id: str
:return: The full JSON response for the issue, or empty dict on failure.
:rtype: dict
"""
params = {
"api_key": self.api_key,
"format": "json",
}
url = self.ISSUE_API_URL.format(issue_id=issue_id)
response = requests.get(url, headers=self.HEADERS, params=params)
if not response.ok:
self._handle_http_error(response)
json_data = response.json()
if json_data.get("status_code") != 1:
error_msg = json_data.get("error", "Unknown ComicVine API error")
logger.error(
"ComicVine API returned status_code %s: %s",
json_data.get("status_code"),
error_msg,
)
return {}
return json_data.get("results", {})
def get_volume(self, volume_id: str) -> dict:
"""
Fetch a single volume by its ComicVine ID from the volume detail
endpoint. Used to get publisher info and other volume-level metadata.
:param volume_id: The ComicVine numeric ID for the volume (e.g. "91273")
:type volume_id: str
:return: The full JSON response for the volume, or empty dict on failure.
:rtype: dict
"""
params = {
"api_key": self.api_key,
"format": "json",
}
url = self.VOLUME_API_URL.format(volume_id=volume_id)
response = requests.get(url, headers=self.HEADERS, params=params)
if not response.ok:
self._handle_http_error(response)
json_data = response.json()
if json_data.get("status_code") != 1:
error_msg = json_data.get("error", "Unknown ComicVine API error")
logger.error(
"ComicVine API returned status_code %s: %s",
json_data.get("status_code"),
error_msg,
)
return {}
return json_data.get("results", {})
def lookup_comic_from_comicvine(title: str) -> dict:
original_title = title
issue_number = None
volume_nubmer = None
resource_type = "issue"
if "Issue " in title:
resource_type = "issue"
issue_number = title.split("Issue ")[1]
volume_number = None
if "Volume " in title:
@ -215,48 +285,136 @@ def lookup_comic_from_comicvine(title: str) -> dict:
logger.warning("No ComicVine API key configured, not looking anything up")
return {}
client = ComicVineClient(api_key=getattr(settings, "COMICVINE_API_KEY", None))
client = ComicVineClient(api_key=api_key)
raw_results = client.search(title).get("results")
results = [r for r in raw_results if r.get("resource_type") == resource_type]
raw_results = client.search(title)
if not raw_results:
return {}
results = raw_results.get("results", [])
results = [r for r in results if r.get("resource_type") == resource_type]
if not results:
logger.warning("No comic found on ComicVine")
return {}
found_result = None
for result in results:
if result.get("issue_number") == str(issue_number):
if issue_number is not None and result.get("issue_number") == str(issue_number):
found_result = result
break
if result.get("volume_number") == str(volume_number):
if volume_number is not None and result.get("volume_number") == str(volume_number):
found_result = result
break
if not found_result:
found_result = results[0]
logger.info("ComicVine results", extra={"results": results})
data_dict = _build_data_dict_from_issue(found_result, original_title)
_enrich_with_volume_data(client, data_dict)
return data_dict
if not found_result:
logger.warning("No matches found on ComicVine")
def lookup_issue_by_comicvine_id(comicvine_id: str) -> dict:
"""
Look up an issue directly by its ComicVine ID using the issue detail
endpoint. Returns richer data than the search-based lookup.
:param comicvine_id: The ComicVine numeric ID for the issue (e.g. "538480")
:type comicvine_id: str
:return: A dict of extracted book metadata, or empty dict on failure.
:rtype: dict
"""
if not comicvine_id:
return {}
title = found_result.get("name")
api_key = getattr(settings, "COMICVINE_API_KEY", "")
if not api_key:
logger.warning("No ComicVine API key configured, not looking anything up")
return {}
if found_result.get("volume"):
title = found_result.get("volume").get("name")
client = ComicVineClient(api_key=api_key)
issue_data = client.get_issue(comicvine_id)
if not issue_data:
logger.warning("No issue found on ComicVine for ID %s", comicvine_id)
return {}
data_dict = _build_data_dict_from_issue(issue_data, issue_data.get("name", ""))
_enrich_with_volume_data(client, data_dict)
return data_dict
def _build_data_dict_from_issue(issue_data: dict, original_title: str = "") -> dict:
"""
Build a book metadata dict from a ComicVine issue resource (either from
search results or issue detail endpoint). Both return the same shape of
issue data.
:param issue_data: The issue resource dict from ComicVine.
:param original_title: The original search term, if any.
:return: A dict of extracted book metadata.
:rtype: dict
"""
title = issue_data.get("name")
if issue_data.get("volume"):
title = issue_data.get("volume").get("name")
cover_url = None
if issue_data.get("image"):
cover_url = issue_data["image"].get("original_url")
volume_name = None
volume_cv_id = None
publisher_name = None
volume_data = issue_data.get("volume")
if volume_data:
volume_name = volume_data.get("name")
volume_cv_id = volume_data.get("id")
publisher_data = volume_data.get("publisher")
if publisher_data:
publisher_name = publisher_data.get("name")
data_dict = {
"title": title,
"original_title": original_title,
"issue_number": found_result.get("issue_number"),
"volume_number": found_result.get("volume_number"),
"cover_url": found_result.get("image").get("original_url"),
"comicvine_id": found_result.get("id"),
"comicvine_data": found_result,
"summary": found_result.get("description"),
"publish_date": found_result.get("cover_date"),
"first_publish_year": found_result.get("cover_date", "")[:4],
"issue_number": issue_data.get("issue_number"),
"volume_number": issue_data.get("volume_number"),
"volume": volume_name,
"volume_comicvine_id": volume_cv_id,
"publisher": publisher_name,
"cover_url": cover_url,
"comicvine_id": issue_data.get("id"),
"summary": issue_data.get("description"),
"publish_date": issue_data.get("cover_date"),
"first_publish_year": (issue_data.get("cover_date") or "")[:4],
"tags": ["comicbook"],
}
return data_dict
def _enrich_with_volume_data(client: ComicVineClient, data_dict: dict) -> None:
"""
Follow-up a successful issue lookup by fetching the volume detail and
filling in publisher and other volume-level metadata that the issue
endpoint doesn't provide.
:param client: An initialised ComicVineClient instance.
:param data_dict: The data dict from an issue lookup (mutated in place).
"""
volume_cv_id = data_dict.get("volume_comicvine_id")
if not volume_cv_id:
return
volume_data = client.get_volume(str(volume_cv_id))
if not volume_data:
return
publisher_data = volume_data.get("publisher")
if publisher_data:
publisher_name = publisher_data.get("name")
if publisher_name and not data_dict.get("publisher"):
data_dict["publisher"] = publisher_name
if not data_dict.get("volume"):
data_dict["volume"] = volume_data.get("name")

View File

@ -26,8 +26,6 @@ def lookup_book_from_google(title: str) -> dict:
if not google_result:
return {}
publish_date = pendulum.parse(google_result.get("publishedDate"))
isbn_13 = ""
isbn_10 = ""
for ident in google_result.get("industryIdentifiers", []):
@ -35,25 +33,25 @@ def lookup_book_from_google(title: str) -> dict:
isbn_13 = ident.get("identifier")
if ident.get("type") == "ISBN_10":
isbn_10 = ident.get("identifier")
# TODO this may lead to issues with the first get if Google changes our title
# book_metadata.title = google_result.get("title")
# if google_result.get("subtitle"):
# book_metadata["title"] = ": ".join(
# [google_result.get("title"), google_result.get("subtitle")]
# )
# book_dict["subtitle"] = google_result.get("subtitle")
book_dict["authors"] = google_result.get("authors")
book_dict["publisher"] = google_result.get("publisher")
book_dict["first_publish_year"] = publish_date.year
book_dict["pages"] = google_result.get("pageCount")
book_dict["isbn_13"] = isbn_13
book_dict["isbn_10"] = isbn_10
book_dict["publish_date"] = google_result.get("publishedDate")
if len(book_dict["publish_date"]) == 4:
book_dict["publish_date"] = f"{book_dict['publish_date']}-1-1"
book_dict["language"] = google_result.get("language")
book_dict["summary"] = google_result.get("description")
book_dict["genres"] = google_result.get("categories")
raw_date = google_result.get("publishedDate")
if raw_date:
try:
publish_date = pendulum.parse(raw_date)
book_dict["first_publish_year"] = publish_date.year
except Exception:
pass
book_dict["publish_date"] = raw_date
if len(raw_date) == 4:
book_dict["publish_date"] = f"{raw_date}-1-1"
book_dict["cover_url"] = (
google_result.get("imageLinks", {})
.get("thumbnail", "")

View File

@ -32,8 +32,6 @@ class KoReaderBookRows:
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)
@ -110,6 +108,8 @@ class KoReaderBookRows:
end_session = True
def __init__(self, book_count=0, **kwargs):
self.BOOK_ROWS = []
self.PAGE_STATS_ROWS = []
self._generate_random_book_rows(book_count)
self._generate_custom_book_row(**kwargs)
self._generate_random_page_stats_rows()

View File

@ -44,7 +44,10 @@ def test_build_scrobbles_from_pages(get_mock, koreader_rows, demo_user, valid_re
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 )
# The test data generator adds the session-gap 3600s AFTER the trigger page
# (not before), so the first session includes 21 pages (1-21), and each
# subsequent session has 20 until the last. The last page is now included
# in the final scrobble instead of being orphaned.
expected_scrobbles = 6 * len(book_map.keys())
assert len(scrobbles) == expected_scrobbles
assert len(scrobbles[0].logdata.page_data.keys()) == 21
@ -52,7 +55,7 @@ def test_build_scrobbles_from_pages(get_mock, koreader_rows, demo_user, valid_re
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
assert len(scrobbles[5].logdata.page_data.keys()) == 19
def test_get_author_str_from_row():

View File

@ -0,0 +1,146 @@
# Generated by Django 4.2.29 on 2026-06-12 16:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("charts", "0001_initial"),
]
operations = [
migrations.AddIndex(
model_name="chartrecord",
index=models.Index(
fields=["user", "year", "month", "album", "rank"],
name="charts_char_user_id_1adcde_idx",
),
),
migrations.AddIndex(
model_name="chartrecord",
index=models.Index(
fields=["user", "year", "month", "track", "rank"],
name="charts_char_user_id_d18aab_idx",
),
),
migrations.AddIndex(
model_name="chartrecord",
index=models.Index(
fields=["user", "year", "month", "video", "rank"],
name="charts_char_user_id_de9f0a_idx",
),
),
migrations.AddIndex(
model_name="chartrecord",
index=models.Index(
fields=["user", "year", "month", "board_game", "rank"],
name="charts_char_user_id_d5d58f_idx",
),
),
migrations.AddIndex(
model_name="chartrecord",
index=models.Index(
fields=["user", "year", "month", "book", "rank"],
name="charts_char_user_id_e877cf_idx",
),
),
migrations.AddIndex(
model_name="chartrecord",
index=models.Index(
fields=["user", "year", "month", "food", "rank"],
name="charts_char_user_id_a0ad71_idx",
),
),
migrations.AddIndex(
model_name="chartrecord",
index=models.Index(
fields=["user", "year", "month", "podcast", "rank"],
name="charts_char_user_id_846b80_idx",
),
),
migrations.AddIndex(
model_name="chartrecord",
index=models.Index(
fields=["user", "year", "month", "trail", "rank"],
name="charts_char_user_id_54feba_idx",
),
),
migrations.AddIndex(
model_name="chartrecord",
index=models.Index(
fields=["user", "year", "week", "album", "rank"],
name="charts_char_user_id_a3dc49_idx",
),
),
migrations.AddIndex(
model_name="chartrecord",
index=models.Index(
fields=["user", "year", "week", "track", "rank"],
name="charts_char_user_id_4b01ab_idx",
),
),
migrations.AddIndex(
model_name="chartrecord",
index=models.Index(
fields=["user", "year", "week", "video", "rank"],
name="charts_char_user_id_2ac9d2_idx",
),
),
migrations.AddIndex(
model_name="chartrecord",
index=models.Index(
fields=["user", "year", "week", "board_game", "rank"],
name="charts_char_user_id_ba968a_idx",
),
),
migrations.AddIndex(
model_name="chartrecord",
index=models.Index(
fields=["user", "year", "week", "book", "rank"],
name="charts_char_user_id_e66751_idx",
),
),
migrations.AddIndex(
model_name="chartrecord",
index=models.Index(
fields=["user", "year", "week", "food", "rank"],
name="charts_char_user_id_d23f06_idx",
),
),
migrations.AddIndex(
model_name="chartrecord",
index=models.Index(
fields=["user", "year", "week", "podcast", "rank"],
name="charts_char_user_id_be8122_idx",
),
),
migrations.AddIndex(
model_name="chartrecord",
index=models.Index(
fields=["user", "year", "week", "trail", "rank"],
name="charts_char_user_id_b94ea9_idx",
),
),
migrations.AddIndex(
model_name="chartrecord",
index=models.Index(
fields=["user", "year", "month", "day", "artist", "rank"],
name="charts_char_user_id_406e0e_idx",
),
),
migrations.AddIndex(
model_name="chartrecord",
index=models.Index(
fields=["user", "year", "month", "day", "album", "rank"],
name="charts_char_user_id_322b0d_idx",
),
),
migrations.AddIndex(
model_name="chartrecord",
index=models.Index(
fields=["user", "year", "month", "day", "tv_series", "rank"],
name="charts_char_user_id_aa44b7_idx",
),
),
]

View File

@ -60,10 +60,29 @@ class ChartRecord(TimeStampedModel):
models.Index(fields=["user", "year", "geo_location", "rank"]),
models.Index(fields=["user", "year", "food", "rank"]),
models.Index(fields=["user", "year", "book", "rank"]),
models.Index(fields=["user", "year", "week", "artist", "rank"]),
models.Index(fields=["user", "year", "week", "tv_series", "rank"]),
models.Index(fields=["user", "year", "month", "artist", "rank"]),
models.Index(fields=["user", "year", "month", "album", "rank"]),
models.Index(fields=["user", "year", "month", "track", "rank"]),
models.Index(fields=["user", "year", "month", "tv_series", "rank"]),
models.Index(fields=["user", "year", "month", "video", "rank"]),
models.Index(fields=["user", "year", "month", "board_game", "rank"]),
models.Index(fields=["user", "year", "month", "book", "rank"]),
models.Index(fields=["user", "year", "month", "food", "rank"]),
models.Index(fields=["user", "year", "month", "podcast", "rank"]),
models.Index(fields=["user", "year", "month", "trail", "rank"]),
models.Index(fields=["user", "year", "week", "artist", "rank"]),
models.Index(fields=["user", "year", "week", "album", "rank"]),
models.Index(fields=["user", "year", "week", "track", "rank"]),
models.Index(fields=["user", "year", "week", "tv_series", "rank"]),
models.Index(fields=["user", "year", "week", "video", "rank"]),
models.Index(fields=["user", "year", "week", "board_game", "rank"]),
models.Index(fields=["user", "year", "week", "book", "rank"]),
models.Index(fields=["user", "year", "week", "food", "rank"]),
models.Index(fields=["user", "year", "week", "podcast", "rank"]),
models.Index(fields=["user", "year", "week", "trail", "rank"]),
models.Index(fields=["user", "year", "month", "day", "artist", "rank"]),
models.Index(fields=["user", "year", "month", "day", "album", "rank"]),
models.Index(fields=["user", "year", "month", "day", "tv_series", "rank"]),
]
@property

View File

@ -7,7 +7,10 @@ register = template.Library()
def get_item(dictionary, key):
if isinstance(dictionary, dict):
return dictionary.get(key)
return None
try:
return dictionary[int(key)]
except (IndexError, KeyError, TypeError, ValueError):
return None
@register.filter

View File

@ -104,8 +104,8 @@ def build_charts(
media_config = {
"artist": {
"filter": Q(track__isnull=False) & Q(track__artist__isnull=False),
"values": "track__artist",
"filter": Q(track__isnull=False) & Q(track__artists__isnull=False),
"values": "track__artists",
"annotate": Count("id", distinct=True),
},
"album": {

View File

@ -114,108 +114,106 @@ class ChartRecordView(TemplateView):
context["current_week"] = current_week
context["current_day"] = current_day
if chart_type == "maloja":
context["chart_keys"] = {
"today": "Today",
"week": "This Week",
"month": "This Month",
"year": "This Year",
"all": "All Time",
}
context["chart_keys"] = {
"today": "Today",
"week": "This Week",
"month": "This Month",
"year": "This Year",
"all": "All Time",
}
context["maloja_charts"] = {
"artist": {
"today": list(
self.get_charts_for_period(
user,
"artist",
year=current_year,
month=current_month,
day=current_day,
)
),
"week": list(
self.get_charts_for_period(
user,
"artist",
year=current_year,
week=current_week,
)
),
"month": list(
self.get_charts_for_period(
user,
"artist",
year=current_year,
month=current_month,
)
),
"year": list(
self.get_charts_for_period(user, "artist", year=current_year)
),
"all": list(self.get_charts_for_period(user, "artist")),
},
"track": {
"today": list(
self.get_charts_for_period(
user,
"track",
year=current_year,
month=current_month,
day=current_day,
)
),
"week": list(
self.get_charts_for_period(
user, "track", year=current_year, week=current_week
)
),
"month": list(
self.get_charts_for_period(
user,
"track",
year=current_year,
month=current_month,
)
),
"year": list(
self.get_charts_for_period(user, "track", year=current_year)
),
"all": list(self.get_charts_for_period(user, "track")),
},
"tv_series": {
"today": list(
self.get_charts_for_period(
user,
"tv_series",
year=current_year,
month=current_month,
day=current_day,
)
),
"week": list(
self.get_charts_for_period(
user,
"tv_series",
year=current_year,
week=current_week,
)
),
"month": list(
self.get_charts_for_period(
user,
"tv_series",
year=current_year,
month=current_month,
)
),
"year": list(
self.get_charts_for_period(user, "tv_series", year=current_year)
),
"all": list(self.get_charts_for_period(user, "tv_series")),
},
}
return context
context["maloja_charts"] = {
"artist": {
"today": list(
self.get_charts_for_period(
user,
"artist",
year=current_year,
month=current_month,
day=current_day,
)
),
"week": list(
self.get_charts_for_period(
user,
"artist",
year=current_year,
week=current_week,
)
),
"month": list(
self.get_charts_for_period(
user,
"artist",
year=current_year,
month=current_month,
)
),
"year": list(
self.get_charts_for_period(user, "artist", year=current_year)
),
"all": list(self.get_charts_for_period(user, "artist")),
},
"album": {
"today": list(
self.get_charts_for_period(
user,
"album",
year=current_year,
month=current_month,
day=current_day,
)
),
"week": list(
self.get_charts_for_period(
user, "album", year=current_year, week=current_week
)
),
"month": list(
self.get_charts_for_period(
user,
"album",
year=current_year,
month=current_month,
)
),
"year": list(
self.get_charts_for_period(user, "album", year=current_year)
),
"all": list(self.get_charts_for_period(user, "album")),
},
"tv_series": {
"today": list(
self.get_charts_for_period(
user,
"tv_series",
year=current_year,
month=current_month,
day=current_day,
)
),
"week": list(
self.get_charts_for_period(
user,
"tv_series",
year=current_year,
week=current_week,
)
),
"month": list(
self.get_charts_for_period(
user,
"tv_series",
year=current_year,
month=current_month,
)
),
"year": list(
self.get_charts_for_period(user, "tv_series", year=current_year)
),
"all": list(self.get_charts_for_period(user, "tv_series")),
},
}
if not date_param:
context["period"] = "current"
@ -440,12 +438,14 @@ class ChartRecordView(TemplateView):
return context
def get_available_years(self, user):
return list(
ChartRecord.objects.filter(user=user)
.values_list("year", flat=True)
.distinct()
.order_by("-year")
)
if not hasattr(self, "_available_years"):
self._available_years = list(
ChartRecord.objects.filter(user=user)
.values_list("year", flat=True)
.distinct()
.order_by("-year")
)
return self._available_years
def get_period_type(self):
date_param = self.request.GET.get("date")

View File

@ -21,6 +21,7 @@ class FoodAdmin(admin.ModelAdmin):
"uuid",
"title",
)
raw_id_fields = ("category",)
ordering = ("-created",)
search_fields = ("title",)
inlines = [

View File

@ -28,6 +28,14 @@ class GeoLocationLogData(BaseLogData, WithPeopleLogData):
activity: str = ""
detected_at: str = ""
@classmethod
def from_log_dict(cls, log_dict: dict) -> dict:
instance_data = log_dict.get("movement_detection", {}).copy()
for field_name in ["description", "notes", "with_people_ids"]:
if field_name in log_dict:
instance_data[field_name] = log_dict[field_name]
return instance_data
class GeoLocation(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, "LOCATION_COMPLETION_PERCENT", 100)

View File

@ -1,7 +1,5 @@
from django.contrib import admin
from music.models import Artist, Album, Track
from music.models import Album, Artist, Track
from scrobbles.admin import ScrobbleInline
@ -11,7 +9,7 @@ class AlbumAdmin(admin.ModelAdmin):
list_display = (
"name",
"year",
"album_artist",
"artist",
"theaudiodb_genre",
"theaudiodb_mood",
"musicbrainz_id",
@ -54,12 +52,12 @@ class TrackAdmin(admin.ModelAdmin):
"artist",
"musicbrainz_id",
)
raw_id_fields = ("artist", "albums", "album")
list_filter = ("album", "artist")
raw_id_fields = ("artist_fk", "artists", "albums", "album")
search_fields = ("title",)
ordering = ("-created",)
filter_horizontal = [
"albums",
"artists",
]
inlines = [
ScrobbleInline,

View File

@ -149,7 +149,7 @@ def live_charts(
def artist_scrobble_count(artist_id: int, filter: str = "today") -> int:
return Scrobble.objects.filter(track__artist=artist_id).count()
return Scrobble.objects.filter(track__artists=artist_id).count()
def live_tv_charts(
@ -158,7 +158,7 @@ def live_tv_charts(
limit: int = 15,
) -> QuerySet:
from django.db.models import OuterRef, Subquery
from videos.models import Video, Series
from videos.models import Series, Video
now = timezone.now()
tzinfo = now.tzinfo
@ -215,7 +215,7 @@ def live_youtube_channel_charts(
limit: int = 15,
) -> QuerySet:
from django.db.models import OuterRef, Subquery
from videos.models import Video, Channel
from videos.models import Channel, Video
now = timezone.now()
tzinfo = now.tzinfo

View File

@ -1,3 +1,5 @@
from taggit.serializers import TaggitSerializer, TagListSerializerField
from music.models import Album, Artist, Track
from rest_framework import serializers
@ -14,7 +16,25 @@ class AlbumSerializer(serializers.HyperlinkedModelSerializer):
fields = "__all__"
class TrackSerializer(serializers.HyperlinkedModelSerializer):
class TrackSerializer(TaggitSerializer, serializers.HyperlinkedModelSerializer):
tags = TagListSerializerField()
genre = TagListSerializerField()
class Meta:
model = Track
fields = "__all__"
fields = [
"url",
"id",
"uuid",
"title",
"artist_fk",
"artists",
"album",
"albums",
"musicbrainz_id",
"genre",
"tags",
"base_run_time_seconds",
"created",
"modified",
]

View File

@ -0,0 +1,90 @@
import logging
import requests
logger = logging.getLogger(__name__)
LB_LABS_API = "https://labs.api.listenbrainz.org"
STANDARD_ALGORITHM = "session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30"
MLHD_ALGORITHM = "session_based_mlhd_session_300_contribution_5_threshold_15_limit_50_skip_30"
SIMILAR_ARTISTS_ALGORITHM = "session_based_days_1825_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30"
def get_similar_recordings(recording_mbid: str) -> list[dict]:
"""Fetch similar recordings from ListenBrainz.
Tries the standard dataset first, falls back to MLHD dataset.
Returns [] on error or no data.
"""
if not recording_mbid:
return []
try:
resp = requests.post(
f"{LB_LABS_API}/similar-recordings/json",
json=[
{
"recording_mbids": [recording_mbid],
"algorithm": STANDARD_ALGORITHM,
}
],
timeout=10,
)
resp.raise_for_status()
data = resp.json()
if data:
return data
resp = requests.post(
f"{LB_LABS_API}/mlhd-similar-recordings/json",
json=[
{
"recording_mbids": [recording_mbid],
"algorithm": MLHD_ALGORITHM,
}
],
timeout=10,
)
resp.raise_for_status()
return resp.json()
except requests.RequestException as e:
logger.warning(
"ListenBrainz similar recordings error",
extra={
"recording_mbid": recording_mbid,
"error": str(e),
},
)
return []
def get_similar_artists(artist_mbid: str) -> list[dict]:
"""Fetch similar artists from ListenBrainz.
Returns [] on error or no data.
"""
if not artist_mbid:
return []
try:
resp = requests.post(
f"{LB_LABS_API}/similar-artists/json",
json=[
{
"artist_mbids": [artist_mbid],
"algorithm": SIMILAR_ARTISTS_ALGORITHM,
}
],
timeout=10,
)
resp.raise_for_status()
return resp.json()
except requests.RequestException as e:
logger.warning(
"ListenBrainz similar artists error",
extra={
"artist_mbid": artist_mbid,
"error": str(e),
},
)
return []

View File

@ -0,0 +1,249 @@
import logging
import time
import musicbrainzngs
from django.core.management.base import BaseCommand
from django.db import transaction
from music.musicbrainz import resolve_track, get_track_metadata_with_artist, extract_featured_artists
logger = logging.getLogger(__name__)
VARIOUS_ARTISTS_NAMES = ["various artists", "va", "various"]
def _is_various_artists(artist_name: str) -> bool:
return artist_name.strip().casefold() in VARIOUS_ARTISTS_NAMES
def _get_artist_from_scrobble_logs(track_id: int):
from scrobbles.models import Scrobble
scrobbles = Scrobble.objects.filter(track_id=track_id)[:10]
for scrobble in scrobbles:
raw = scrobble.log.get("raw_data", {})
if not raw:
continue
provider_mbid = raw.get("Provider_musicbrainztrack")
if provider_mbid:
return {"artist_name": raw.get("Artist", ""), "recording_mbid": provider_mbid}
artist = raw.get("Artist", "")
if artist:
return {"artist_name": artist, "recording_mbid": None}
return None
def _lookup_recording_by_mbid(mbid: str):
try:
result = musicbrainzngs.get_recording_by_id(mbid, includes=["artists"])
recording = result["recording"]
length_ms = recording.get("length")
artist_credit = recording.get("artist-credit", [{}])
primary_artist = artist_credit[0].get("artist", {}) if artist_credit else {}
return {
"recording_mbid": mbid,
"length_ms": int(length_ms) if length_ms else None,
"artist_name": primary_artist.get("name", ""),
"artist_mbid": primary_artist.get("id", ""),
}
except (musicbrainzngs.WebServiceError, musicbrainzngs.ResponseError) as e:
logger.warning(f"MusicBrainz error looking up {mbid}: {e}")
return None
def _lookup_length_by_mbid(mbid: str):
try:
result = musicbrainzngs.get_recording_by_id(mbid)
recording = result["recording"]
length_ms = recording.get("length")
if length_ms:
return int(length_ms)
return None
except (musicbrainzngs.WebServiceError, musicbrainzngs.ResponseError) as e:
logger.warning(f"MusicBrainz error looking up {mbid}: {e}")
return None
class Command(BaseCommand):
help = "Clean up metadata on music tracks from MusicBrainz"
def add_arguments(self, parser):
parser.add_argument(
"--commit",
action="store_true",
help="Commit changes to the database",
)
parser.add_argument(
"--batch-size",
type=int,
default=100,
help="Number of tracks to process per batch (default: 100)",
)
parser.add_argument(
"--only-missing-mbid",
action="store_true",
help="Only process tracks missing musicbrainz_id",
)
parser.add_argument(
"--only-missing-length",
action="store_true",
help="Only process tracks missing base_run_time_seconds",
)
def handle(self, *args, **options):
from music.models import Track
commit = options["commit"]
batch_size = options["batch_size"]
if not commit:
self.stdout.write("Dry run — no changes will be saved. Use --commit to apply.")
musicbrainzngs.set_useragent("vrobbler", "0.3.0")
qs = Track.objects.all()
if options["only_missing_mbid"]:
qs = qs.filter(musicbrainz_id__isnull=True)
if options["only_missing_length"]:
qs = qs.filter(base_run_time_seconds__isnull=True)
total = qs.count()
self.stdout.write(f"Processing {total} tracks")
missing_mbid_qs = qs.filter(musicbrainz_id__isnull=True)
missing_length_qs = qs.filter(
musicbrainz_id__isnull=False, base_run_time_seconds__isnull=True
)
self.stdout.write(
f" {missing_mbid_qs.count()} tracks without musicbrainz_id"
)
self.stdout.write(
f" {missing_length_qs.count()} tracks with mbid but no run time"
)
if not commit:
self.stdout.write("\nSkipping API lookups in dry-run mode. Use --commit to run against MusicBrainz.")
return
found_count = 0
not_found_count = 0
length_fixed_count = 0
va_fixed_count = 0
track_ids = list(missing_mbid_qs.values_list("pk", flat=True))
i = 0
for batch_num, offset in enumerate(range(0, len(track_ids), batch_size)):
batch_pks = track_ids[offset : offset + batch_size]
with transaction.atomic():
for track in Track.objects.filter(pk__in=batch_pks).iterator():
i += 1
artist_name = ""
artist_obj = track.artist
if artist_obj:
artist_name = artist_obj.name
is_va = _is_various_artists(artist_name)
result = None
method = ""
if is_va:
scrobble_info = _get_artist_from_scrobble_logs(track.id)
if scrobble_info:
if scrobble_info["recording_mbid"]:
result = _lookup_recording_by_mbid(
scrobble_info["recording_mbid"]
)
if result:
method = "provider"
self.stdout.write(
f" VA track '{track.title}' matched via provider MBID "
f"{scrobble_info['recording_mbid']} (artist: {scrobble_info['artist_name']})"
)
else:
result, method = resolve_track(
track.title, scrobble_info["artist_name"]
)
if result:
self.stdout.write(
f" VA track '{track.title}' matched via log artist "
f"'{scrobble_info['artist_name']}' as {result['recording_mbid']} ({method})"
)
if not result:
result, method = resolve_track(track.title)
if result:
self.stdout.write(
f" VA track '{track.title}' matched by title alone "
f"as {result['recording_mbid']} ({method})"
)
if not result:
result, method = resolve_track(track.title, artist_name)
if result and result.get("recording_mbid"):
track.musicbrainz_id = result["recording_mbid"]
length_ms = result.get("length_ms")
if length_ms and not track.base_run_time_seconds:
track.base_run_time_seconds = int(int(length_ms) / 1000)
track.save(update_fields=["musicbrainz_id", "base_run_time_seconds"])
method_tag = f"musicbrainz-{method}" if method else "musicbrainz-enriched"
track.tags.add(method_tag, "musicbrainz-enriched")
from music.models import Artist
cleaned_title, featured_names = extract_featured_artists(track.title)
for feat_name in featured_names:
artist = Artist.find_or_create(feat_name, track_name=track.title)
if artist:
track.artists.add(artist)
self.stdout.write(
f" [{i}] FOUND {track} — mbid={track.musicbrainz_id} ({method})"
)
found_count += 1
else:
track.tags.add("musicbrainz-notfound")
self.stdout.write(
f" [{i}] NOTFOUND {track}"
)
not_found_count += 1
if is_va and result:
va_fixed_count += 1
self.stdout.write(
f" Batch {batch_num + 1}: {offset + len(batch_pks)}/{total} processed, "
f"found: {found_count}, not found: {not_found_count}"
)
time.sleep(1)
length_ids = list(missing_length_qs.values_list("pk", flat=True))
j = 0
for batch_num, offset in enumerate(range(0, len(length_ids), batch_size)):
batch_pks = length_ids[offset : offset + batch_size]
with transaction.atomic():
for track in Track.objects.filter(pk__in=batch_pks).iterator():
j += 1
length_ms = _lookup_length_by_mbid(track.musicbrainz_id)
if length_ms:
track.base_run_time_seconds = int(int(length_ms) / 1000)
track.tags.add("musicbrainz-enriched")
track.save(update_fields=["base_run_time_seconds"])
self.stdout.write(
f" [{j}] LENGTH {track}{track.base_run_time_seconds}s"
)
length_fixed_count += 1
self.stdout.write(
f" Batch {batch_num + 1}: {j}/{len(length_ids)} length lookups"
)
time.sleep(1)
self.stdout.write(
f"\nResults (commit={commit}):\n"
f" Found on MusicBrainz: {found_count}\n"
f" Not found / tagged musicbrainz-notfound: {not_found_count}\n"
f" Run times filled from MBID: {length_fixed_count}\n"
f" Various Artists tracks resolved: {va_fixed_count}"
)

View File

@ -0,0 +1,123 @@
import logging
import time
from django.core.management.base import BaseCommand
from music.listenbrainz import get_similar_artists
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Fetch similar artists from ListenBrainz for artists with a musicbrainz_id"
def add_arguments(self, parser):
parser.add_argument(
"--commit",
action="store_true",
help="Commit changes to the database",
)
parser.add_argument(
"--batch-size",
type=int,
default=100,
help="Number of artists to process per batch (default: 100)",
)
parser.add_argument(
"--artist-id",
type=str,
default="",
help="Only process the artist with this musicbrainz_id",
)
parser.add_argument(
"--overwrite",
action="store_true",
help="Re-fetch similar artists even if already populated",
)
def handle(self, *args, **options):
from music.models import Artist
commit = options["commit"]
batch_size = options["batch_size"]
artist_id = options["artist_id"]
overwrite = options["overwrite"]
if not commit:
self.stdout.write(
"Dry run — no changes will be saved. Use --commit to apply."
)
qs = Artist.objects.exclude(musicbrainz_id__isnull=True).exclude(
musicbrainz_id=""
)
if artist_id:
qs = qs.filter(musicbrainz_id=artist_id)
self.stdout.write(f"Filtering to artist with musicbrainz_id: {artist_id}")
elif not overwrite:
qs = qs.filter(similar_artists__isnull=True)
else:
qs = qs.all()
total = qs.count()
if total == 0:
self.stdout.write("No artists to process.")
return
self.stdout.write(
f"Found {total} artists with musicbrainz_id"
+ (" (overwrite mode)" if overwrite else "")
+ (" (--artist-id filter)" if artist_id else "")
)
if not commit:
self.stdout.write(
"\nSkipping API lookups in dry-run mode. Use --commit to run against ListenBrainz."
)
return
found_count = 0
empty_count = 0
error_count = 0
artist_ids = list(qs.values_list("pk", flat=True))
i = 0
for batch_num, offset in enumerate(
range(0, len(artist_ids), batch_size)
):
batch_pks = artist_ids[offset : offset + batch_size]
for artist in Artist.objects.filter(pk__in=batch_pks).iterator():
i += 1
self.stdout.write(
f" [{i}/{total}] Fetching similar artists for {artist}...",
ending="",
)
try:
similar = get_similar_artists(artist.musicbrainz_id)
if similar:
artist.similar_artists = similar
artist.save(update_fields=["similar_artists"])
self.stdout.write(f" {len(similar)} similar artists found")
found_count += 1
else:
artist.similar_artists = []
artist.save(update_fields=["similar_artists"])
self.stdout.write(" none found")
empty_count += 1
except Exception as e:
self.stdout.write(f" error: {e}")
error_count += 1
self.stdout.write(
f" Batch {batch_num + 1}: {offset + len(batch_pks)}/{total} processed, "
f"found: {found_count}, empty: {empty_count}, errors: {error_count}"
)
time.sleep(1)
self.stdout.write(
f"\nResults (commit={commit}):\n"
f" Similar artists found: {found_count}\n"
f" No similar artists: {empty_count}\n"
f" Errors: {error_count}"
)

View File

@ -0,0 +1,125 @@
import logging
import time
from django.core.management.base import BaseCommand
from music.listenbrainz import get_similar_recordings
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Fetch similar recordings from ListenBrainz for tracks with a musicbrainz_id"
def add_arguments(self, parser):
parser.add_argument(
"--commit",
action="store_true",
help="Commit changes to the database",
)
parser.add_argument(
"--batch-size",
type=int,
default=100,
help="Number of tracks to process per batch (default: 100)",
)
parser.add_argument(
"--track-id",
type=str,
default="",
help="Only process the track with this musicbrainz_id",
)
parser.add_argument(
"--overwrite",
action="store_true",
help="Re-fetch similar recordings even if already populated",
)
def handle(self, *args, **options):
from music.models import Track
commit = options["commit"]
batch_size = options["batch_size"]
track_id = options["track_id"]
overwrite = options["overwrite"]
if not commit:
self.stdout.write(
"Dry run — no changes will be saved. Use --commit to apply."
)
qs = Track.objects.exclude(musicbrainz_id__isnull=True).exclude(
musicbrainz_id=""
)
if track_id:
qs = qs.filter(musicbrainz_id=track_id)
self.stdout.write(f"Filtering to track with musicbrainz_id: {track_id}")
elif not overwrite:
qs = qs.filter(similar_recordings__isnull=True)
else:
qs = qs.all()
total = qs.count()
if total == 0:
self.stdout.write("No tracks to process.")
return
self.stdout.write(
f"Found {total} tracks with musicbrainz_id"
+ (" (overwrite mode)" if overwrite else "")
+ (" (--track-id filter)" if track_id else "")
)
if not commit:
self.stdout.write(
"\nSkipping API lookups in dry-run mode. Use --commit to run against ListenBrainz."
)
return
found_count = 0
empty_count = 0
error_count = 0
track_ids = list(qs.values_list("pk", flat=True))
i = 0
for batch_num, offset in enumerate(
range(0, len(track_ids), batch_size)
):
batch_pks = track_ids[offset : offset + batch_size]
for track in Track.objects.filter(pk__in=batch_pks).iterator():
i += 1
self.stdout.write(
f" [{i}/{total}] Fetching similar recordings for {track}...",
ending="",
)
try:
similar = get_similar_recordings(track.musicbrainz_id)
if similar:
track.similar_recordings = similar
track.save(update_fields=["similar_recordings"])
self.stdout.write(
f" {len(similar)} similar recordings found"
)
found_count += 1
else:
track.similar_recordings = []
track.save(update_fields=["similar_recordings"])
self.stdout.write(" none found")
empty_count += 1
except Exception as e:
self.stdout.write(f" error: {e}")
error_count += 1
self.stdout.write(
f" Batch {batch_num + 1}: {offset + len(batch_pks)}/{total} processed, "
f"found: {found_count}, empty: {empty_count}, errors: {error_count}"
)
time.sleep(1)
self.stdout.write(
f"\nResults (commit={commit}):\n"
f" Similar recordings found: {found_count}\n"
f" No similar recordings: {empty_count}\n"
f" Errors: {error_count}"
)

View File

@ -0,0 +1,127 @@
import csv
import logging
from django.core.management.base import BaseCommand
logger = logging.getLogger(__name__)
def _get_source(raw_data):
if "Artist" in raw_data:
return "Jellyfin"
if "artist" in raw_data:
return "Mopidy"
return None
def _get_raw_values(raw_data, source):
if source == "Jellyfin":
return raw_data.get("Artist", ""), raw_data.get("Album", "")
return raw_data.get("artist", ""), raw_data.get("album", "")
def _normalize(name):
return name.strip().casefold()
def _artist_mismatch(raw_artist, track_artist_names):
if not raw_artist or not track_artist_names:
return False
track_names = [_normalize(n) for n in track_artist_names.split(" / ")]
raw = _normalize(raw_artist)
if raw in track_names:
return False
if raw == _normalize(track_artist_names):
return False
return True
def _album_mismatch(raw_album, track_album_name):
if not raw_album or not track_album_name:
return False
return _normalize(raw_album) != _normalize(track_album_name)
class Command(BaseCommand):
help = (
"Outputs a CSV of track IDs where raw metadata from scrobble logs "
"does not match the track's stored artists or album"
)
def add_arguments(self, parser):
parser.add_argument(
"--file-path",
type=str,
default="/tmp/metadata-report.csv",
help="Output CSV file path (default: /tmp/metadata-report.csv)",
)
def handle(self, *args, **options):
from scrobbles.models import Scrobble
file_path = options["file_path"]
qs = (
Scrobble.objects.filter(media_type=Scrobble.MediaType.TRACK)
.exclude(log__isnull=True)
.exclude(log={})
.select_related("track__album")
.prefetch_related("track__artists")
.iterator()
)
rows = []
for scrobble in qs:
track = scrobble.track
if not track:
continue
raw_data = scrobble.log.get("raw_data")
if not raw_data:
continue
source = _get_source(raw_data)
if not source:
continue
raw_artist, raw_album = _get_raw_values(raw_data, source)
if not raw_artist and not raw_album:
continue
track_artist_names = " / ".join(
track.artists.all().values_list("name", flat=True)
)
track_album_name = track.album.name if track.album else ""
if _artist_mismatch(raw_artist, track_artist_names) or _album_mismatch(
raw_album, track_album_name
):
rows.append(
{
"track_id": track.id,
"track_artist_name": track_artist_names,
"track_album_name": track_album_name,
"raw_artist": raw_artist,
"raw_album": raw_album,
"source": source,
}
)
fieldnames = [
"track_id",
"track_artist_name",
"track_album_name",
"raw_artist",
"raw_album",
"source",
]
with open(file_path, "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
self.stdout.write(
self.style.SUCCESS(
f"Wrote {len(rows)} mismatched track(s) to {file_path}"
)
)

View File

@ -0,0 +1,32 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("music", "0031_alter_track_genre"),
]
operations = [
migrations.RenameField(
model_name="track",
old_name="artist",
new_name="artist_fk",
),
migrations.AlterField(
model_name="track",
name="artist_fk",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=models.DO_NOTHING,
related_name="+",
to="music.artist",
),
),
migrations.AddField(
model_name="track",
name="artists",
field=models.ManyToManyField(to="music.artist"),
),
]

View File

@ -0,0 +1,27 @@
from django.db import migrations
def backfill_artist_fk_to_artists(apps, schema_editor):
Track = apps.get_model("music", "Track")
TrackArtist = Track.artists.through
TrackArtist.objects.bulk_create(
[
TrackArtist(track_id=r["id"], artist_id=r["artist_fk_id"])
for r in Track.objects.filter(artist_fk__isnull=False).values("id", "artist_fk_id")
],
ignore_conflicts=True,
)
class Migration(migrations.Migration):
dependencies = [
("music", "0032_track_artist_fk_and_artists"),
]
operations = [
migrations.RunPython(
backfill_artist_fk_to_artists,
reverse_code=migrations.RunPython.noop,
),
]

View File

@ -0,0 +1,29 @@
from django.db import migrations
def backfill_album_artist_to_artists(apps, schema_editor):
Album = apps.get_model("music", "Album")
AlbumArtist = Album.artists.through
AlbumArtist.objects.bulk_create(
[
AlbumArtist(album_id=r["id"], artist_id=r["album_artist_id"])
for r in Album.objects.filter(album_artist__isnull=False).values(
"id", "album_artist_id"
)
],
ignore_conflicts=True,
)
class Migration(migrations.Migration):
dependencies = [
("music", "0033_backfill_track_artists"),
]
operations = [
migrations.RunPython(
backfill_album_artist_to_artists,
reverse_code=migrations.RunPython.noop,
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.30 on 2026-05-29 23:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("music", "0034_backfill_album_artists"),
]
operations = [
migrations.AddField(
model_name="track",
name="similar_recordings",
field=models.JSONField(blank=True, null=True),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.30 on 2026-05-30 00:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("music", "0035_track_similar_recordings"),
]
operations = [
migrations.AddField(
model_name="artist",
name="similar_artists",
field=models.JSONField(blank=True, null=True),
),
]

View File

@ -16,9 +16,11 @@ 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.musicbrainz import (
extract_featured_artists,
get_album_metadata_with_artist,
get_recording_mbid_exact,
get_track_metadata_with_artist,
resolve_track,
)
from music.theaudiodb import lookup_album_from_tadb, lookup_artist_from_tadb
from music.utils import clean_artist_name
@ -54,6 +56,7 @@ class Artist(TimeStampedModel):
theaudiodb_genre = models.CharField(max_length=255, **BNULL)
theaudiodb_mood = models.CharField(max_length=255, **BNULL)
musicbrainz_id = models.CharField(max_length=255, **BNULL)
similar_artists = models.JSONField(**BNULL)
allmusic_id = models.CharField(max_length=100, **BNULL)
bandcamp_id = models.CharField(max_length=100, **BNULL)
thumbnail = models.ImageField(upload_to="artist/", **BNULL)
@ -124,7 +127,7 @@ class Artist(TimeStampedModel):
def charts(self):
from scrobbles.models import ChartRecord
return ChartRecord.objects.filter(track__artist=self).order_by("-year")
return ChartRecord.objects.filter(track__artists=self).order_by("-year")
def scrape_allmusic(self, force=False) -> None:
if not self.allmusic_id or force:
@ -294,7 +297,7 @@ class Album(TimeStampedModel):
alt_names = models.TextField(**BNULL)
def __str__(self) -> str:
return "{} by {}".format(self.name, self.album_artist)
return "{} by {}".format(self.name, self.artist)
def get_absolute_url(self):
return reverse("music:album_detail", kwargs={"slug": self.uuid})
@ -320,6 +323,10 @@ class Album(TimeStampedModel):
.order_by("-scrobble_count")
)
@property
def artist(self):
return self.artists.first() or self.album_artist
def fix_album_artist(self):
from music.utils import get_or_create_various_artists
@ -338,10 +345,10 @@ class Album(TimeStampedModel):
)
return
if self.album_artist and (not self.allmusic_id or force):
slug = get_allmusic_slug(self.album_artist.name, self.name)
if self.artist and (not self.allmusic_id or force):
slug = get_allmusic_slug(self.artist.name, self.name)
if not slug:
logger.info(f"No allmsuic link for {self} by {self.album_artist}")
logger.info(f"No allmsuic link for {self} by {self.artist}")
return
self.allmusic_id = slug
self.save(update_fields=["allmusic_id"])
@ -351,7 +358,7 @@ class Album(TimeStampedModel):
allmusic_data = scrape_data_from_allmusic(self.allmusic_link)
if not allmusic_data:
logger.info(f"No allmsuic data for {self} by {self.album_artist}")
logger.info(f"No allmsuic data for {self} by {self.artist}")
return
self.allmusic_review = allmusic_data["review"]
@ -360,8 +367,8 @@ class Album(TimeStampedModel):
def scrape_theaudiodb(self) -> None:
artist = "Various Artists"
if self.album_artist:
artist = self.album_artist.name
if self.artist:
artist = self.artist.name
album_data = lookup_album_from_tadb(self.name, artist)
if not album_data.get("theaudiodb_id"):
logger.info(f"No data for {self} found in TheAudioDB")
@ -374,7 +381,7 @@ class Album(TimeStampedModel):
def scrape_bandcamp(self, force=False) -> None:
if not self.bandcamp_id or force:
slug = get_bandcamp_slug(self.album_artist.name, self.name)
slug = get_bandcamp_slug(self.artist.name, self.name)
if not slug:
logger.info(f"No bandcamp link for {self}")
return
@ -422,7 +429,8 @@ class Album(TimeStampedModel):
self.artists.add(new_artist)
if not new_artist:
for t in self.track_set.all():
self.artists.add(t.artist)
for a in t.artists.all():
self.artists.add(a)
if not self.cover_image or self.cover_image == "default-image-replace-me":
self.fetch_artwork()
self.fix_album_artist()
@ -484,19 +492,19 @@ class Album(TimeStampedModel):
@property
def rym_link(self):
artist_slug = self.album_artist.name.lower().replace(" ", "-")
artist_slug = self.artist.name.lower().replace(" ", "-")
album_slug = self.name.lower().replace(" ", "-")
return f"https://rateyourmusic.com/release/album/{artist_slug}/{album_slug}/"
@property
def bandcamp_link(self):
if self.bandcamp_id and self.album_artist.bandcamp_id:
return f"https://{self.album_artist.bandcamp_id}.bandcamp.com/album/{self.bandcamp_id}"
if self.bandcamp_id and self.artist.bandcamp_id:
return f"https://{self.artist.bandcamp_id}.bandcamp.com/album/{self.bandcamp_id}"
return ""
@property
def bandcamp_search_link(self):
artist = self.album_artist.name.lower()
artist = self.artist.name.lower()
album = self.name.lower()
return f"https://bandcamp.com/search?q={album} {artist}&item_type=a"
@ -564,7 +572,7 @@ class Album(TimeStampedModel):
album_artist=artist,
alt_names=alt_name,
)
album.artists.add(*extra_artists)
album.artists.add(artist, *extra_artists)
album.fetch_artwork()
return album
@ -573,19 +581,28 @@ class Album(TimeStampedModel):
class Track(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, "MUSIC_COMPLETION_PERCENT", 100)
artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING)
artist_fk = models.ForeignKey(
Artist, on_delete=models.DO_NOTHING, **BNULL, related_name="+"
)
artists = models.ManyToManyField(Artist)
albums = models.ManyToManyField(Album, related_name="tracks")
album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL)
musicbrainz_id = models.CharField(max_length=255, **BNULL)
similar_recordings = models.JSONField(**BNULL)
class Meta:
unique_together = [["album", "musicbrainz_id"]]
@property
def artist(self):
return self.artists.first() or self.artist_fk
def __str__(self):
return f"{self.title} by {self.artist}"
@property
def logdata_cls(self):
return TrackLogData()
return TrackLogData
@property
def primary_album(self):
@ -615,8 +632,9 @@ class Track(ScrobblableMixin):
@property
def primary_image_url(self) -> str:
url = ""
if self.artist.thumbnail:
url = self.artist.thumbnail_medium.url
primary_artist = self.artist
if primary_artist and primary_artist.thumbnail:
url = primary_artist.thumbnail_medium.url
if self.primary_album and self.primary_album.cover_image:
url = self.primary_album.cover_image_medium.url
return url
@ -625,6 +643,7 @@ class Track(ScrobblableMixin):
def find_or_create(
cls,
title: str = "",
artist_names: list[str] | None = None,
artist_name: str = "",
album_name: str = "",
run_time_seconds: int | None = None,
@ -638,25 +657,40 @@ class Track(ScrobblableMixin):
name
Optionally, we can update any found artists with overwrite."""
from music.utils import parse_artist_names
if artist_names is None and artist_name:
artist_names = parse_artist_names(artist_name)
if not artist_names:
artist_names = []
album = None
if album_name:
logger.info(f"Looking up album for: {album_name}")
album = Album.find_or_create(name=album_name, artist_name=artist_name)
artist = album.album_artist
else:
artist = Artist.find_or_create(artist_name, track_name=title)
if not artist:
artist = Artist.find_or_create(artist_name)
first_name = artist_names[0] if artist_names else ""
album = Album.find_or_create(name=album_name, artist_name=first_name)
artist_objs = []
for name in artist_names:
artist = Artist.find_or_create(name, track_name=title)
if artist:
artist_objs.append(artist)
track = None
if artist_objs:
track = cls.objects.filter(title=title, artists__in=artist_objs).first()
lookup_keys = {"title": title, "artist": artist}
if run_time_seconds:
lookup_keys["base_run_time_seconds"] = run_time_seconds
logger.info(f"Looking up track using: {lookup_keys}")
track = cls.objects.filter(**lookup_keys).first()
if not track:
track = cls.objects.filter(title=title, artist=artist).first()
if not track:
track, _ = cls.objects.get_or_create(title=title, artist=artist)
track = cls.objects.filter(title=title).first()
if not track:
track = cls(title=title)
track.save()
track.refresh_from_db()
if artist_objs:
track.artists.add(*artist_objs)
if album:
track.albums.add(album)
@ -665,29 +699,37 @@ class Track(ScrobblableMixin):
if mbid and run_time_seconds:
track.base_run_time_seconds = run_time_seconds
track.musicbrainz_id = mbid
track.tags.add("musicbrainz-provider", "musicbrainz-enriched")
else:
artist_name_str = " & ".join(artist_names) if artist_names else ""
logger.info(
f"Enriching track {track}",
extra={
"title": title,
"artist_name": artist_name,
"artist_name": artist_name_str,
"track_id": track.id,
},
)
try:
mbid, length = get_recording_mbid_exact(
title, artist_name, album_name
)
except Exception:
result, method = resolve_track(title, artist_name_str, album_name)
if result and result.get("recording_mbid"):
track.musicbrainz_id = result["recording_mbid"]
length_ms = result.get("length_ms")
if length_ms and not track.base_run_time_seconds:
track.base_run_time_seconds = int(int(length_ms) / 1000)
method_tag = f"musicbrainz-{method}" if method else ""
track.tags.add(method_tag, "musicbrainz-enriched")
cleaned_title, featured_names = extract_featured_artists(title)
for feat_name in featured_names:
artist = Artist.find_or_create(feat_name, track_name=title)
if artist:
track.artists.add(artist)
else:
print("No musicbrainz result found, cannot enrich")
track.tags.add("musicbrainz-notfound")
return track
track.base_run_time_seconds = run_time_seconds or int(length / 1000)
track.musicbrainz_id = mbid
if commit:
track.save()
return track
def fix_metadata(self, force_update=False):
...
def fix_metadata(self, force_update=False): ...

View File

@ -1,11 +1,155 @@
from datetime import datetime
import logging
import re
from datetime import datetime
import musicbrainzngs
from dateutil.parser import parse
logger = logging.getLogger(__name__)
TRACK_SUFFIXES = [
r" - Bonus Track",
r" - Bonus",
r" - Remix",
r" - Live",
r" - Radio Edit",
r" - Extended Mix",
r" - Instrumental",
r" - Acoustic",
r" - Mix",
r" - Edit",
r" \[Explicit\]",
]
FEATURED_PATTERNS = [
(r"\(feat\. (.+?)\)", re.IGNORECASE),
(r"\(ft\. (.+?)\)", re.IGNORECASE),
(r"featuring (.+)", re.IGNORECASE),
(r"feat\. (.+)", re.IGNORECASE),
(r"ft\. (.+)", re.IGNORECASE),
]
def strip_track_suffixes(title: str) -> str:
for suffix in TRACK_SUFFIXES:
title = re.sub(suffix, "", title, flags=re.IGNORECASE)
return title.strip()
def extract_featured_artists(title: str) -> tuple[str, list[str]]:
featured = []
cleaned = title
for pattern, flags in FEATURED_PATTERNS:
m = re.search(pattern, cleaned, flags)
if m:
featured.append(m.group(1).strip())
cleaned = re.sub(pattern, "", cleaned, flags=flags).strip()
return cleaned, featured
def search_recordings(title: str, artist: str = "") -> list[dict]:
kwargs: dict = {"recording": title, "limit": 10}
if artist:
kwargs["artist"] = artist
try:
return musicbrainzngs.search_recordings(**kwargs).get("recording-list", [])
except (musicbrainzngs.WebServiceError, musicbrainzngs.ResponseError) as e:
logger.warning(f"MusicBrainz search error: {e}")
return []
def pick_best_recording(
recordings: list[dict], title: str, artist: str = ""
) -> tuple[dict | None, str]:
query_title = title.strip().casefold()
query_artist = artist.strip().casefold() if artist else ""
best_score: tuple[dict | None, str] = (None, "")
for rec in recordings:
rec_title = rec.get("title", "").strip()
score = int(rec.get("ext:score", 0))
if rec_title.casefold() != query_title:
if score >= 85 and not best_score[0]:
best_score = (rec, "score")
continue
if query_artist:
artist_credit = rec.get("artist-credit", [])
rec_artist = (
artist_credit[0]["artist"]["name"].strip()
if artist_credit
else ""
)
if rec_artist.casefold() != query_artist:
if score >= 85 and not best_score[0]:
best_score = (rec, "score")
continue
return (rec, "exact")
return best_score
def resolve_track(
title: str, artist: str = "", album: str = ""
) -> tuple[dict | None, str]:
if not title:
return (None, "")
titles_to_try: list[tuple[str, str]] = [(title, "")]
stripped = strip_track_suffixes(title)
if stripped != title:
titles_to_try.append((stripped, "stripped-"))
for attempt_title, prefix in titles_to_try:
if album and not prefix:
try:
mbid, length = get_recording_mbid_exact(
attempt_title, artist, album
)
return (
{
"recording_mbid": mbid,
"length_ms": length,
},
"exact",
)
except Exception:
pass
if artist:
result = get_track_metadata_with_artist(attempt_title, artist)
if result and result.get("recording_mbid"):
return (result, f"{prefix}exact")
recordings = search_recordings(attempt_title, artist)
result, tag = pick_best_recording(recordings, attempt_title, artist)
if result:
length_ms = result.get("length")
return (
{
"recording_mbid": result["id"],
"length_ms": int(length_ms) if length_ms else None,
},
f"{prefix}{tag}",
)
recordings = search_recordings(attempt_title)
result, tag = pick_best_recording(recordings, attempt_title)
if result:
tag = "title-only" if not prefix else f"{prefix}title-only"
length_ms = result.get("length")
return (
{
"recording_mbid": result["id"],
"length_ms": int(length_ms) if length_ms else None,
},
tag,
)
return (None, "")
musicbrainzngs.set_useragent("Vrobbler", "1.0", "help@unbl.ink")

View File

@ -15,12 +15,26 @@ def clean_artist_name(name: str) -> str:
name = re.split(" w. ", name, flags=re.IGNORECASE)[0].strip()
if " featuring " in name.lower():
name = re.split(" featuring ", name, flags=re.IGNORECASE)[0].strip()
# if " & " in name.lower() and "of the wand" not in name.lower():
# name = re.split("&", name, flags=re.IGNORECASE)[0].strip()
return name
def parse_artist_names(name: str) -> list[str]:
"""Split a combined artist string (e.g. 'A & B') into individual names.
First strips feat./featuring/w. prefixes, then splits on ' & ' to
support collaboration-style credits like 'Matt Sweeney & Bonnie Prince Billy'.
"""
name = clean_artist_name(name)
if " & " in name.lower():
return [
part.strip()
for part in re.split(r"\s+&\s+", name, flags=re.IGNORECASE)
if part.strip()
]
return [name]
def get_or_create_various_artists() -> "Artist":
from music.models import Artist
@ -35,19 +49,19 @@ def deduplicate_tracks(commit=False) -> int:
from music.models import Track
duplicates = (
Track.objects.values("artist", "title")
Track.objects.values("title")
.annotate(dup_count=models.Count("id"))
.filter(dup_count__gt=1)
)
query = models.Q()
for dup in duplicates:
query |= models.Q(artist=dup["artist"], title=dup["title"])
query |= models.Q(title=dup["title"])
duplicate_tracks = Track.objects.filter(query)
for b in duplicate_tracks:
tracks = Track.objects.filter(artist=b.artist, title=b.title)
tracks = Track.objects.filter(title=b.title)
first = tracks.first()
for other in tracks.exclude(id=first.id):
print("Moving scrobbles for", other.id, " to ", first.id)
@ -74,7 +88,7 @@ def condense_albums(commit: bool = False):
for track in Track.objects.all():
albums_to_add = []
duplicates = (
Track.objects.filter(title=track.title, artist=track.artist)
Track.objects.filter(title=track.title)
.exclude(id=track.id)
.exclude(id__in=processed_ids)
)
@ -88,7 +102,7 @@ def condense_albums(commit: bool = False):
track.albums.add(dup_track.album)
# Find out if this track appears more than once
duplicates = Track.objects.filter(title=track.title, artist=track.artist)
duplicates = Track.objects.filter(title=track.title)
if duplicates.count() > 1:
logger.info(f"Track appears more than once, condensing: {track}")

View File

@ -1,10 +1,9 @@
from charts.models import ChartRecord
from django.db.models import Count
from django.views import generic
from music.models import Album, Artist, Track
from charts.models import ChartRecord
from scrobbles.stats import get_scrobble_count_qs
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView
class TrackListView(ScrobbleableListView):
@ -20,16 +19,24 @@ class ArtistListView(generic.ListView):
paginate_by = 100
def get_queryset(self):
return (
super()
.get_queryset()
.annotate(scrobble_count=Count("track__scrobble"))
.order_by("-scrobble_count")
)
qs = super().get_queryset().annotate(scrobble_count=Count("track__scrobble"))
genre = self.request.GET.get("genre")
if genre:
qs = qs.filter(theaudiodb_genre=genre)
mood = self.request.GET.get("mood")
if mood:
qs = qs.filter(theaudiodb_mood=mood)
return qs.order_by("-scrobble_count")
def get_context_data(self, *, object_list=None, **kwargs):
context_data = super().get_context_data(object_list=object_list, **kwargs)
context_data["view"] = self.request.GET.get("view")
genre = self.request.GET.get("genre")
if genre:
context_data["active_filter"] = f"genre: {genre}"
mood = self.request.GET.get("mood")
if mood:
context_data["active_filter"] = f"mood: {mood}"
return context_data
@ -64,11 +71,38 @@ class ArtistDetailView(generic.DetailView):
from scrobbles.models import Scrobble
context_data["recent_scrobbles"] = (
Scrobble.objects.filter(track__artist=artist)
Scrobble.objects.filter(track__artists=artist)
.select_related("track", "track__album")
.order_by("-timestamp")[:100]
)
similar = artist.similar_artists or []
if similar:
top = similar[:10]
mbids = [sa["artist_mbid"] for sa in top if sa.get("artist_mbid")]
local_artists = {
a.musicbrainz_id: a
for a in Artist.objects.filter(musicbrainz_id__in=mbids)
}
for sa in top:
local = local_artists.get(sa.get("artist_mbid"))
sa["local_url"] = local.get_absolute_url() if local else None
sa["musicbrainz_url"] = (
f"https://musicbrainz.org/artist/{sa['artist_mbid']}"
if sa.get("artist_mbid")
else None
)
context_data["similar_artists"] = top
if artist.theaudiodb_genre:
context_data["genre_count"] = Artist.objects.filter(
theaudiodb_genre=artist.theaudiodb_genre
).count()
if artist.theaudiodb_mood:
context_data["mood_count"] = Artist.objects.filter(
theaudiodb_mood=artist.theaudiodb_mood
).count()
return context_data

View File

@ -6,6 +6,7 @@ from people.models import Person, PersonScrobble
class PersonAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = ("name", "bgg_username", "bgstats_id")
raw_id_fields = ("user", "created_by")
ordering = ("-created",)
search_fields = ("name",)

View File

@ -18,6 +18,7 @@ class PodcastAdmin(admin.ModelAdmin):
"producer",
"active",
)
raw_id_fields = ("producer",)
ordering = ("name",)
@ -28,6 +29,7 @@ class PodcastEpisodeAdmin(admin.ModelAdmin):
"title",
"podcast",
)
raw_id_fields = ("podcast",)
list_filter = ("podcast",)
ordering = ("-created",)
inlines = [

View File

@ -6,6 +6,7 @@ from profiles.models import UserProfile
@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin):
date_hierarchy = "created"
raw_id_fields = ("user",)
ordering = ("-created",)
readonly_fields = ("timezone_change_log",)
exclude = (

View File

@ -1,6 +1,8 @@
from django import forms
from profiles.models import UserProfile
from scrobbles.constants import Visibility
from scrobbles.models import Scrobble
class UserProfileForm(forms.ModelForm):
@ -31,6 +33,9 @@ class UserProfileForm(forms.ModelForm):
"webdav_auto_import",
"ntfy_url",
"ntfy_enabled",
"mopidy_api_url",
"favorites_mopidy_playlist",
"monthly_mopidy_playlist_pattern",
"redirect_to_webpage",
"enable_public_widgets",
"widget_custom_css",
@ -42,3 +47,51 @@ class UserProfileForm(forms.ModelForm):
"archivebox_password": forms.PasswordInput(render_value=True),
"webdav_pass": forms.PasswordInput(render_value=True),
}
MEDIA_TYPE_LABELS = {
mt.value: mt.label for mt in Scrobble.MediaType
}
INHERIT = ""
class BulkVisibilityForm(forms.Form):
bulk_action = forms.ChoiceField(
choices=[
(Visibility.PUBLIC, "Public"),
(Visibility.PRIVATE, "Private"),
],
widget=forms.RadioSelect,
required=False,
label="Set all non-shared scrobbles to",
)
def __init__(self, *args, **kwargs):
self.profile = kwargs.pop("profile")
super().__init__(*args, **kwargs)
media_types = Scrobble.MediaType.values
choices = [
(Visibility.PUBLIC, "Public"),
(Visibility.SHARED, "Shared"),
(Visibility.PRIVATE, "Private"),
]
existing_overrides = self.profile.media_type_visibility or {}
for mt in sorted(media_types):
label = MEDIA_TYPE_LABELS.get(mt, mt)
self.fields[f"media_type_{mt}"] = forms.ChoiceField(
choices=choices,
required=False,
label=label,
initial=existing_overrides.get(mt, Visibility.PRIVATE),
)
def clean(self):
cleaned = super().clean()
overrides = {}
for mt in Scrobble.MediaType.values:
val = cleaned.get(f"media_type_{mt}")
if val:
overrides[mt] = val
cleaned["media_type_visibility"] = overrides
return cleaned

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.30 on 2026-06-04 16:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0032_userprofile_weigh_in_units"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="mopidy_api_url",
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.30 on 2026-06-05 14:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0033_userprofile_mopidy_api_url"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="favorites_mopidy_playlist",
field=models.CharField(
blank=True,
help_text="Playlist name (e.g. 'Favorites'). Will map to m3u:Favorites.m3u8",
max_length=255,
null=True,
),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.30 on 2026-06-05 17:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0034_userprofile_favorites_mopidy_playlist"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="monthly_mopidy_playlist_pattern",
field=models.CharField(
blank=True,
help_text="Django date format pattern for monthly playlists (e.g. 'Y F')",
max_length=255,
null=True,
),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.29 on 2026-06-09 15:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0035_userprofile_monthly_mopidy_playlist_pattern"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="default_scrobble_visibility",
field=models.CharField(
choices=[
("public", "Public"),
("shared", "Shared"),
("private", "Private"),
],
default="shared",
max_length=10,
),
),
]

View File

@ -0,0 +1,26 @@
# Generated by Django 4.2.29 on 2026-06-09 16:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0036_userprofile_default_scrobble_visibility"),
]
operations = [
migrations.AlterField(
model_name="userprofile",
name="default_scrobble_visibility",
field=models.CharField(
choices=[
("public", "Public"),
("shared", "Shared"),
("private", "Private"),
],
default="private",
max_length=10,
),
),
]

View File

@ -0,0 +1,20 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0037_alter_userprofile_default_scrobble_visibility"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="media_type_visibility",
field=models.JSONField(
blank=True,
default=dict,
help_text='Per-media-type visibility overrides, e.g. {"Video": "public", "Track": "private"}',
),
),
]

View File

@ -9,6 +9,11 @@ from django.utils.functional import cached_property
from django_extensions.db.models import TimeStampedModel
from encrypted_field import EncryptedField
from profiles.constants import PRETTY_TIMEZONE_CHOICES
VISIBILITY_CHOICES = (
("public", "Public"),
("shared", "Shared"),
("private", "Private"),
)
User = get_user_model()
BNULL = {"blank": True, "null": True}
@ -64,11 +69,33 @@ class UserProfile(TimeStampedModel):
ntfy_url = models.CharField(max_length=255, **BNULL)
ntfy_enabled = models.BooleanField(default=False)
mopidy_api_url = models.CharField(max_length=255, **BNULL)
favorites_mopidy_playlist = models.CharField(
max_length=255, **BNULL,
help_text="Playlist name (e.g. 'Favorites'). Will map to m3u:Favorites.m3u8",
)
monthly_mopidy_playlist_pattern = models.CharField(
max_length=255, **BNULL,
help_text="Django date format pattern for monthly playlists (e.g. 'Y F')",
)
redirect_to_webpage = models.BooleanField(default=True)
enable_public_widgets = models.BooleanField(default=False)
widget_custom_css = models.TextField(**BNULL)
default_scrobble_visibility = models.CharField(
max_length=10,
choices=VISIBILITY_CHOICES,
default="private",
)
media_type_visibility = models.JSONField(
default=dict,
blank=True,
help_text="Per-media-type visibility overrides, e.g. {\"Video\": \"public\", \"Track\": \"private\"}",
)
home_scrobble_limit = models.IntegerField(default=20)
weigh_in_units = models.CharField(
@ -113,6 +140,11 @@ class UserProfile(TimeStampedModel):
return history
def get_timestamp_with_tz(self, timestamp):
from django.conf import settings
server_tz = ZoneInfo(settings.TIME_ZONE)
ref_dt = timestamp if timestamp.tzinfo is not None else timestamp.replace(tzinfo=server_tz)
timezone = self.tzinfo
if self.timezone_change_log:
change_list = self.historic_timezone_changes
@ -123,13 +155,13 @@ class UserProfile(TimeStampedModel):
end = None
if end:
if start <= timestamp.replace(tzinfo=end.timezone) <= end:
if start <= ref_dt <= end:
timezone = start.timezone
else:
if start <= timestamp.replace(tzinfo=start.timezone):
if start <= ref_dt:
timezone = start.timezone
return timestamp.replace(tzinfo=timezone)
return ref_dt.astimezone(timezone)
def adjust_timezone_of_scrobbles(self, commit=False):
current_dt = None

View File

@ -6,4 +6,9 @@ app_name = "profiles"
urlpatterns = [
path("settings/", views.ProfileFormView.as_view(), name="profile_settings"),
path(
"settings/visibility/",
views.BulkVisibilityView.as_view(),
name="bulk_visibility",
),
]

View File

@ -1,8 +1,12 @@
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Count, Q
from django.http.response import HttpResponseBadRequest
from django.urls import reverse_lazy
from django.views.generic import FormView
from profiles.forms import UserProfileForm
from profiles.forms import BulkVisibilityForm, UserProfileForm
from scrobbles.constants import Visibility
from scrobbles.models import Scrobble
from tasks.todoist import generate_todoist_oauth_url
@ -30,3 +34,46 @@ class ProfileFormView(LoginRequiredMixin, FormView):
context["profile"] = self.request.user.profile
context["todoist_oauth_url"] = generate_todoist_oauth_url(self.request.user.id)
return context
class BulkVisibilityView(LoginRequiredMixin, FormView):
template_name = "profiles/visibility_settings.html"
form_class = BulkVisibilityForm
success_url = reverse_lazy("profiles:bulk_visibility")
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["profile"] = self.request.user.profile
return kwargs
def form_valid(self, form):
request = self.request
profile = request.user.profile
bulk_action = form.cleaned_data.get("bulk_action")
if bulk_action:
qs = Scrobble.objects.filter(
user=request.user,
).exclude(visibility=Visibility.SHARED)
total = qs.count()
qs.update(visibility=bulk_action)
messages.success(
request,
f"Updated {total} scrobble(s) to {bulk_action}.",
)
profile.media_type_visibility = form.cleaned_data["media_type_visibility"]
profile.save(update_fields=["media_type_visibility"])
messages.success(request, "Per-media-type visibility overrides saved.")
return super().form_valid(form)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
profile = self.request.user.profile
qs = Scrobble.objects.filter(user=self.request.user)
ctx["scrobble_count"] = qs.count()
ctx["visibility_counts"] = qs.values("visibility").annotate(
count=Count("id")
)
ctx["profile"] = profile
return ctx

View File

@ -21,6 +21,7 @@ class PuzzleAdmin(admin.ModelAdmin):
"uuid",
"title",
)
raw_id_fields = ("manufacturer",)
ordering = ("-created",)
search_fields = ("title",)
inlines = [

View File

@ -4,11 +4,13 @@ from scrobbles.models import (
AudioScrobblerTSVImport,
BGStatsImport,
EBirdCSVImport,
FavoriteMedia,
KoReaderImport,
LastFmImport,
RetroarchImport,
ScaleCSVImport,
Scrobble,
ShareViewLog,
TrailGPXImport,
)
from scrobbles.mixins import Genre
@ -17,33 +19,11 @@ from scrobbles.mixins import Genre
class ScrobbleInline(admin.TabularInline):
model = Scrobble
extra = 0
raw_id_fields = (
"video",
"podcast_episode",
"track",
"video_game",
"book",
"paper",
"sport_event",
"food",
"board_game",
"geo_location",
"task",
"mood",
"brick_set",
"trail",
"beer",
"web_page",
"life_event",
"birding_location",
"user",
)
exclude = (
"scrobble_log",
"timezone",
"videogame_save_data",
"screenshot",
)
per_page = 15
ordering = ("-timestamp",)
show_change_link = True
fields = ("timestamp", "media_type", "source", "in_progress")
readonly_fields = fields
class ImportBaseAdmin(admin.ModelAdmin):
@ -53,52 +33,44 @@ class ImportBaseAdmin(admin.ModelAdmin):
"process_count",
"processed_finished",
"processing_started",
"error_log",
)
ordering = ("-created",)
@admin.register(AudioScrobblerTSVImport)
class AudioScrobblerTSVImportAdmin(ImportBaseAdmin):
...
class AudioScrobblerTSVImportAdmin(ImportBaseAdmin): ...
@admin.register(LastFmImport)
class LastFmImportAdmin(ImportBaseAdmin):
...
class LastFmImportAdmin(ImportBaseAdmin): ...
@admin.register(KoReaderImport)
class KoReaderImportAdmin(ImportBaseAdmin):
...
class KoReaderImportAdmin(ImportBaseAdmin): ...
@admin.register(RetroarchImport)
class RetroarchImportAdmin(ImportBaseAdmin):
...
class RetroarchImportAdmin(ImportBaseAdmin): ...
class RetroarchImportAdmin(ImportBaseAdmin):
...
class RetroarchImportAdmin(ImportBaseAdmin): ...
@admin.register(BGStatsImport)
class BGStatsImportAdmin(ImportBaseAdmin):
...
class BGStatsImportAdmin(ImportBaseAdmin): ...
@admin.register(EBirdCSVImport)
class EBirdCSVImportAdmin(ImportBaseAdmin):
...
class EBirdCSVImportAdmin(ImportBaseAdmin): ...
@admin.register(ScaleCSVImport)
class ScaleCSVImportAdmin(ImportBaseAdmin):
...
class ScaleCSVImportAdmin(ImportBaseAdmin): ...
@admin.register(TrailGPXImport)
class TrailGPXImportAdmin(ImportBaseAdmin):
...
class TrailGPXImportAdmin(ImportBaseAdmin): ...
@admin.register(Genre)
@ -121,10 +93,13 @@ class ScrobbleAdmin(admin.ModelAdmin):
"in_progress",
"is_paused",
"played_to_completion",
"visibility",
"user",
)
raw_id_fields = (
"user",
"video",
"channel",
"podcast_episode",
"track",
"sport_event",
@ -143,17 +118,20 @@ class ScrobbleAdmin(admin.ModelAdmin):
"web_page",
"life_event",
"birding_location",
"long_play_last_scrobble",
)
list_filter = (
"is_paused",
"in_progress",
"media_type",
"visibility",
"long_play_complete",
"source",
"timezone",
"user",
)
ordering = ("-timestamp",)
readonly_fields = ("share_token_version", "share_view_count")
def media_name(self, obj):
return obj.media_obj
@ -164,3 +142,41 @@ class ScrobbleAdmin(admin.ModelAdmin):
def get_queryset(self, request):
qs = super().get_queryset(request).exclude(timestamp__year=None)
return qs
@admin.register(ShareViewLog)
class ShareViewLogAdmin(admin.ModelAdmin):
list_display = ("scrobble", "ip_address", "created")
list_filter = ("created",)
date_hierarchy = "created"
raw_id_fields = ("scrobble",)
@admin.register(FavoriteMedia)
class FavoriteMediaAdmin(admin.ModelAdmin):
list_display = ("user", "media_type", "sent_to_mopidy", "created")
list_filter = ("media_type", "sent_to_mopidy", "user")
date_hierarchy = "created"
raw_id_fields = (
"user",
"video",
"channel",
"track",
"podcast_episode",
"sport_event",
"book",
"paper",
"video_game",
"board_game",
"geo_location",
"puzzle",
"food",
"task",
"mood",
"brick_set",
"trail",
"beer",
"web_page",
"life_event",
"birding_location",
)

View File

@ -1,6 +1,7 @@
import re
from rest_framework import serializers
from scrobbles.constants import Visibility
from scrobbles.models import (
AudioScrobblerTSVImport,
KoReaderImport,

View File

@ -1,6 +1,8 @@
from logging import getLogger
from rest_framework import permissions, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
from scrobbles.api.serializers import (
AudioScrobblerTSVImportSerializer,
KoReaderImportSerializer,
@ -26,6 +28,12 @@ class ScrobbleViewSet(viewsets.ModelViewSet):
def get_queryset(self):
return super().get_queryset().filter(user=self.request.user)
@action(detail=True, methods=["post"])
def regenerate_share_token(self, request, uuid=None):
scrobble = self.get_object()
scrobble.regenerate_share_token()
return Response({"share_url": scrobble.get_share_url()})
class KoReaderImportViewSet(viewsets.ModelViewSet):
queryset = KoReaderImport.objects.all().order_by("-created")

View File

@ -1,6 +1,12 @@
from django.db import models
from enum import Enum
JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"]
class Visibility(models.TextChoices):
PUBLIC = "public", "Public"
SHARED = "shared", "Shared"
PRIVATE = "private", "Private"
JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"]
LONG_PLAY_MEDIA = {

View File

@ -50,6 +50,18 @@ class BaseLogData(JSONDataclass):
def override_fields(cls) -> dict:
return {}
@classmethod
def from_log_dict(cls, log_dict: dict) -> dict:
"""Extract LogData keyword arguments from a stored log dict.
Override in subclasses to handle custom nesting/structure.
"""
return {
k: v
for k, v in log_dict.items()
if k in cls.__dataclass_fields__
}
def notes_as_str(self, separator: str = " | ") -> str:
import html
import re
@ -57,43 +69,165 @@ class BaseLogData(JSONDataclass):
if not self.notes:
return ""
if isinstance(self.notes, dict):
lines = []
for ts, text in self.notes.items():
note_text = " ".join(text.strip().split())
lines.append(f"{ts}: {note_text}")
return separator.join(lines)
if isinstance(self.notes, str):
return html.escape(re.sub(r"\s*\[start:\d+\|end:\d+\]$", "", self.notes))
if isinstance(self.notes, list):
cleaned_notes = []
for note in self.notes:
cleaned = html.escape(re.sub(r"\s*\[start:\d+\|end:\d+\]$", "", note))
cleaned_notes.append(cleaned)
if isinstance(note, dict):
timestamp, note_text = next(iter(note.items()))
note_text = " ".join(note_text.strip().split())
cleaned_notes.append(f"{timestamp}: {note_text}")
elif isinstance(note, str):
cleaned = html.escape(
re.sub(r"\s*\[start:\d+\|end:\d+\]$", "", note)
)
cleaned_notes.append(cleaned)
return separator.join(cleaned_notes)
return ""
@staticmethod
def _format_timestamp(ts: str) -> str:
from datetime import datetime, timezone
import re
cleaned = ts.strip().strip("[]")
dt = None
if cleaned.isdigit():
try:
seconds = int(cleaned)
dt = datetime.fromtimestamp(seconds, tz=timezone.utc)
except (ValueError, OSError):
pass
if dt is None:
for fmt in [
"%Y-%m-%dT%H:%M:%S.%fZ",
"%Y-%m-%dT%H:%M:%SZ",
"%Y-%m-%dT%H:%M:%S.%f",
"%Y-%m-%dT%H:%M:%S",
]:
try:
dt = datetime.strptime(cleaned, fmt)
break
except ValueError:
continue
if dt is None:
m = re.match(r"(\d{4}-\d{2}-\d{2})\s+\w{3}\s+(\d{2}:\d{2})", cleaned)
if m:
try:
dt = datetime.strptime(
f"{m.group(1)} {m.group(2)}", "%Y-%m-%d %H:%M"
)
except ValueError:
pass
if dt is None:
try:
dt = datetime.fromisoformat(cleaned)
except ValueError:
pass
if dt:
return dt.strftime("%b %-d, %Y %-I:%M %p")
return ts
def notes_as_html(self) -> str:
import html
import bleach
import markdown
from django.utils.safestring import mark_safe
if not self.notes:
return ""
notes_list = []
if isinstance(self.notes, str):
notes_list = [self.notes]
elif isinstance(self.notes, list):
notes_list = self.notes
md = markdown.Markdown(extensions=["extra"])
allowed_tags = [
"p",
"br",
"strong",
"em",
"a",
"ul",
"ol",
"li",
"code",
"pre",
"blockquote",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"img",
]
html_notes = []
for note in notes_list:
for line in note.split("\n"):
note_items = []
if isinstance(self.notes, dict):
for ts, text in sorted(self.notes.items(), reverse=True):
note_items.append((ts, text.strip()))
elif isinstance(self.notes, str):
for line in self.notes.split("\n"):
if line.strip():
escaped_line = html.escape(line)
html_notes.append(f'<div class="sticky-note">{escaped_line}</div>')
return mark_safe("".join(html_notes))
note_items.append((None, line.strip()))
elif isinstance(self.notes, list):
for note in self.notes:
if isinstance(note, dict):
timestamp, note_text = next(iter(note.items()))
note_items.append((timestamp, note_text.strip()))
elif isinstance(note, str):
for line in note.split("\n"):
if line.strip():
note_items.append((None, line.strip()))
html_parts = []
for i, (ts, text) in enumerate(note_items):
if i > 0:
html_parts.append('<hr class="note-divider">')
ts_html = ""
if ts:
ts_html = (
f'<h5 class="note-timestamp">{self._format_timestamp(ts)}</h5>'
)
content_html = bleach.clean(
md.convert(text),
tags=allowed_tags,
strip=True,
)
html_parts.append(
f'<div class="note-item">{ts_html}<div class="note-content">{content_html}</div></div>'
)
return mark_safe("".join(html_parts))
@dataclass
class LongPlayLogData(JSONDataclass):
long_play_complete: bool = False
pass
@dataclass
class SportEventLogData(BaseLogData):
thesportsdb_id: Optional[str] = None
start: Optional[str] = None
round_name: Optional[str] = None
season_name: Optional[str] = None
@dataclass

View File

@ -1,8 +1,8 @@
import csv
import tempfile
from scrobbles.models import Scrobble
from django.db.models import Q
from scrobbles.models import Scrobble
def export_scrobbles(start_date=None, end_date=None, format="AS"):
@ -50,7 +50,7 @@ def export_scrobbles(start_date=None, end_date=None, format="AS"):
track = scrobble.track
track_number = 0 # TODO Add track number
track_rating = "S" # TODO implement ratings?
track_artist = track.artist or track.album.album_artist
track_artist = track.artist or track.album.artist
row = [
track_artist,
track.album.name,

View File

@ -1,3 +1,4 @@
import json
from dataclasses import fields
from typing import Union, get_args, get_origin
@ -89,7 +90,7 @@ def form_from_dataclass(dataclass):
form_cls = type(f"{dataclass.__name__}Form", (forms.Form,), form_fields)
if "notes" in form_cls.base_fields:
if "notes" in form_cls.base_fields and "notes" not in override_fields:
form_cls.base_fields["notes"] = forms.CharField(
required=False,
widget=forms.Textarea(attrs={"rows": 4}),
@ -101,3 +102,54 @@ def form_from_dataclass(dataclass):
form_cls.clean_notes = clean_notes
return form_cls
class NotesDictWidget(forms.Widget):
template_name = "tasks/task_notes_widget.html"
class Media:
js = ("tasks/task_notes.js",)
def value_from_datadict(self, data, files, name):
timestamps = data.getlist(f"{name}_timestamps")
if timestamps:
contents = data.getlist(f"{name}_contents")
result = {}
for i, ts in enumerate(timestamps):
if i < len(contents) and ts:
result[ts] = contents[i]
return result if result else ""
return data.get(name, "")
def get_context(self, name, value, attrs):
context = super().get_context(name, value, attrs)
notes = {}
if value:
if isinstance(value, str):
try:
notes = json.loads(value)
except (json.JSONDecodeError, TypeError):
notes = {}
elif isinstance(value, dict):
notes = value
context["widget"]["notes"] = notes
return context
class NotesDictField(forms.Field):
widget = NotesDictWidget
def clean(self, value):
if not value:
return {}
if isinstance(value, str):
if value.strip():
from scrobbles.utils import make_note_timestamp
return {make_note_timestamp(): value.strip()}
return {}
if isinstance(value, dict):
return value
return {}

View File

@ -76,6 +76,7 @@ class LastFM:
in_progress=False,
media_type=Scrobble.MediaType.TRACK,
timezone=tz_timestamp.tzinfo.name,
visibility="private",
)
# Vrobbler scrobbles on finish, LastFM scrobbles on start
seconds_eariler = timestamp - timedelta(seconds=20)
@ -92,7 +93,6 @@ class LastFM:
new_scrobbles.append(new_scrobble)
created = Scrobble.objects.bulk_create(new_scrobbles)
# TODO Add a notification for users that their import is complete
logger.info(
f"Last.fm import fnished",
extra={

View File

@ -5,6 +5,7 @@ from datetime import datetime, timedelta
from django.contrib.auth import get_user_model
from scrobbles.models import Scrobble
from scrobbles.notifications import ScrobbleNtfyNotification
from tasks.models import Task
logger = logging.getLogger(__name__)
@ -85,6 +86,11 @@ def import_scale_csv(file_path, user_id):
log_dict["unit_type"] = user.profile.weigh_in_units
weight_val = log_dict.get("weight")
if weight_val is not None:
unit = "kg" if user.profile.weigh_in_units == "metric" else "lbs"
log_dict["title"] = f"{weight_val} {unit}"
existing = Scrobble.objects.filter(
timestamp=start,
task=weigh_in,
@ -105,9 +111,12 @@ def import_scale_csv(file_path, user_id):
played_to_completion=True,
in_progress=False,
media_type=Scrobble.MediaType.TASK,
visibility="private",
)
new_scrobbles.append(new_scrobble)
created = Scrobble.objects.bulk_create(new_scrobbles)
logger.info(f"Created {len(created)} weigh-in scrobbles")
for scrobble in created:
ScrobbleNtfyNotification(scrobble).send()
return created

View File

@ -12,6 +12,7 @@ from django.core.files import File
from locations.models import GeoLocation
from scrobbles.models import Scrobble
from scrobbles.notifications import ScrobbleNtfyNotification
from trails.models import Trail, TrailLogData
logger = logging.getLogger(__name__)
@ -304,6 +305,7 @@ def import_trail_gpx(file_path, user_id, original_filename=None):
played_to_completion=True,
in_progress=False,
media_type=Scrobble.MediaType.TRAIL,
visibility="private",
)
_, ext = os.path.splitext(file_path)
@ -328,4 +330,6 @@ def import_trail_gpx(file_path, user_id, original_filename=None):
created = Scrobble.objects.bulk_create(new_scrobbles)
logger.info(f"Created {len(created)} trail scrobbles")
for scrobble in created:
ScrobbleNtfyNotification(scrobble).send()
return created

View File

@ -80,6 +80,7 @@ def import_audioscrobbler_tsv_file(file_path, user_id):
in_progress=False,
media_type=Scrobble.MediaType.TRACK,
timezone=timestamp.tzinfo.name,
visibility="private",
)
existing = Scrobble.objects.filter(
timestamp=timestamp, track=track, user=user

View File

@ -63,12 +63,19 @@ def import_from_webdav_for_all_users(
)
logger.info("Scanning WebDAV retroarch for user %s", user_id)
retro_count += scan_webdav_for_retroarch(
client, user_id, update_hash_only=update_retroarch_hash
client,
user_id,
update_hash_only=update_retroarch_hash,
include_processed=include_processed,
)
logger.info("Scanning WebDAV bgstats for user %s", user_id)
bgstats_count += scan_webdav_for_bgstats(client, user_id)
bgstats_count += scan_webdav_for_bgstats(
client, user_id, include_processed=include_processed
)
logger.info("Scanning WebDAV ebird for user %s", user_id)
ebird_count += scan_webdav_for_ebird(client, user_id)
ebird_count += scan_webdav_for_ebird(
client, user_id, include_processed=include_processed
)
logger.info("Scanning WebDAV scale for user %s", user_id)
scale_count += scan_webdav_for_scale(client, user_id)
@ -146,7 +153,7 @@ def scan_webdav_for_koreader(
)
if update_etag_only:
if last_import and remote_etag:
if last_import and remote_etag and hasattr(last_import, "webdav_etag"):
last_import.webdav_etag = remote_etag
last_import.save(update_fields=["webdav_etag"])
logger.info(
@ -156,7 +163,7 @@ def scan_webdav_for_koreader(
)
return 0
if last_import and last_import.webdav_etag and remote_etag:
if last_import and getattr(last_import, "webdav_etag", None) and remote_etag:
if last_import.webdav_etag == remote_etag:
logger.info(
"koreader stats file unchanged (ETag match)",
@ -308,7 +315,9 @@ def scan_webdav_for_gpx(webdav_client, user_id, include_processed=False):
return new_imports
def scan_webdav_for_retroarch(webdav_client, user_id, update_hash_only=False):
def scan_webdav_for_retroarch(
webdav_client, user_id, update_hash_only=False, include_processed=False
):
"""Check for new .lrtl files on WebDAV, download+import if changed.
Uses ETags from a single PROPFIND to detect changes without downloading
@ -317,16 +326,24 @@ def scan_webdav_for_retroarch(webdav_client, user_id, update_hash_only=False):
When *update_hash_only* is True, mismatched hashes update the last import's
stored hash without re-downloading — useful when migrating to a new hash
scheme to avoid a one-time re-import.
When *include_processed* is True, also imports files from the processed/
subdirectory. These are sorted chronologically by their YYYYMMDDHHMM-
timestamp prefix so that the importer always sees the earliest snapshot
first, which is critical for correct incremental scrobbling since
retroarch overwrites the same .lrtl filename.
"""
import hashlib
import shutil
import re
import zipfile
from datetime import datetime
from scrobbles.models import RetroarchImport
from scrobbles.tasks import process_retroarch_import
retroarch_path = DEFAULT_RETROARCH_PATH + "playlogs/"
retroarch_path = DEFAULT_RETROARCH_PATH
processed_dir = f"{retroarch_path}processed/"
try:
webdav_client.info(retroarch_path)
except:
@ -334,7 +351,13 @@ def scan_webdav_for_retroarch(webdav_client, user_id, update_hash_only=False):
return 0
try:
files = webdav_client.list(retroarch_path, get_info=True)
webdav_client.mkdir(processed_dir, recursive=True)
except Exception:
pass
# Collect files from root directory
try:
root_files = webdav_client.list(retroarch_path, get_info=True)
except Exception as e:
logger.warning(
"Could not list var/retroarch/",
@ -342,14 +365,51 @@ def scan_webdav_for_retroarch(webdav_client, user_id, update_hash_only=False):
)
return 0
lrtl_files = sorted(
[f for f in files if (f.get("name") or "").lower().endswith(".lrtl")],
key=lambda x: (x.get("name") or ""),
)
# Extract basename from path since the webdav adapter returns name=None
for f in root_files:
if f.get("path") and not f.get("name"):
f["name"] = os.path.basename(f["path"])
lrtl_files = [
f for f in root_files
if (f.get("name") or "").lower().endswith(".lrtl")
]
# Optionally include historical files from processed/
if include_processed:
try:
processed_files = webdav_client.list(processed_dir, get_info=True)
except Exception as e:
logger.warning(
"Could not list var/retroarch/processed/",
extra={"user_id": user_id, "error": str(e)},
)
else:
for f in processed_files:
if f.get("path") and not f.get("name"):
f["name"] = os.path.basename(f["path"])
lrtl_files.extend([
f for f in processed_files
if (f.get("name") or "").lower().endswith(".lrtl")
])
if not lrtl_files:
logger.info("No .lrtl files found on webdav", extra={"user_id": user_id})
return 0
# Sort chronologically: processed files (timestamp prefixed) first,
# current root files last.
ts_pattern = re.compile(r"^(\d{12})-")
def sort_key(f):
name = f.get("name") or ""
m = ts_pattern.match(name)
if m:
return m.group(1)
# Root files (no timestamp) go after all historical snapshots
return "999999999999"
lrtl_files.sort(key=sort_key)
# Don't queue if one is still being processed
if RetroarchImport.objects.filter(
user_id=user_id, processed_finished__isnull=True
@ -367,7 +427,6 @@ def scan_webdav_for_retroarch(webdav_client, user_id, update_hash_only=False):
hasher.update((f.get("etag") or f.get("modified") or "").encode())
content_hash = hasher.hexdigest()
# Skip if the last completed import already has this hash
last_import = (
RetroarchImport.objects.filter(
user_id=user_id, processed_finished__isnull=False
@ -375,7 +434,9 @@ def scan_webdav_for_retroarch(webdav_client, user_id, update_hash_only=False):
.order_by("-processed_finished")
.first()
)
if last_import and last_import.files_hash == content_hash:
# Skip unchanged root files only, never skip when re-processing history
if not include_processed and last_import and last_import.files_hash == content_hash:
logger.info(
"Retroarch lrtl files unchanged for user, skipping",
extra={"user_id": user_id},
@ -396,15 +457,32 @@ def scan_webdav_for_retroarch(webdav_client, user_id, update_hash_only=False):
download_dir = tempfile.mkdtemp()
try:
downloaded = []
imported_filenames = []
for f in lrtl_files:
basename = f.get("name")
# Determine source directory: processed files already live under processed/
is_processed = ts_pattern.match(basename)
source_dir = processed_dir if is_processed else retroarch_path
remote_path = f"{source_dir}{basename}"
dst = os.path.join(download_dir, basename)
try:
webdav_client.download_sync(
remote_path=f.get("path"),
remote_path=remote_path,
local_path=dst,
)
downloaded.append(basename)
if is_processed:
imported_filenames.append(basename)
else:
ts = datetime.now().strftime("%Y%m%d%H%M%S")
processed_name = f"{ts}-{basename}"
imported_filenames.append(processed_name)
webdav_client.move(
remote_path,
f"{processed_dir}{processed_name}",
)
except Exception as e:
logger.error("Failed to download retroarch %s: %s", basename, e)
@ -421,7 +499,7 @@ def scan_webdav_for_retroarch(webdav_client, user_id, update_hash_only=False):
imp = RetroarchImport.objects.create(
user_id=user_id,
original_filename=f"batch-{len(downloaded)}-files",
original_filename=", ".join(imported_filenames),
files_hash=content_hash,
)
with open(zip_path, "rb") as f:
@ -438,8 +516,12 @@ def scan_webdav_for_retroarch(webdav_client, user_id, update_hash_only=False):
shutil.rmtree(download_dir, ignore_errors=True)
def scan_webdav_for_bgstats(webdav_client, user_id):
"""Download .bgsplay files from WebDAV and queue imports for new files."""
def scan_webdav_for_bgstats(webdav_client, user_id, include_processed=False):
"""Download .bgsplay files from WebDAV and queue imports for new files.
After importing, files are moved to var/bgstats/processed/ so they are
not re-imported on subsequent scans unless *include_processed* is True.
"""
from scrobbles.models import BGStatsImport
from scrobbles.tasks import process_bgstats_import
@ -459,6 +541,12 @@ def scan_webdav_for_bgstats(webdav_client, user_id):
)
return 0
processed_dir = f"{bgstats_path}processed/"
try:
webdav_client.mkdir(processed_dir, recursive=True)
except Exception:
pass
new_imports = 0
already_imported = set(
BGStatsImport.objects.filter(user_id=user_id).values_list(
@ -470,6 +558,8 @@ def scan_webdav_for_bgstats(webdav_client, user_id):
fname = os.path.basename(fname)
if not fname.lower().endswith(".bgsplay"):
continue
if fname == "processed":
continue
if fname in already_imported:
logger.debug(f"Skipping already-imported {fname}")
continue
@ -477,7 +567,7 @@ def scan_webdav_for_bgstats(webdav_client, user_id):
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname)
try:
webdav_client.download_sync(
remote_path=f"{bgstats_path}/{fname}", local_path=tmp.name
remote_path=f"{bgstats_path}{fname}", local_path=tmp.name
)
imp = BGStatsImport.objects.create(
user_id=user_id,
@ -485,6 +575,14 @@ def scan_webdav_for_bgstats(webdav_client, user_id):
)
with open(tmp.name, "rb") as f:
imp.bgsplay_file.save(fname, f, save=True)
stem, ext = os.path.splitext(fname)
ts = datetime.now().strftime("%Y%m%dT%H%M%S")
webdav_client.move(
f"{bgstats_path}{fname}",
f"{processed_dir}{stem}_{ts}{ext}",
)
process_bgstats_import.delay(imp.id)
new_imports += 1
except Exception as e:
@ -492,11 +590,48 @@ def scan_webdav_for_bgstats(webdav_client, user_id):
finally:
os.unlink(tmp.name)
if include_processed:
try:
processed_files = webdav_client.list(processed_dir)
except Exception as e:
logger.warning(
"Could not list var/bgstats/processed/",
extra={"user_id": user_id, "error": str(e)},
)
return new_imports
for fname in processed_files:
fname = os.path.basename(fname)
if not fname.lower().endswith(".bgsplay"):
continue
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname)
try:
webdav_client.download_sync(
remote_path=f"{processed_dir}{fname}", local_path=tmp.name
)
imp = BGStatsImport.objects.create(
user_id=user_id,
original_filename=fname,
)
with open(tmp.name, "rb") as f:
imp.bgsplay_file.save(fname, f, save=True)
process_bgstats_import.delay(imp.id)
new_imports += 1
except Exception as e:
logger.error(f"Failed to import processed BG Stats file {fname}: {e}")
finally:
os.unlink(tmp.name)
return new_imports
def scan_webdav_for_ebird(webdav_client, user_id):
"""Download .csv files from WebDAV var/ebird/ and queue imports for new files."""
def scan_webdav_for_ebird(webdav_client, user_id, include_processed=False):
"""Download .csv files from WebDAV var/ebird/ and queue imports for new files.
After importing, files are moved to var/ebird/processed/ so they are
not re-imported on subsequent scans unless *include_processed* is True.
"""
from scrobbles.models import EBirdCSVImport
from scrobbles.tasks import process_ebird_csv_import
@ -516,6 +651,12 @@ def scan_webdav_for_ebird(webdav_client, user_id):
)
return 0
processed_dir = f"{ebird_path}processed/"
try:
webdav_client.mkdir(processed_dir, recursive=True)
except Exception:
pass
new_imports = 0
already_imported = set(
EBirdCSVImport.objects.filter(user_id=user_id).values_list(
@ -527,6 +668,8 @@ def scan_webdav_for_ebird(webdav_client, user_id):
fname = os.path.basename(fname)
if not fname.lower().endswith(".csv"):
continue
if fname == "processed":
continue
if fname in already_imported:
logger.debug(f"Skipping already-imported {fname}")
continue
@ -534,7 +677,7 @@ def scan_webdav_for_ebird(webdav_client, user_id):
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname)
try:
webdav_client.download_sync(
remote_path=f"{ebird_path}/{fname}", local_path=tmp.name
remote_path=f"{ebird_path}{fname}", local_path=tmp.name
)
imp = EBirdCSVImport.objects.create(
user_id=user_id,
@ -542,6 +685,14 @@ def scan_webdav_for_ebird(webdav_client, user_id):
)
with open(tmp.name, "rb") as f:
imp.csv_file.save(fname, f, save=True)
stem, ext = os.path.splitext(fname)
ts = datetime.now().strftime("%Y%m%dT%H%M%S")
webdav_client.move(
f"{ebird_path}{fname}",
f"{processed_dir}{stem}_{ts}{ext}",
)
process_ebird_csv_import.delay(imp.id)
new_imports += 1
except Exception as e:
@ -549,6 +700,39 @@ def scan_webdav_for_ebird(webdav_client, user_id):
finally:
os.unlink(tmp.name)
if include_processed:
try:
processed_files = webdav_client.list(processed_dir)
except Exception as e:
logger.warning(
"Could not list var/ebird/processed/",
extra={"user_id": user_id, "error": str(e)},
)
return new_imports
for fname in processed_files:
fname = os.path.basename(fname)
if not fname.lower().endswith(".csv"):
continue
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname)
try:
webdav_client.download_sync(
remote_path=f"{processed_dir}{fname}", local_path=tmp.name
)
imp = EBirdCSVImport.objects.create(
user_id=user_id,
original_filename=fname,
)
with open(tmp.name, "rb") as f:
imp.csv_file.save(fname, f, save=True)
process_ebird_csv_import.delay(imp.id)
new_imports += 1
except Exception as e:
logger.error(f"Failed to import processed eBird CSV file {fname}: {e}")
finally:
os.unlink(tmp.name)
return new_imports

View File

@ -0,0 +1,68 @@
from django.core.management.base import BaseCommand
from django.db import models
from scrobbles.models import Scrobble
from scrobbles.utils import analyze_scrobble_sentiment
class Command(BaseCommand):
help = "Backfill VADER sentiment analysis for scrobbles with notes"
def add_arguments(self, parser):
parser.add_argument(
"--commit",
action="store_true",
help="Actually update scrobble logs with sentiment data",
)
parser.add_argument(
"--overwrite",
action="store_true",
help="Re-analyze scrobbles that already have sentiment data",
)
def handle(self, *args, **options):
commit = options["commit"]
overwrite = options["overwrite"]
qs = Scrobble.objects.filter(
~models.Q(log__notes__isnull=True)
& ~models.Q(log__notes=[])
& ~models.Q(log__notes={})
)
if not overwrite:
qs = qs.filter(log__sentiment__isnull=True)
total = qs.count()
analyzed_count = 0
skipped_count = 0
self.stdout.write(f"Found {total} scrobbles to process")
for scrobble in qs.iterator():
if commit:
analyzed = analyze_scrobble_sentiment(scrobble, overwrite=overwrite)
else:
notes_str = ""
if scrobble.logdata:
notes_str = scrobble.logdata.notes_as_str()
analyzed = bool(notes_str)
if analyzed:
analyzed_count += 1
if commit:
scores = (scrobble.log or {}).get("sentiment", {})
self.stdout.write(
f" Updated scrobble {scrobble.id}: compound={scores.get('compound', 'N/A')}"
)
else:
self.stdout.write(
f" [DRY RUN] Would analyze scrobble {scrobble.id}"
)
else:
skipped_count += 1
self.stdout.write(
f"\nAnalyzed {analyzed_count} scrobbles, skipped {skipped_count}"
)
if not commit:
self.stdout.write("Run with --commit to persist changes")

View File

@ -1,9 +1,7 @@
from django.core.management.base import BaseCommand
from vrobbler.apps.tasks.utils import (
convert_notes_to_dict,
convert_old_boardgame_log_to_new,
convert_old_orgmode_log_to_new,
convert_old_todoist_log_to_new,
convert_tasks_notes_list_to_dict,
)
@ -21,7 +19,5 @@ class Command(BaseCommand):
commit = True
else:
print("No changes will be saved, use --commit to save")
convert_old_orgmode_log_to_new(commit)
convert_old_todoist_log_to_new(commit)
convert_notes_to_dict(commit)
convert_tasks_notes_list_to_dict(commit)
convert_old_boardgame_log_to_new(commit)

View File

@ -0,0 +1,174 @@
from django.core.management.base import BaseCommand
from django.db import connection
from scrobbles.constants import LONG_PLAY_MEDIA
from scrobbles.models import Scrobble
BATCH_SIZE = 1000
class Command(BaseCommand):
help = (
"Backfill long_play_last_scrobble FK chains, then recompute "
"long_play_seconds by walking forward through scrobbles in "
"timestamp order with a running accumulator."
)
def add_arguments(self, parser):
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be changed without making changes",
)
parser.add_argument(
"--media-type",
type=str,
help="Only process a specific media type (e.g., Book, VideoGame)",
)
def handle(self, *args, **options):
dry_run = options["dry_run"]
media_type = options.get("media_type")
media_types = list(LONG_PLAY_MEDIA.values())
if media_type:
if media_type not in media_types:
self.stdout.write(
self.style.ERROR(
f"Invalid media type '{media_type}'. "
f"Valid: {', '.join(media_types)}"
)
)
return
media_types = [media_type]
# Step 1: backfill long_play_last_scrobble
self.stdout.write("Step 1: Backfilling long_play_last_scrobble chains...")
total_backfilled = 0
for mt in media_types:
n = self._backfill_chain(mt, dry_run)
total_backfilled += n
self.stdout.write(f" {mt}: {n} scrobbles linked")
if dry_run:
self.stdout.write(
self.style.WARNING(
f"Would backfill {total_backfilled} scrobbles total. "
"Run without --dry-run to apply."
)
)
else:
self.stdout.write(
self.style.SUCCESS(f"Backfilled {total_backfilled} scrobbles")
)
# Step 2: recompute long_play_seconds
self.stdout.write(
"\nStep 2: Recomputing long_play_seconds in timestamp order..."
)
total_updated = 0
for mt in media_types:
n = self._recompute_for_media_type(mt, dry_run)
total_updated += n
self.stdout.write(f" {mt}: {n} scrobbles updated")
if dry_run:
self.stdout.write(
self.style.WARNING(
f"Dry run: would update {total_updated} scrobbles total. "
"Use without --dry-run to apply."
)
)
else:
self.stdout.write(
self.style.SUCCESS(f"Updated {total_updated} scrobbles")
)
def _recompute_for_media_type(self, media_type: str, dry_run: bool) -> int:
"""Process scrobbles for a single media type in timestamp order with a
running accumulator, avoiding O(n2) FK chain walks."""
fk = _media_type_to_fk(media_type)
fk_id = f"{fk}_id"
scrobbles = Scrobble.objects.filter(
media_type=media_type,
**{f"{fk}__isnull": False},
playback_position_seconds__isnull=False,
).order_by(fk_id, "user_id", "timestamp")
total = scrobbles.count()
if not total:
return 0
updated = 0
batch = []
last_key = None
running_total = 0
for scrobble in scrobbles.iterator():
key = (getattr(scrobble, fk_id), scrobble.user_id)
if key != last_key:
running_total = 0
last_key = key
running_total += scrobble.playback_position_seconds or 0
if scrobble.long_play_seconds != running_total:
updated += 1
if not dry_run:
scrobble.long_play_seconds = running_total
batch.append(scrobble)
if len(batch) >= BATCH_SIZE:
Scrobble.objects.bulk_update(batch, ["long_play_seconds"])
batch = []
if scrobble.long_play_complete:
running_total = 0
if batch:
Scrobble.objects.bulk_update(batch, ["long_play_seconds"])
return updated
def _backfill_chain(self, media_type: str, dry_run: bool) -> int:
"""Set long_play_last_scrobble on each scrobble to the previous
scrobble for the same media+user using a single UPDATE with a
correlated subquery."""
fk = _media_type_to_fk(media_type)
table = Scrobble._meta.db_table
if dry_run:
with connection.cursor() as cursor:
cursor.execute(
f"SELECT COUNT(*) FROM {table} "
f"WHERE long_play_last_scrobble_id IS NULL "
f"AND {fk}_id IS NOT NULL"
)
return cursor.fetchone()[0]
with connection.cursor() as cursor:
cursor.execute(
f"UPDATE {table} "
f"SET long_play_last_scrobble_id = ("
f" SELECT id FROM {table} AS prev "
f" WHERE prev.{fk}_id = {table}.{fk}_id "
f" AND prev.user_id = {table}.user_id "
f" AND prev.timestamp < {table}.timestamp "
f" ORDER BY prev.timestamp DESC LIMIT 1"
f") "
f"WHERE long_play_last_scrobble_id IS NULL "
f"AND {fk}_id IS NOT NULL"
)
return cursor.rowcount
def _media_type_to_fk(media_type):
mapping = {
"VideoGame": "video_game",
"Book": "book",
"BrickSet": "brick_set",
"Task": "task",
}
return mapping.get(media_type)

View File

@ -0,0 +1,287 @@
# Generated by Django 4.2.30 on 2026-06-05 14:55
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
import uuid
class Migration(migrations.Migration):
dependencies = [
("boardgames", "0015_alter_boardgame_genre"),
("trails", "0009_trail_route_waypoint"),
("moods", "0008_alter_mood_genre"),
("birds", "0002_birdingcsvimport"),
("bricksets", "0005_alter_brickset_genre"),
("music", "0036_artist_similar_artists"),
("puzzles", "0006_alter_puzzle_genre"),
("videogames", "0015_alter_videogame_genre"),
("lifeevents", "0005_alter_lifeevent_genre"),
("beers", "0008_alter_beer_genre"),
("foods", "0007_alter_food_genre"),
("tasks", "0007_alter_task_genre"),
("books", "0036_alter_book_genre_alter_paper_genre"),
("videos", "0030_alter_channel_genre_alter_series_genre_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("podcasts", "0020_alter_podcast_genre_alter_podcastepisode_genre"),
("webpages", "0009_alter_webpage_genre"),
("sports", "0018_alter_sportevent_genre"),
("locations", "0010_clean_start"),
("scrobbles", "0088_scalecsvimport_file_hash"),
]
operations = [
migrations.CreateModel(
name="FavoriteMedia",
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
),
),
(
"media_type",
models.CharField(
choices=[
("Video", "Video"),
("Track", "Track"),
("PodcastEpisode", "Podcast episode"),
("SportEvent", "Sport event"),
("Book", "Book"),
("Paper", "Paper"),
("VideoGame", "Video game"),
("BoardGame", "Board game"),
("GeoLocation", "GeoLocation"),
("Trail", "Trail"),
("Beer", "Beer"),
("Puzzle", "Puzzle"),
("Food", "Food"),
("Task", "Task"),
("WebPage", "Web Page"),
("LifeEvent", "Life event"),
("Mood", "Mood"),
("BrickSet", "Brick set"),
("Channel", "Channel"),
("BirdingLocation", "Birding location"),
],
max_length=20,
),
),
("sent_to_mopidy", models.BooleanField(default=False)),
(
"beer",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="beers.beer",
),
),
(
"birding_location",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="birds.birdinglocation",
),
),
(
"board_game",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="boardgames.boardgame",
),
),
(
"book",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="books.book",
),
),
(
"brick_set",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="bricksets.brickset",
),
),
(
"channel",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="videos.channel",
),
),
(
"food",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="foods.food",
),
),
(
"geo_location",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="locations.geolocation",
),
),
(
"life_event",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="lifeevents.lifeevent",
),
),
(
"mood",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="moods.mood",
),
),
(
"paper",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="books.paper",
),
),
(
"podcast_episode",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="podcasts.podcastepisode",
),
),
(
"puzzle",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="puzzles.puzzle",
),
),
(
"sport_event",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="sports.sportevent",
),
),
(
"task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="tasks.task",
),
),
(
"track",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="music.track",
),
),
(
"trail",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="trails.trail",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
(
"video",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="videos.video",
),
),
(
"video_game",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="videogames.videogame",
),
),
(
"web_page",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="webpages.webpage",
),
),
],
options={
"ordering": ["-created"],
},
),
]

View File

@ -0,0 +1,53 @@
# Generated by Django 4.2.29 on 2026-06-08 14:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0089_favoritemedia"),
]
operations = [
migrations.AddField(
model_name="audioscrobblertsvimport",
name="error_log",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="bgstatsimport",
name="error_log",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="ebirdcsvimport",
name="error_log",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="koreaderimport",
name="error_log",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="lastfmimport",
name="error_log",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="retroarchimport",
name="error_log",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="scalecsvimport",
name="error_log",
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name="trailgpximport",
name="error_log",
field=models.TextField(blank=True, null=True),
),
]

View File

@ -0,0 +1,32 @@
# Generated by Django 4.2.29 on 2026-06-09 15:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0090_audioscrobblertsvimport_error_log_and_more"),
]
operations = [
migrations.AddField(
model_name="scrobble",
name="share_token",
field=models.UUIDField(blank=True, editable=False, null=True, unique=True),
),
migrations.AddField(
model_name="scrobble",
name="visibility",
field=models.CharField(
choices=[
("public", "Public"),
("shared", "Shared"),
("private", "Private"),
],
db_index=True,
default="shared",
max_length=10,
),
),
]

View File

@ -0,0 +1,29 @@
from uuid import uuid4
from django.db import migrations
def backfill_share_token(apps, schema_editor):
Scrobble = apps.get_model("scrobbles", "Scrobble")
batch = []
for scrobble in Scrobble.objects.filter(share_token__isnull=True).iterator(
chunk_size=500
):
scrobble.share_token = uuid4()
batch.append(scrobble)
if batch:
Scrobble.objects.bulk_update(batch, ["share_token"], batch_size=500)
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0091_scrobble_share_token_scrobble_visibility"),
]
operations = [
migrations.RunPython(
backfill_share_token,
reverse_code=migrations.RunPython.noop,
),
]

View File

@ -0,0 +1,22 @@
# Generated by Django 4.2.29 on 2026-06-09 16:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0092_backfill_visibility_and_share_token"),
]
operations = [
migrations.RemoveField(
model_name="scrobble",
name="share_token",
),
migrations.AddField(
model_name="scrobble",
name="share_token_version",
field=models.PositiveIntegerField(default=0),
),
]

View File

@ -0,0 +1,75 @@
# Generated by Django 4.2.29 on 2026-06-09 16:24
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0093_remove_scrobble_share_token_and_more"),
]
operations = [
migrations.AddField(
model_name="scrobble",
name="share_view_count",
field=models.PositiveIntegerField(default=0),
),
migrations.AlterField(
model_name="scrobble",
name="visibility",
field=models.CharField(
choices=[
("public", "Public"),
("shared", "Shared"),
("private", "Private"),
],
db_index=True,
default="private",
max_length=10,
),
),
migrations.CreateModel(
name="ShareViewLog",
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"
),
),
("ip_address", models.GenericIPAddressField(blank=True, null=True)),
("user_agent", models.TextField(blank=True, null=True)),
("referrer", models.URLField(blank=True, max_length=2048, null=True)),
(
"scrobble",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="share_views",
to="scrobbles.scrobble",
),
),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
]

View File

@ -0,0 +1,20 @@
from django.db import migrations
def backfill_null_visibility(apps, schema_editor):
Scrobble = apps.get_model("scrobbles", "Scrobble")
Scrobble.objects.filter(visibility__isnull=True).update(visibility="private")
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0094_scrobble_share_view_count_alter_scrobble_visibility_and_more"),
]
operations = [
migrations.RunPython(
backfill_null_visibility,
reverse_code=migrations.RunPython.noop,
),
]

View File

@ -0,0 +1,35 @@
from django.db import migrations
def convert_page_data_to_dict(apps, schema_editor):
Scrobble = apps.get_model("scrobbles", "Scrobble")
for scrobble in Scrobble.objects.filter(media_type="Book").exclude(
log__page_data=None
):
page_data = scrobble.log.get("page_data")
if isinstance(page_data, list):
new_page_data = {}
for entry in page_data:
page_num = entry.get("page_number")
if page_num is not None:
try:
page_num = int(page_num)
except (ValueError, TypeError):
continue
new_page_data[page_num] = entry
scrobble.log["page_data"] = new_page_data
scrobble.save(update_fields=["log"])
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0095_backfill_null_visibility"),
]
operations = [
migrations.RunPython(
convert_page_data_to_dict,
reverse_code=migrations.RunPython.noop,
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 4.2.29 on 2026-06-12 16:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0096_convert_book_page_data_to_dict"),
]
operations = [
migrations.AddIndex(
model_name="scrobble",
index=models.Index(
fields=["user", "-timestamp"], name="scrobbles_s_user_id_d367a7_idx"
),
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 4.2.29 on 2026-06-15 17:48
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0097_scrobble_scrobbles_s_user_id_d367a7_idx"),
]
operations = [
migrations.AddField(
model_name="scrobble",
name="long_play_last_scrobble",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="next_long_play_scrobbles",
to="scrobbles.scrobble",
),
),
]

View File

@ -65,6 +65,9 @@ class ScrobblableMixin(TimeStampedModel):
class Meta:
abstract = True
def __str__(self) -> str:
return f"{self.title} - {self.subtitle}"
@property
def run_time_seconds(self) -> int:
run_time = 900
@ -111,7 +114,7 @@ class ScrobblableMixin(TimeStampedModel):
@property
def start_url(self):
return reverse("scrobbles:start", kwargs={"uuid": self.uuid})
return reverse("scrobbles:start", kwargs={"media_uuid": self.uuid})
@property
def strings(self) -> ScrobblableConstants:
@ -159,26 +162,21 @@ class LongPlayScrobblableMixin(ScrobblableMixin):
return False
def get_longplay_finish_url(self):
return reverse("scrobbles:longplay-finish", kwargs={"uuid": self.uuid})
return reverse("scrobbles:longplay-finish", kwargs={"media_uuid": self.uuid})
def first_long_play_scrobble_for_user(self, user) -> Optional["Scrobble"]:
return (
get_scrobbles_for_media(self, user)
.filter(
log__long_play_complete=False,
log__serial_scrobble_id__isnull=True,
)
.order_by("timestamp")
.first()
)
last = self.last_long_play_scrobble_for_user(user)
if not last:
return None
current = last
while current.long_play_last_scrobble and not current.long_play_last_scrobble.long_play_complete:
current = current.long_play_last_scrobble
return current
def last_long_play_scrobble_for_user(self, user) -> Optional["Scrobble"]:
return (
get_scrobbles_for_media(self, user)
.filter(
log__long_play_complete=False,
log__serial_scrobble_id__isnull=False,
)
.order_by("timestamp")
.last()
.filter(long_play_complete=False)
.order_by("-timestamp")
.first()
)

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