Compare commits

...

44 Commits
55.2 ... 58.5

Author SHA1 Message Date
ec4c190e6c [release] Bump to version 58.5
All checks were successful
build / test (push) Successful in 2m15s
deploy / test (push) Successful in 2m14s
deploy / build-and-deploy (push) Successful in 56s
- The maloja style charts are messed up
2026-06-30 15:02:16 -04:00
58126928c7 [charts] Fix maloja charts acting weird
Some checks failed
build / test (push) Has been cancelled
2026-06-30 15:01:57 -04:00
c0d2881585 [release] Bump to version 58.4
All checks were successful
build / test (push) Successful in 2m13s
deploy / test (push) Successful in 2m10s
deploy / build-and-deploy (push) Successful in 53s
- Allow people all trends or individual trends
- Fix a bug in board game scorelog data
2026-06-25 20:02:34 -04:00
41a68291a4 [trends] Allow disabling one or many or all trends
All checks were successful
build / test (push) Successful in 2m22s
2026-06-25 18:58:23 -04:00
0a411bedf4 [boardgames] Fix bug in logdata
All checks were successful
build / test (push) Successful in 2m23s
2026-06-24 19:09:43 -04:00
f2b67b38dc [release] Bump to version 58.3
All checks were successful
build / test (push) Successful in 2m2s
deploy / test (push) Successful in 2m5s
deploy / build-and-deploy (push) Successful in 34s
- Remove curl-cffi as it doesn't work on FreeBSD
2026-06-23 23:19:10 -04:00
662ebe66b9 [webpages] Remove curl_cffi as it doesn't work on FreeBSD 2026-06-23 23:17:08 -04:00
5e0dffdc7a [release] Bump to version 58.2
Some checks failed
build / test (push) Successful in 2m5s
deploy / test (push) Successful in 2m3s
deploy / build-and-deploy (push) Failing after 25s
- Add more robust webpage scraping
- Time of Day Categories trend
2026-06-23 23:04:48 -04:00
2283a6c640 [webpages] Add more robust scraping 2026-06-23 23:04:30 -04:00
327ba94c63 [trends] Add new time of day trend
All checks were successful
build / test (push) Successful in 2m4s
2026-06-23 22:21:18 -04:00
ee59cde882 [release] Bump to version 58.1
All checks were successful
build / test (push) Successful in 2m7s
deploy / test (push) Successful in 2m8s
deploy / build-and-deploy (push) Successful in 1m5s
- Add auto genre tagging for papers
2026-06-23 16:19:06 -04:00
c7b4656679 [papers] Add genre tagging 2026-06-23 16:18:50 -04:00
04f9e00c9c [release] Bump to version 58.0
All checks were successful
deploy / test (push) Successful in 2m7s
build / test (push) Successful in 2m3s
deploy / build-and-deploy (push) Successful in 1m2s
- Add scrobbling of Papers via webpages with doi.org links in them
2026-06-23 14:28:40 -04:00
c2dabd1dac [papers] Fix scrobbling of academic papers 2026-06-23 14:28:16 -04:00
7a0cb8b9d0 [release] Bump to version 57.1
Some checks failed
build / test (push) Successful in 2m3s
deploy / test (push) Successful in 2m5s
deploy / build-and-deploy (push) Failing after 2m18s
- Write poetry lock file
2026-06-21 23:06:14 -04:00
1c2c570c4b [deps] Lock poetry 2026-06-21 23:05:58 -04:00
0671ab432f [release] Bump to version 57.0
Some checks failed
build / test (push) Failing after 35s
deploy / test (push) Failing after 37s
deploy / build-and-deploy (push) Has been skipped
- Scrobble button on some media list pages dont work
- Use HTMx to update the Now Playing widget
- Add a live page that updates the scrobble list via JS polling
- Turns out we cant cache the now playing widget
- What would it look like to add an MCP server to expose scrobbles and media items?
2026-06-21 23:04:23 -04:00
893867419a [templates] Shorten up naturalduration rep 2026-06-21 23:04:01 -04:00
d9dfec81aa [scrobbles] Fix bug where media list scrobble btns didnt work 2026-06-21 23:00:28 -04:00
948fbc19bf [templates] Add HTMX support to Now Playing 2026-06-21 23:00:09 -04:00
7d708ad8a6 [templates] Add polling live page for all scrobbles
Some checks failed
build / test (push) Failing after 35s
2026-06-21 22:39:46 -04:00
e0505cb82c [templates] Fix caching issue with Now Playing 2026-06-21 22:36:58 -04:00
ab6459e4b0 [mcp] Add a basic mcp service
Some checks failed
build / test (push) Failing after 31s
2026-06-21 01:26:16 -04:00
c001248d1b [release] Bump to version 56.4
All checks were successful
build / test (push) Successful in 2m0s
deploy / test (push) Successful in 1m59s
deploy / build-and-deploy (push) Successful in 49s
- Add ability to do reverse address lookup on lat-long pairs
- Add address fields to GeoLocation
- Add better detail template for Disc Golf Courses
2026-06-21 01:04:57 -04:00
f1c777d4ef [discgolf] Add trail maps and addresses
Some checks failed
build / test (push) Has been cancelled
2026-06-21 01:02:43 -04:00
931488c288 [release] Bump to version 56.3
Some checks failed
build / test (push) Has been cancelled
deploy / test (push) Successful in 1m58s
deploy / build-and-deploy (push) Successful in 32s
- Fix bug in importer script from discgolf being added
2026-06-20 01:12:33 -04:00
ab897fd848 [importers] Just fix a smol bug 2026-06-20 01:12:14 -04:00
4f189b4d66 [release] Bump to version 56.2
Some checks failed
build / test (push) Has been cancelled
deploy / build-and-deploy (push) Has been cancelled
deploy / test (push) Has been cancelled
- Fix bug in creating people when importing course plays
2026-06-20 01:10:53 -04:00
1487504318 [discgolf] Fix bug in creating people 2026-06-20 01:10:34 -04:00
0655363a0d [release] Bump to version 56.1
All checks were successful
build / test (push) Successful in 2m4s
deploy / test (push) Successful in 2m0s
deploy / build-and-deploy (push) Successful in 49s
- Add tests to discgolf app
2026-06-20 00:55:35 -04:00
dccc80c615 [discgolf] Fix tests and naming scheme
Some checks failed
build / test (push) Has been cancelled
2026-06-20 00:55:16 -04:00
4f91d5b40b [tests] Fix broken birding tests 2026-06-20 00:42:46 -04:00
cb01781615 [release] Bump to version 56.0
Some checks failed
build / test (push) Failing after 1m33s
deploy / test (push) Failing after 1m35s
deploy / build-and-deploy (push) Has been skipped
- Add DiscGolf as a scrobbleable media
2026-06-20 00:37:42 -04:00
1f5fada8b1 [discgolf] Add new scrobble type
Some checks failed
build / test (push) Has been cancelled
2026-06-20 00:37:18 -04:00
31888a85cb [release] Bump to version 55.6
All checks were successful
build / test (push) Successful in 1m56s
deploy / test (push) Successful in 1m54s
deploy / build-and-deploy (push) Successful in 30s
- Figure out why historical Lastfm imports don't work
2026-06-19 14:12:44 -04:00
22d8b0787e [importers] Allow starting full import for new user
Some checks failed
build / test (push) Has been cancelled
2026-06-19 14:12:09 -04:00
8cc559752b [release] Bump to version 55.5
All checks were successful
build / test (push) Successful in 2m5s
deploy / test (push) Successful in 3m12s
deploy / build-and-deploy (push) Successful in 31s
- Fix bug in lastfm import for new users
2026-06-19 14:06:10 -04:00
db3f9696fa [importers] Fix smol bug in lastfm importer
Some checks failed
build / test (push) Has been cancelled
2026-06-19 14:05:46 -04:00
407d570c82 [release] Bump to version 55.4
All checks were successful
build / test (push) Successful in 2m12s
deploy / test (push) Successful in 2m4s
deploy / build-and-deploy (push) Successful in 1m22s
- Tighten up the speed of startup and first request
2026-06-19 13:41:23 -04:00
033239260f [perf] Add caching and lock protections
All checks were successful
build / test (push) Successful in 1m57s
2026-06-19 13:37:30 -04:00
9f854dc735 [release] Bump to version 55.3
All checks were successful
build / test (push) Successful in 2m6s
deploy / test (push) Successful in 2m3s
deploy / build-and-deploy (push) Successful in 39s
- =alt_names= feature for artists (commented out / dead code)
- Put chart rebuilds in a lower priority task queue
- Check for existing book scrobble and update page count
2026-06-19 01:14:39 -04:00
f29272a853 [music] Clean up dead code
Some checks failed
build / test (push) Has been cancelled
2026-06-19 01:13:28 -04:00
4e56d9420a [settings] Put chart rebuilds in their own queue 2026-06-19 01:12:50 -04:00
852a257159 [scrobbles] Clean up a TODO already done
All checks were successful
build / test (push) Successful in 1m59s
2026-06-18 16:59:03 -04:00
95 changed files with 5096 additions and 612 deletions

View File

@ -88,7 +88,8 @@ fetching and simple saving.
*** Metadata sources
**** Scraper
* Backlog [0/21] :vrobbler:project:personal:
* Backlog [0/23] :vrobbler:project:personal:
** TODO [#C] After transition to linux add curl_cffi as webpage scrapper again :webpages:metadata:
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
:PROPERTIES:
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
@ -579,6 +580,18 @@ named constants for maintainability.
- ~vrobbler/apps/scrobbles/importers/tsv.py~ (line 55) -- ="S"= completion status
** TODO [#A] Deduplicate BGG plays before posting :boardgames:bgg:duplication:
:PROPERTIES:
:ID: e9b842bf-0049-42e7-a060-f3ebd0067d2f
:END:
*** Description
No check for existing BGG plays before posting, which can create duplicates.
Should look up past plays by =bggeek_id= first.
File: ~vrobbler/apps/boardgames/bgg.py~ (line 117)
** TODO [#C] Clean up naming of =bgsplay= parsing :importers:refactoring:
:PROPERTIES:
:ID: c751dbbc-464a-4e63-9fe3-e034303f7b54
@ -591,6 +604,210 @@ a helper method to create board game scrobbles given a json blob. It's
independent of the email flow it was originally creatdd for
** TODO [#B] Is there way to create unique slugs for media instances :media_types:
* Version 58.5 [1/1]
** DONE [#A] The maloja style charts are messed up :templates:charts:
:PROPERTIES:
:ID: 987397a2-7e74-4eb1-87cc-4c8bbe1c7b23
:END:
* Version 58.4 [2/2]
** DONE [#B] Allow people all trends or individual trends :trends:profiles:
:PROPERTIES:
:ID: 1d081152-abd1-73c2-a625-903565a10c6c
:END:
** DONE [#A] Fix a bug in board game scorelog data :boardgames:logdata:
:PROPERTIES:
:ID: 014bab30-13bf-fae7-e678-4666a8d38ae4
:END:
* Version 58.3 [1/1]
** DONE [#A] Remove curl-cffi as it doesn't work on FreeBSD :webpages:deps:
:PROPERTIES:
:ID: 6bc1b0dd-e449-3d32-a176-46451e793e5d
:END:
* Version 58.2 [2/2]
** DONE [#B] Add more robust webpage scraping :webpages:metadata:
:PROPERTIES:
:ID: 84d9bfa5-75c0-0718-764e-379f7456602a
:END:
** DONE [#B] Time of Day Categories trend :trends:
:PROPERTIES:
:ID: 6598074f-2290-46db-967b-29f45d30be29
:END:
*** Description
Added a "Time of Day Categories" trend that groups scrobbles for Books, Trails,
Birding Locations, and Board Games into Early Bird (5-10:59am), Day Jay (11am-6:59pm),
and Night Owl (7pm-4:59am) buckets. Shows both overall and per-media-type breakdowns.
* Version 58.1 [1/1]
** DONE [#B] Add auto genre tagging for papers :books:papers:metadata:
:PROPERTIES:
:ID: e6b5c3a5-7fc6-b530-96c2-b5962a716db6
:END:
* Version 58.0 [1/1]
** DONE [#B] Add scrobbling of Papers via webpages with doi.org links in them :feature:papers:
:PROPERTIES:
:ID: d30bb8aa-eefd-002c-38d5-3f2fcef345f2
:END:
* Version 57.1 [1/1]
** DONE [#A] Write poetry lock file :bug:deps:
:PROPERTIES:
:ID: 0f5a6f4b-a486-ba7e-bbce-f7581274398c
:END:
* Version 57.0 [5/5]
** DONE [#A] Scrobble button on some media list pages dont work :bug:scrobbles:
:PROPERTIES:
:ID: a3a5c707-2e3d-a6b1-0f7f-4c6f7433aa1f
:END:
** DONE [#B] Use HTMx to update the Now Playing widget :feature:templates:
:PROPERTIES:
:ID: 5f5631fc-9ee1-d5a5-d0f8-94fea6fbbfa4
:END:
** DONE [#B] Add a live page that updates the scrobble list via JS polling :feature:templates:
:PROPERTIES:
:ID: 58790d76-dc6e-8aa5-2dc0-e64fe786fbf1
:END:
** DONE [#A] Turns out we cant cache the now playing widget :bug:templates:
:PROPERTIES:
:ID: 9ce669ea-c000-cdfe-a634-ad5cdaeae81c
:END:
** DONE [#C] What would it look like to add an MCP server to expose scrobbles and media items? :mcpserver:feature:
:PROPERTIES:
:ID: c5fca159-c7e0-5795-7c05-bbc48f539650
:END:
* Version 56.4 [3/3]
** DONE [#B] Add ability to do reverse address lookup on lat-long pairs :geolocations:feature:
:PROPERTIES:
:ID: 86c071ff-7638-41ba-6b65-1382df1cb5aa
:END:
** DONE [#B] Add address fields to GeoLocation :addresses:geolocation:
:PROPERTIES:
:ID: a55ae508-07ab-ccdd-e453-846bd3fca6fb
:END:
** DONE [#B] Add better detail template for Disc Golf Courses :discgolf:templates:
:PROPERTIES:
:ID: 12cee67c-f723-0fa3-848a-cbc6e4d65fc3
:END:
* Version 56.3 [1/1]
** DONE [#A] Fix bug in importer script from discgolf being added :bug:
:PROPERTIES:
:ID: c3733f96-18f1-eef8-f5d9-edaf97e35623
:END:
* Version 56.2 [1/1]
** DONE [#A] Fix bug in creating people when importing course plays :discgolf:bug:
:PROPERTIES:
:ID: 255e9886-098b-39ae-1077-25e43223660e
:END:
* Version 56.1 [1/1]
** DONE [#A] Add tests to discgolf app :discgolf:tests:
:PROPERTIES:
:ID: 28e8344e-c3cf-19af-ce1c-cb821d4fcb5f
:END:
* Version 56.0 [1/1]
** DONE [#B] Add DiscGolf as a scrobbleable media :discgolf:
:PROPERTIES:
:ID: 8cdde5d3-0ae5-7d5a-99d2-200c86afae03
:END:
*** Description
I have a csv file fro the uDisc disc golf scoring app that looks like:
Singles round. Note second row is the par for the course
#+begin_src csv
PlayerName,CourseName,LayoutName,StartDate,EndDate,Total,+/-,RoundRating,Hole1,Hole2,Hole3,Hole4,Hole5,Hole6,Hole7,Hole8,Hole9
Par,DR Front 9,Custom Layout,2026-06-19 1535-0400,2026-06-19 1725-0400,27,,,3,3,3,3,3,3,3,3,3
Colin Powell,DR Front 9,Custom Layout,2026-06-19 1535-0400,2026-06-19 1725-0400,30,3,,2,3,4,4,3,4,3,3,4
Asa Sewell,DR Front 9,Custom Layout,2026-06-19 1535-0400,2026-06-19 1725-0400,44,17,,5,4,4,8,5,5,4,4,5
Emma Sweet,DR Front 9,Custom Layout,2026-06-19 1535-0400,2026-06-19 1725-0400,41,14,,5,4,5,6,3,4,3,5,6
Jane Sewell,DR Front 9,Custom Layout,2026-06-19 1535-0400,2026-06-19 1725-0400,44,17,,4,5,5,5,5,5,4,6,5
Nabby Sewell,DR Front 9,Custom Layout,2026-06-19 1535-0400,2026-06-19 1725-0400,59,32,,6,6,7,7,6,7,6,6,8
Silas Sewell,DR Front 9,Custom Layout,2026-06-19 1535-0400,2026-06-19 1725-0400,41,14,,5,5,4,5,3,5,4,4,6`
#+end_src
Teams of two or more persons. Note second row is the par for the course
#+begin_src csv
PlayerName,CourseName,LayoutName,StartDate,EndDate,Total,+/-,RoundRating,Hole1,Hole2,Hole3,Hole4,Hole5,Hole6,Hole7,Hole8,Hole9
Par,Peninsula Links,Main,2026-06-19 2322-0400,2026-06-19 2323-0400,27,,,3,3,3,3,3,3,3,3,3
Colin Powell + Asa Sewell,Peninsula Links,Main,2026-06-19 2322-0400,2026-06-19 2323-0400,29,2,,3,4,2,3,2,3,5,3,4
Emma Sweet + Jane Sewell,Peninsula Links,Main,2026-06-19 2322-0400,2026-06-19 2323-0400,28,1,,4,3,4,2,3,4,3,3,2
#+end_src
We should add a new app called discgolf that has the following data models:
- DiscGolfRound - scrobblable media + course_id, round_type (Singles, Teams)
- DiscGolfCourse - name, layoutname, number_of_holes
And the logdata for a DiscGolfOuting scrobble should have:
+ {person: {hole_number: score}, total: int}
+ {team: {name: "", people: [person, person], hole_number: score}, total: int}
+ weather
+ fun_factor (miserable, not great, so-so, good, excellent, party time)
* Version 55.6 [1/1]
** DONE [#A] Figure out why historical Lastfm imports don't work :importers:lastfm:music:
:PROPERTIES:
:ID: 71b18c1b-de96-6d93-20fa-de2ec0df1288
:END:
* Version 55.5 [1/1]
** DONE [#B] Fix bug in lastfm import for new users :importers:lastfm:music:
:PROPERTIES:
:ID: d034966d-0c7f-e512-4cf8-7329c9026b6f
:END:
* Version 55.4 [1/1]
** DONE [#A] Tighten up the speed of startup and first request :perf:
:PROPERTIES:
:ID: 9ee8834c-6be2-d04b-df6d-56375504083f
:END:
* Version 55.3 [3/3]
** DONE [#C] =alt_names= feature for artists (commented out / dead code) :music:dead-code:
:PROPERTIES:
:ID: e22060a2-5f7a-4f33-9056-309ecd27159c
:END:
*** Description
File: ~vrobbler/apps/music/models.py~ (line 236)
An entire block of code for tracking alternate artist names is commented
out. The TODO questions whether it even works. Review: either implement
properly or remove the dead code.
** DONE [#A] Put chart rebuilds in a lower priority task queue :charts:tasks:
:PROPERTIES:
:ID: 43c90de0-fc1c-1139-dac7-9b7c82006b2e
:END:
** DONE [#A] Check for existing book scrobble and update page count :books:scrobbling:
:PROPERTIES:
:ID: 1a0609bc-6b16-4da4-96c1-59588229e4b4
:END:
*** Description
File: ~vrobbler/apps/scrobbles/scrobblers.py~ (line 330)
When scrobbling a book (comic), the code doesn't check for prior scrobbles to
update reading progress. Needed for proper page-count tracking.
* Version 55.2 [2/2]
** DONE [#A] Fix bug in scrobble id in calendar view :templates:
:PROPERTIES:

View File

@ -1,2 +1,2 @@
web: python manage.py runserver 0.0.0.0:8014
worker: celery -A vrobbler worker -l DEBUG
worker: celery -A vrobbler worker -Q default,charts -l DEBUG

784
poetry.lock generated
View File

@ -967,6 +967,23 @@ prompt-toolkit = ">=3.0.36"
[package.extras]
testing = ["pytest (>=7.2.1)", "pytest-cov (>=4.0.0)", "tox (>=4.4.3)"]
[[package]]
name = "cloudscraper"
version = "1.2.71"
description = "A Python module to bypass Cloudflare's anti-bot page."
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "cloudscraper-1.2.71-py2.py3-none-any.whl", hash = "sha256:76f50ca529ed2279e220837befdec892626f9511708e200d48d5bb76ded679b0"},
{file = "cloudscraper-1.2.71.tar.gz", hash = "sha256:429c6e8aa6916d5bad5c8a5eac50f3ea53c9ac22616f6cb21b18dcc71517d0d3"},
]
[package.dependencies]
pyparsing = ">=2.4.7"
requests = ">=2.9.2"
requests-toolbelt = ">=0.9.1"
[[package]]
name = "colorama"
version = "0.4.6"
@ -1512,6 +1529,25 @@ files = [
{file = "django_mathfilters-1.0.0-py3-none-any.whl", hash = "sha256:64200a21bb249fbf27be601d4bbb788779e09c6e063170c097cd82c4d18ebb83"},
]
[[package]]
name = "django-mcp-server"
version = "0.5.7"
description = "Django MCP Server is a Django extensions to easily enable AI Agents to interact with Django Apps through the Model Context Protocol it works equally well on WSGI and ASGI"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "django_mcp_server-0.5.7-py3-none-any.whl", hash = "sha256:04b58bf02623aaee59708c3661ffe17981acd4532587c38b6cfe2c9e7090c6d3"},
{file = "django_mcp_server-0.5.7.tar.gz", hash = "sha256:5077f8fabf5fb621b5ce490afd0db60f21e57b3a451ed14a9f44aef545ea4eee"},
]
[package.dependencies]
django = ">=4.0"
djangorestframework = ">=3.15.0"
inflection = ">=0.5.1,<0.6.0"
mcp = ">=1.8.0"
uritemplate = ">=4.1.1,<5.0.0"
[[package]]
name = "django-oauth-toolkit"
version = "3.2.0"
@ -2156,6 +2192,18 @@ http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "httpx-sse"
version = "0.4.3"
description = "Consume Server-Sent Event (SSE) messages with HTTPX."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc"},
{file = "httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d"},
]
[[package]]
name = "idna"
version = "3.11"
@ -2196,6 +2244,18 @@ perf = ["ipython"]
test = ["packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"]
type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""]
[[package]]
name = "inflection"
version = "0.5.1"
description = "A port of Ruby on Rails inflector to Python"
optional = false
python-versions = ">=3.5"
groups = ["main"]
files = [
{file = "inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2"},
{file = "inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417"},
]
[[package]]
name = "iniconfig"
version = "2.3.0"
@ -2387,6 +2447,101 @@ files = [
test = ["async-timeout ; python_version < \"3.11\"", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"]
trio = ["trio"]
[[package]]
name = "jellyfish"
version = "1.2.1"
description = ""
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "jellyfish-1.2.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b35d4b5b688f759ffd075190a9850b04671bad14c5b37124eb43e99306ec16ea"},
{file = "jellyfish-1.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b37b76ea338c4a473c34a9b9e1e033a78aafb9040a8c0eea579fc5805d8e4b46"},
{file = "jellyfish-1.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:137cfcc26396d0f2e1265ac61f800bb921921ea722a43dd897e58190f767c474"},
{file = "jellyfish-1.2.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ab1bfea271ce4bda09d975080d5465cf5a8b127e7c0ea61ea3f972417a7a2193"},
{file = "jellyfish-1.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2348f698f9c1d72023afc8d39939045421a01da9b7e3078e3029227e35f28419"},
{file = "jellyfish-1.2.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4072e21ad4036af41bd57b447b1dda64fe60aa679cfa8854ba0a0338152439f1"},
{file = "jellyfish-1.2.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:cf6cd68921f2bacc547ba1cf64ad0e76bc1727f3bab13bba2e5f5869aba038b1"},
{file = "jellyfish-1.2.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:01647c12261bc1f7b102e918e7665497176d87f6fc96271439c8855872bc2606"},
{file = "jellyfish-1.2.1-cp310-cp310-win32.whl", hash = "sha256:ddf05ea471da2808d77ecfa425d8884124b4754f4d483afa7703b6655530cf5c"},
{file = "jellyfish-1.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:e4a210a960f3917da757b0581750b6e0a8db9acef68dafbc1b6e2ae39e847ba8"},
{file = "jellyfish-1.2.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9913789a98ccf49213fbb1dabc597847a0ec33d3b0e151689498f4b38ba9be0f"},
{file = "jellyfish-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e36d9000d4f7e1a35689a74ec7749d27a216dfa6c47cac2e5ad3de8a523bd69"},
{file = "jellyfish-1.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7853d2ed7d6929c029312ec849410f1ea7ae76ce72ad1140fb73f6e8a1e6aa4f"},
{file = "jellyfish-1.2.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:68080af234256ef943f0add6fc79816b0c643d8df291c17a85c1b6e45bdfbb96"},
{file = "jellyfish-1.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c5acb213aa75a61bcfc176566e20f2503069667e760d83d403b59e115fef0dd"},
{file = "jellyfish-1.2.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4b28fcefc0c3534277ff0306e6c10672fb050f4784b5f3be7037e80801569fb5"},
{file = "jellyfish-1.2.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:f69aeb08659a6c81d559bbe319075e3417434ae5b3a5e4a758d1c4055a03497a"},
{file = "jellyfish-1.2.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:63770120cc3386dcc13bcc4df508ab281a6b14c3b2c0e33586439a6c40ee122f"},
{file = "jellyfish-1.2.1-cp311-cp311-win32.whl", hash = "sha256:ecf62d4aad0baa8832ab60f96e7baedbe6558bd292597503d927e9c5bce745d8"},
{file = "jellyfish-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:bd186c041d9be86c4fa5e2490943ce5d7f05b472f45d7f49426f259f3dd20bc4"},
{file = "jellyfish-1.2.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:32a85b752cb51463face13e2b1797cfa617cd7fb7073f15feaa4020a86a346ce"},
{file = "jellyfish-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:675ab43840488944899ca87f02d4813c1e32107e56afaba7489705a70214e8aa"},
{file = "jellyfish-1.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c888f624d03e55e501bc438906505c79fb307d8da37a6dda18dd1ac2e6d5ea9c"},
{file = "jellyfish-1.2.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d2b56a1fd2c5126c4a3362ec4470291cdd3c7daa22f583da67e75e30dc425ce6"},
{file = "jellyfish-1.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a3ccff843822e7f3ad6f91662488a3630724c8587976bce114f3c7238e8ffa1"},
{file = "jellyfish-1.2.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10da696747e2de0336180fd5ba77ef769a7c80f9743123545f7fc0251efbbcec"},
{file = "jellyfish-1.2.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:c3c18f13175a9c90f3abd8805720b0eb3e10eca1d5d4e0cf57722b2a62d62016"},
{file = "jellyfish-1.2.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0368596e176bf548b3be2979ff33e274fb6d5e13b2cebe85137b8b698b002a85"},
{file = "jellyfish-1.2.1-cp312-cp312-win32.whl", hash = "sha256:451ddf4094e108e33d3b86d7817a7e20a2c5e6812d08c34ee22f6a595f38dcca"},
{file = "jellyfish-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:15318c13070fe6d9caeb7e10f9cdf89ff47c9d20f05a9a2c0d3b5cb8062a7033"},
{file = "jellyfish-1.2.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:4b3e3223aaad74e18aacc74775e01815e68af810258ceea6fa6a81b19f384312"},
{file = "jellyfish-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e967e67058b78189d2b20a9586c7720a05ec4a580d6a98c796cd5cd2b7b11303"},
{file = "jellyfish-1.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32581c50b34a09889b2d96796170e53da313a1e7fde32be63c82e50e7e791e3c"},
{file = "jellyfish-1.2.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07b022412ebece96759006cb015d46b8218d7f896d8b327c6bbee784ddf38ed9"},
{file = "jellyfish-1.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80a49eb817eaa6591f43a31e5c93d79904de62537f029907ef88c050d781a638"},
{file = "jellyfish-1.2.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:e1b990fb15985571616f7f40a12d6fa062897b19fb5359b6dec3cd811d802c24"},
{file = "jellyfish-1.2.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:dd895cf63fac0a9f11b524fff810d9a6081dcf3c518b34172ac8684eb504dd43"},
{file = "jellyfish-1.2.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:6d2bac5982d7a08759ea487bfa00149e6aa8a3be7cd43c4ed1be1e3505425c69"},
{file = "jellyfish-1.2.1-cp313-cp313-win32.whl", hash = "sha256:509355ebedec69a8bf0cc113a6bf9c01820d12fe2eea44f47dfa809faf2d5463"},
{file = "jellyfish-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:9c747ae5c0fb4bd519f6abbfe4bd704b2f1c63fd4dd3dbb8d8864478974e1571"},
{file = "jellyfish-1.2.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:212aaf177236192a735bbbf5938717aa8518d14a25b08b015e47e783e70be060"},
{file = "jellyfish-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b8986d9768daddd5e87abf513ae168ea0afe690a444d4c82d5b1b14b0d045820"},
{file = "jellyfish-1.2.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5fa0ba0946f3c274f6a87aaa3c631dc70a363bd46cceea828ce777e8db653b6f"},
{file = "jellyfish-1.2.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6e76b23431a667cd485fb562428d1ad29bae9fdd0fcdfb5a51cc8087bae0e88c"},
{file = "jellyfish-1.2.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a058f4c6a591d5e5a47569f5648a26303ba19c76a960fef7e0beba2aa959e52e"},
{file = "jellyfish-1.2.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6a49ce2a580edd3b16b69421137deef464e2f8907f9ef906d49950b1a52908c1"},
{file = "jellyfish-1.2.1-cp314-cp314-musllinux_1_1_i686.whl", hash = "sha256:c85aa2bc76a36d92a3197f406f86636664d5b323727dfec4fa2842a8a24a06ae"},
{file = "jellyfish-1.2.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:29cfa8bfb72aacf2d611a3313b358ed4d4140fa3d3efcffea750c8e7f8acb1aa"},
{file = "jellyfish-1.2.1-cp314-cp314-win32.whl", hash = "sha256:f121218dc33fb318c34ddd889dc7362606ce1316af2bb63b73cc1df81523ca34"},
{file = "jellyfish-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:9a73b5c6425a70ebd440579a677eb4f03b327b2f59090db34e6c937aeea5aabd"},
{file = "jellyfish-1.2.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:5335f622458aa105289a8e358bc32ecd1b9634b6ffec3e77ea3577e49c297171"},
{file = "jellyfish-1.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6c51e565f85ce38cf9388c4f916d53888b0fa34788fcebe3aff3db24948e0960"},
{file = "jellyfish-1.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14bbb30d988dec1d12183cf5d4621c908f98add2009c72a185e8c3e8d00b804f"},
{file = "jellyfish-1.2.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9930e20f0e9f65ad1d57d98290c2be3abd75812d058815605f44a56056fb9a66"},
{file = "jellyfish-1.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0028857c5381c9d55e21cc6cb0d7f9545c3a9a7bb7dbca3960fe0a898c691ac2"},
{file = "jellyfish-1.2.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:56da7632e029912af25e25422fae3b6df318400297d552791f4b21da6d815ed6"},
{file = "jellyfish-1.2.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a3cab91020e3ff7565e55a611ec3e3257c093ac950d55778a48bfc8c57562b6e"},
{file = "jellyfish-1.2.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0b21c1596ce283fd7ee954eb0eeb007d59e480364324bcd91ad55146e91f3936"},
{file = "jellyfish-1.2.1-cp39-cp39-win32.whl", hash = "sha256:1098ce1f84ae3f147f0a18a6803ffb09b9c8cd5fedce42465643ca0b5c9d0224"},
{file = "jellyfish-1.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:4b013876109d91fa6fc871ffa4e0dbfda11820c33dc4ad0e2967b3fc1187f804"},
{file = "jellyfish-1.2.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c499ea3a134130797c50e367687a6a46a12653c59af381bee92c41a5ab0bd55d"},
{file = "jellyfish-1.2.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:91cad49a4fb731b726afc5ae385a3217a7016ed88a04da40c131cff8136a5db5"},
{file = "jellyfish-1.2.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5bda2275f31a64adf3483e39f7a4e2107f7dfe3a3f85f0d2c0cb6ae5fbe4a443"},
{file = "jellyfish-1.2.1-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:98a133b40dc00cfda6609e1b0cb0ab0b77796fc2719aae886a12009514f73499"},
{file = "jellyfish-1.2.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa30c7b59bd1c5e105693108a6d7a98f3e7a1a59e23e15bc5897b91fd5849f5"},
{file = "jellyfish-1.2.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:db97d873f23b0c15b4ed911ece10e5cc0bb96cdc53666d5c3788bd0af81807f1"},
{file = "jellyfish-1.2.1-pp310-pypy310_pp73-musllinux_1_1_i686.whl", hash = "sha256:393f609fd6139ce782e747e22c399483ffc58341009e6a97e39ffe5f5b2c674c"},
{file = "jellyfish-1.2.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fb3c6e537cb4605c22895a8d4a10cdb26611ba2bbfc7f0b4c1d06bb9d8aad648"},
{file = "jellyfish-1.2.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:748dc45a0394fbe9120b8b3b9a39fab0967c7e2d6ecdd5304af018e774f80f96"},
{file = "jellyfish-1.2.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:13f1ac9caba22af10bfe42f674822643c0266009f882e0fe652079706dc5d13a"},
{file = "jellyfish-1.2.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1ffeeb6c78c45fbb6d2a22b0173fb8a6af849001d6c26fab49c525136dbd9734"},
{file = "jellyfish-1.2.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1354b558a0a16597b6032dd0af64bebd24994f7e7484cf14993320eb764b06cb"},
{file = "jellyfish-1.2.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5977810972c6f0b2e61252c4758fd5aee21abf663ff309881195a99d37daa94"},
{file = "jellyfish-1.2.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:536c80d8d4ec7f39cbb10b85d926ff96cef3cde4a83ca0991c07cd9835d5dc13"},
{file = "jellyfish-1.2.1-pp311-pypy311_pp73-musllinux_1_1_i686.whl", hash = "sha256:21baa92d4a5112167721156f6d061c2ae105f2995b3a5e19cec6662928f0c439"},
{file = "jellyfish-1.2.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:68ea3ddd4dae1152a7f7155ef02a7bfad919611158d71b301f9aa167685819af"},
{file = "jellyfish-1.2.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:d7be8021658b46b22500a77f1707901bd98fc210f185c229b81c74efd3c1baf2"},
{file = "jellyfish-1.2.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:bcdcd603a7737cd3f5a2ab10ce9b49844329deb81c2daafcd8131e54fc730205"},
{file = "jellyfish-1.2.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c28a4ae3e201e1c1b7bacacd40e2e76c4068b90c9ae3a0d525e0ac98206f1cc"},
{file = "jellyfish-1.2.1-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bebccd0652ac1c7e438ae1f451edefde63d14b3af6f6daa30c599919dcb92886"},
{file = "jellyfish-1.2.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05be396aebe3dce7a8cb2f97727ecdf99e86457c48e97190775dce33f8b7e39d"},
{file = "jellyfish-1.2.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:9d4448c874959ae012cda0f6d570ac0bd7f0fcf12007714eaebf86b86919b66f"},
{file = "jellyfish-1.2.1-pp39-pypy39_pp73-musllinux_1_1_i686.whl", hash = "sha256:4a21d7eda5e6996772055f798e3fe1de1b33b3edad7f6cf0567097a21585a812"},
{file = "jellyfish-1.2.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:a0ef6f0ecc085c1f8fddb048f538c8bb89989e5d470eab45d4e9bd48ee73a40d"},
{file = "jellyfish-1.2.1.tar.gz", hash = "sha256:72d2fda61b23babe862018729be73c8b0dc12e3e6601f36f6e65d905e249f4db"},
]
[[package]]
name = "jmespath"
version = "1.1.0"
@ -2399,6 +2554,43 @@ files = [
{file = "jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d"},
]
[[package]]
name = "jsonschema"
version = "4.26.0"
description = "An implementation of JSON Schema validation for Python"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce"},
{file = "jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326"},
]
[package.dependencies]
attrs = ">=22.2.0"
jsonschema-specifications = ">=2023.03.6"
referencing = ">=0.28.4"
rpds-py = ">=0.25.0"
[package.extras]
format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"]
format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "rfc3987-syntax (>=1.1.0)", "uri-template", "webcolors (>=24.6.0)"]
[[package]]
name = "jsonschema-specifications"
version = "2025.9.1"
description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"},
{file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"},
]
[package.dependencies]
referencing = ">=0.31.0"
[[package]]
name = "jstyleson"
version = "0.0.2"
@ -2745,6 +2937,48 @@ files = [
{file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"},
]
[[package]]
name = "mcp"
version = "1.28.0"
description = "Model Context Protocol SDK"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "mcp-1.28.0-py3-none-any.whl", hash = "sha256:9c1e7cf3a9125557e418ecd4fed8e9adddce81b0dfdae4d6601d700f5beb71a4"},
{file = "mcp-1.28.0.tar.gz", hash = "sha256:559d3f9943674cafbe5744c5d3794f3237e8b47f9bbc58e20c0fad680d8487c2"},
]
[package.dependencies]
anyio = ">=4.5"
httpx = ">=0.27.1,<1.0.0"
httpx-sse = ">=0.4"
jsonschema = ">=4.20.0"
pydantic = [
{version = ">=2.11.0,<3.0.0", markers = "python_version < \"3.14\""},
{version = ">=2.12.0,<3.0.0", markers = "python_version >= \"3.14\""},
]
pydantic-settings = ">=2.5.2"
pyjwt = {version = ">=2.10.1", extras = ["crypto"]}
python-multipart = ">=0.0.9"
pywin32 = [
{version = ">=310", markers = "sys_platform == \"win32\" and python_version < \"3.14\""},
{version = ">=311", markers = "sys_platform == \"win32\" and python_version >= \"3.14\""},
]
sse-starlette = ">=1.6.1"
starlette = [
{version = ">=0.27", markers = "python_version < \"3.14\""},
{version = ">=0.48.0", markers = "python_version >= \"3.14\""},
]
typing-extensions = ">=4.9.0"
typing-inspection = ">=0.4.1"
uvicorn = {version = ">=0.31.1", markers = "sys_platform != \"emscripten\""}
[package.extras]
cli = ["python-dotenv (>=1.0.0)", "typer (>=0.16.0)"]
rich = ["rich (>=13.9.4)"]
ws = ["websockets (>=15.0.1)"]
[[package]]
name = "mdurl"
version = "0.1.2"
@ -3125,6 +3359,192 @@ files = [
{file = "ndjson-0.3.1.tar.gz", hash = "sha256:bf9746cb6bb1cb53d172cda7f154c07c786d665ff28341e4e689b796b229e5d6"},
]
[[package]]
name = "networkx"
version = "3.6"
description = "Python package for creating and manipulating graphs and networks"
optional = false
python-versions = ">=3.11"
groups = ["main"]
markers = "python_version == \"3.14\""
files = [
{file = "networkx-3.6-py3-none-any.whl", hash = "sha256:cdb395b105806062473d3be36458d8f1459a4e4b98e236a66c3a48996e07684f"},
{file = "networkx-3.6.tar.gz", hash = "sha256:285276002ad1f7f7da0f7b42f004bcba70d381e936559166363707fdad3d72ad"},
]
[package.extras]
benchmarking = ["asv", "virtualenv"]
default = ["matplotlib (>=3.8)", "numpy (>=1.25)", "pandas (>=2.0)", "scipy (>=1.11.2)"]
developer = ["mypy (>=1.15)", "pre-commit (>=4.1)"]
doc = ["intersphinx-registry", "myst-nb (>=1.1)", "numpydoc (>=1.8.0)", "pillow (>=10)", "pydata-sphinx-theme (>=0.16)", "sphinx (>=8.0)", "sphinx-gallery (>=0.18)", "texext (>=0.6.7)"]
example = ["cairocffi (>=1.7)", "contextily (>=1.6)", "igraph (>=0.11)", "iplotx (>=0.9.0)", "momepy (>=0.7.2)", "osmnx (>=2.0.0)", "scikit-learn (>=1.5)", "seaborn (>=0.13)"]
extra = ["lxml (>=4.6)", "pydot (>=3.0.1)", "pygraphviz (>=1.14)", "sympy (>=1.10)"]
release = ["build (>=0.10)", "changelist (==0.5)", "twine (>=4.0)", "wheel (>=0.40)"]
test = ["pytest (>=7.2)", "pytest-cov (>=4.0)", "pytest-xdist (>=3.0)"]
test-extras = ["pytest-mpl", "pytest-randomly"]
[[package]]
name = "networkx"
version = "3.6.1"
description = "Python package for creating and manipulating graphs and networks"
optional = false
python-versions = "!=3.14.1,>=3.11"
groups = ["main"]
markers = "python_version < \"3.14\""
files = [
{file = "networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762"},
{file = "networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509"},
]
[package.extras]
benchmarking = ["asv", "virtualenv"]
default = ["matplotlib (>=3.8)", "numpy (>=1.25)", "pandas (>=2.0)", "scipy (>=1.11.2)"]
developer = ["mypy (>=1.15)", "pre-commit (>=4.1)"]
doc = ["intersphinx-registry", "myst-nb (>=1.1)", "numpydoc (>=1.8.0)", "pillow (>=10)", "pydata-sphinx-theme (>=0.16)", "sphinx (>=8.0)", "sphinx-gallery (>=0.18)", "texext (>=0.6.7)"]
example = ["cairocffi (>=1.7)", "contextily (>=1.6)", "igraph (>=0.11)", "iplotx (>=0.9.0)", "momepy (>=0.7.2)", "osmnx (>=2.0.0)", "scikit-learn (>=1.5)", "seaborn (>=0.13)"]
extra = ["lxml (>=4.6)", "pydot (>=3.0.1)", "pygraphviz (>=1.14)", "sympy (>=1.10)"]
release = ["build (>=0.10)", "changelist (==0.5)", "twine (>=4.0)", "wheel (>=0.40)"]
test = ["pytest (>=7.2)", "pytest-cov (>=4.0)", "pytest-xdist (>=3.0)"]
test-extras = ["pytest-mpl", "pytest-randomly"]
[[package]]
name = "numpy"
version = "2.4.6"
description = "Fundamental package for array computing in Python"
optional = false
python-versions = ">=3.11"
groups = ["main"]
markers = "python_version < \"3.14\""
files = [
{file = "numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4"},
{file = "numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d"},
{file = "numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8"},
{file = "numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538"},
{file = "numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47"},
{file = "numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93"},
{file = "numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8"},
{file = "numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6"},
{file = "numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8"},
{file = "numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147"},
{file = "numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577"},
{file = "numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1"},
{file = "numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb"},
{file = "numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41"},
{file = "numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698"},
{file = "numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f"},
{file = "numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853"},
{file = "numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a"},
{file = "numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2"},
{file = "numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45"},
{file = "numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751"},
{file = "numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8"},
{file = "numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0"},
{file = "numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb"},
{file = "numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f"},
{file = "numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3"},
{file = "numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b"},
{file = "numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089"},
{file = "numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a"},
{file = "numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605"},
{file = "numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91"},
{file = "numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359"},
{file = "numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778"},
{file = "numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1"},
{file = "numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe"},
{file = "numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997"},
{file = "numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20"},
{file = "numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d"},
{file = "numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67"},
{file = "numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd"},
{file = "numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab"},
{file = "numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75"},
{file = "numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd"},
{file = "numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079"},
{file = "numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7"},
{file = "numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5"},
{file = "numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096"},
{file = "numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b"},
{file = "numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8"},
{file = "numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402"},
{file = "numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb"},
{file = "numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1"},
{file = "numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261"},
{file = "numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6"},
{file = "numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a"},
{file = "numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e"},
{file = "numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e"},
{file = "numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43"},
{file = "numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e"},
{file = "numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895"},
{file = "numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4"},
{file = "numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063"},
{file = "numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627"},
{file = "numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66"},
{file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662"},
{file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7"},
{file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f"},
{file = "numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c"},
{file = "numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0"},
{file = "numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02"},
{file = "numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73"},
{file = "numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda"},
]
[[package]]
name = "numpy"
version = "2.5.0"
description = "Fundamental package for array computing in Python"
optional = false
python-versions = ">=3.12"
groups = ["main"]
markers = "python_version == \"3.14\""
files = [
{file = "numpy-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:489780423903667933b4ed6197b6ec3b75ea5dd17d1d8f0f38d798feb6921561"},
{file = "numpy-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ece55976ced6bca95a03ae2839e2e5ccffe8eb6a3e7022415645eb154a81e4e6"},
{file = "numpy-2.5.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:c83b664b0e6eee9594fa920cf0639d8af796606d3fad6cc70180c87e4b97c7be"},
{file = "numpy-2.5.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:bf80333980bf37f523341ddd72c783f39d6829ec7736b9eb99086388a2d52cc2"},
{file = "numpy-2.5.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1a4874217b36d5ac8fc876f52e39df56f8182c88463e9e2dceabf7ca8b7efb8"},
{file = "numpy-2.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aaa760137137e8d3c920d27927748215b56014f92667dc9b6c27dfc61249255a"},
{file = "numpy-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7174ce8265fc7f7417d171c9ea8fe905220748893ea67a2a7abe726ec331c4b0"},
{file = "numpy-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b8c3daaf99de52415d20b42f8e8155c78642cb04207d02f9d317a0dcf1b3fb54"},
{file = "numpy-2.5.0-cp312-cp312-win32.whl", hash = "sha256:6206db0af545d73d068add6d992279145f158428d1da6cc49adc4b630c5d6ee5"},
{file = "numpy-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:6f2d6873e2940c860a309d21e25b1e69af6aaffdd80aa056b04c16380db1c4f2"},
{file = "numpy-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:a55e1eb2bca2cfd17a16b213c99dfc8502d47b0d494224d2122277d0400935ca"},
{file = "numpy-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:520e6b8be0a4b65840ac8090d4f51cef4bed66e2b0894d5a520f099adc24a9b2"},
{file = "numpy-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:146b81cdd3967fdb6beca8ba25f00c58741d8f3cbd797f55af0fbe0bfec3469c"},
{file = "numpy-2.5.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:126b88d95e8ff9b00c9e717aa540469f21d6180162f84c0caec51b16215d49cd"},
{file = "numpy-2.5.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d4313cef1594c5ce46c31b6e54e918338f63f16ee9322304e8c9114d6d81c8bd"},
{file = "numpy-2.5.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:750fb097caf26fa878746d9d119f6f9da12dedcbff1eea966c3e3447647c4a9e"},
{file = "numpy-2.5.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3893adc2dc7c0412ba76777db55a049215d99c9aa3113003be8f49f4f1290ab9"},
{file = "numpy-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:835e454dd99b238cdc5a3f63bce2371296f5ebc53ca1e0f8e6ddbb6d92a29aab"},
{file = "numpy-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f9836778081a0a3c02a6a21493f3e9f5b311f8d2541934f31f05583dc999ea4"},
{file = "numpy-2.5.0-cp313-cp313-win32.whl", hash = "sha256:0b525be4744b60bb0557ac872d53ef07d085b5f39622bc579c98d3809d05b988"},
{file = "numpy-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:44353e2878930039db472b99dc353d749826e4010bd4d2a7f835e94a97a5c748"},
{file = "numpy-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:48f54b00711f83a5f796b70c518e8c2b3c5848dda03a54911f23eb68519b9b60"},
{file = "numpy-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f27582c55ba4c750b7c58c8faf021d2cd9324a662b466229db8a417b41368af9"},
{file = "numpy-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:28e7137057d551e4a83c4ae414e3451f50568409db7569aacc7f9811ee06a446"},
{file = "numpy-2.5.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e1da54b53e75cd9fcfc23efcc7edab2c6aecf97b6037566d8a0fe804af8ec57c"},
{file = "numpy-2.5.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:694d8f74e156f7fd01179f1aa8faa2f648ab6ae0f70b6c3fe57a03249aea2303"},
{file = "numpy-2.5.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a7569a7b53c77716f036bb28cb1c91f166a26ec7d9502cd1e4bdfe502fdec22"},
{file = "numpy-2.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a0433bd4086ebd462960cf375e19195bb07b53dc1d87dd5fcf47ad78576f03"},
{file = "numpy-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:929f0c79ac38bcbd7154fe631dc907abfeddbcc5027a896bd1f7767323271e7a"},
{file = "numpy-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cc4f247a47bbf070bfd70be53ccdcf47b800af563535e7bbe172322197c30e21"},
{file = "numpy-2.5.0-cp314-cp314-win32.whl", hash = "sha256:5dc71423499fab3f46f7a7201155ade1669ea101f2f429d332df9e72f8161731"},
{file = "numpy-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ebb81d9d5443e0309d6c54894c3fbed74ad7da0714352a67b6d773cd189eae73"},
{file = "numpy-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3b94d0d0deceebfad3e67ae5c0e5eb87371e8f7a0581cd04a779928c2450cf1e"},
{file = "numpy-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:22f3d43e362d650bc39db1f17851302874a148ca95ba6981c1dfb5fa6862f35b"},
{file = "numpy-2.5.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:243563efb4cd7528a264567e9fd206c87826457322521d06206a00bfa316c927"},
{file = "numpy-2.5.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:84881d825ca75249b189bbee875fcfe3238aa5c479e6100893cda566e8e86826"},
{file = "numpy-2.5.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cda12aa4779d42b8771180aba759c96f527d43446d8f380ab59e2b35e8489efd"},
{file = "numpy-2.5.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c0121101093d2bd74981b10f8837d78e794a8ff57834eb27179f49e1ba11ac6"},
{file = "numpy-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d371c92cfa09da00022f501ab67fafaea813d752eb30ac44336d45b1e5b0268a"},
{file = "numpy-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9990713e9c38154c6861e7547f1e3fc7a87e75ff09bab24ef1cc81d81c2835e9"},
{file = "numpy-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:edadfbd4794b1086c0d822f81863e8a68fc129d132fd0bb9e31e955d7fbbbdb7"},
{file = "numpy-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f7e5fa4382967ae6548bd2f174219afb908e294b0d5f625af01166edd5f7d9aa"},
{file = "numpy-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865"},
{file = "numpy-2.5.0.tar.gz", hash = "sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c"},
]
[[package]]
name = "oauthlib"
version = "3.3.1"
@ -3980,6 +4400,30 @@ files = [
[package.dependencies]
typing-extensions = ">=4.14.1"
[[package]]
name = "pydantic-settings"
version = "2.14.2"
description = "Settings management using Pydantic"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440"},
{file = "pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f"},
]
[package.dependencies]
pydantic = ">=2.7.0"
python-dotenv = ">=0.21.0"
typing-inspection = ">=0.4.0"
[package.extras]
aws-secrets-manager = ["boto3 (>=1.35.0)", "types-boto3[secretsmanager]"]
azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"]
gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"]
toml = ["tomli (>=2.0.1)"]
yaml = ["pyyaml (>=6.0.1)"]
[[package]]
name = "pyflakes"
version = "3.4.0"
@ -4330,14 +4774,14 @@ testing = ["covdefaults (>=2.3)", "coverage (>=7.5.4)", "pytest (>=8.3.5)", "pyt
[[package]]
name = "python-dotenv"
version = "0.20.0"
version = "1.2.2"
description = "Read key-value pairs from a .env file and set them as environment variables"
optional = false
python-versions = ">=3.5"
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "python-dotenv-0.20.0.tar.gz", hash = "sha256:b7e3b04a59693c42c36f9ab1cc2acc46fa5df8c78e178fc33a8d4cd05c8d498f"},
{file = "python_dotenv-0.20.0-py3-none-any.whl", hash = "sha256:d92a187be61fe482e4fd675b6d52200e7be63a12b724abbf931a40ce4fa92938"},
{file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"},
{file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"},
]
[package.extras]
@ -4355,6 +4799,18 @@ files = [
{file = "python_json_logger-2.0.7-py3-none-any.whl", hash = "sha256:f380b826a991ebbe3de4d897aeec42760035ac760345e57b812938dc8b35e2bd"},
]
[[package]]
name = "python-multipart"
version = "0.0.32"
description = "A streaming multipart parser for Python"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23"},
{file = "python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e"},
]
[[package]]
name = "python3-openid"
version = "3.2.0"
@ -4386,6 +4842,38 @@ files = [
{file = "pytz-2022.7.1.tar.gz", hash = "sha256:01a0681c4b9684a28304615eba55d1ab31ae00bf68ec157ec3708a8182dbbcd0"},
]
[[package]]
name = "pywin32"
version = "312"
description = "Python for Windows Extensions"
optional = false
python-versions = ">=3.9"
groups = ["main"]
markers = "sys_platform == \"win32\""
files = [
{file = "pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e"},
{file = "pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db"},
{file = "pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd"},
{file = "pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c"},
{file = "pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a"},
{file = "pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47"},
{file = "pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b"},
{file = "pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc"},
{file = "pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950"},
{file = "pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c"},
{file = "pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9"},
{file = "pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831"},
{file = "pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b"},
{file = "pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e"},
{file = "pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa"},
{file = "pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed"},
{file = "pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5"},
{file = "pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9"},
{file = "pywin32-312-cp39-cp39-win32.whl", hash = "sha256:d620900033cc7531e50727c3c8333091df5dd3ffe6d68cdca38c03f5821408d5"},
{file = "pywin32-312-cp39-cp39-win_amd64.whl", hash = "sha256:dc90147579a905b8635e1b0ec6514967dcb07e6e0d9c42f1477feef14cac23bb"},
{file = "pywin32-312-cp39-cp39-win_arm64.whl", hash = "sha256:02ebca0f0242b75292e218065004310d6a477407c09fa449bfe4f6022bc0c0fc"},
]
[[package]]
name = "pywin32-ctypes"
version = "0.2.3"
@ -4646,6 +5134,23 @@ async-timeout = {version = ">=4.0.2", markers = "python_full_version <= \"3.11.2
hiredis = ["hiredis (>=1.0.0)"]
ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==20.0.1)", "requests (>=2.26.0)"]
[[package]]
name = "referencing"
version = "0.37.0"
description = "JSON Referencing + Python"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"},
{file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"},
]
[package.dependencies]
attrs = ">=22.2.0"
rpds-py = ">=0.7.0"
typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""}
[[package]]
name = "regex"
version = "2026.2.28"
@ -4874,6 +5379,146 @@ pygments = ">=2.13.0,<3.0.0"
[package.extras]
jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]]
name = "rpds-py"
version = "2026.5.1"
description = "Python bindings to Rust's persistent data structures (rpds)"
optional = false
python-versions = ">=3.11"
groups = ["main"]
files = [
{file = "rpds_py-2026.5.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3397a5ed7174dc2786bb214030232fc36fe8e5584fec43a9952cc542b1a12036"},
{file = "rpds_py-2026.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:99ab6ba7bfa2cb0f96a04e3652355bf04e3f51aceb1e943b8541dab7ba4828cc"},
{file = "rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0efbe45632665e53e3db8fe1e5692db58fc5cb9bab4459d570b83efefe11164"},
{file = "rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:01d17b29c0c23d82b1f4751147ec49cf451f1fc2554eb9ef5f957e55d2656ead"},
{file = "rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7559f72b94ae52659086c595dfa017cde03155f7832071d30959049052cb3ece"},
{file = "rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e25b7088f9ccbfc0dfcaa52bf969300ca229e10ecf758974ebcbb080a4b37bb"},
{file = "rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613fc4ee9eaef26dc5840666214dd6fbcebcf32f46e76f4abc473059f4e13dda"},
{file = "rpds_py-2026.5.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:85264a90ff4c05c1568dd65f5921c837614b67c60358fb4c17df3b7f2e90690a"},
{file = "rpds_py-2026.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe71bca7d547acb17027c7fd1624ff8aae623499c498d3e7011182c4de5c25e0"},
{file = "rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05fa4f41f37ec97c9c260441a940450a192f78d774d2b097eee1379f1e1246a"},
{file = "rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df1d2a1996755b24b9ecee92cb4d36c28f86f464a6a173349c26bab41e94b8c2"},
{file = "rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8895840ac4809e5f60c88fd07617cd71326e73d6e5a8aa783c5c0f7c24985de2"},
{file = "rpds_py-2026.5.1-cp311-cp311-win32.whl", hash = "sha256:3684a59b158a7683aaeb8e25352e9a9dd2122cec78f2d8530266e4f91b4c7b3f"},
{file = "rpds_py-2026.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:7bd530e6a530bb3ea892f194fafa455f3516ac25ecf7143fd33c09be62b0470a"},
{file = "rpds_py-2026.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:0a5ae4dbe43c1076983b72616496919872ae7bbe7a1e21cc48336bc3154d130b"},
{file = "rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d"},
{file = "rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c"},
{file = "rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08"},
{file = "rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb"},
{file = "rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1"},
{file = "rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5"},
{file = "rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644"},
{file = "rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4"},
{file = "rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6"},
{file = "rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4"},
{file = "rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24"},
{file = "rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732"},
{file = "rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed"},
{file = "rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870"},
{file = "rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473"},
{file = "rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d"},
{file = "rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3"},
{file = "rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559"},
{file = "rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db"},
{file = "rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02"},
{file = "rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b"},
{file = "rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e"},
{file = "rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b"},
{file = "rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46"},
{file = "rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf"},
{file = "rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f"},
{file = "rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89"},
{file = "rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842"},
{file = "rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf"},
{file = "rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd"},
{file = "rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600"},
{file = "rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa"},
{file = "rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00"},
{file = "rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0"},
{file = "rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97"},
{file = "rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef"},
{file = "rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d"},
{file = "rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83"},
{file = "rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2"},
{file = "rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd"},
{file = "rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1"},
{file = "rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3"},
{file = "rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc"},
{file = "rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55"},
{file = "rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9"},
{file = "rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78"},
{file = "rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63"},
{file = "rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a"},
{file = "rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195"},
{file = "rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee"},
{file = "rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba"},
{file = "rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec"},
{file = "rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d"},
{file = "rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d"},
{file = "rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02"},
{file = "rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0"},
{file = "rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7"},
{file = "rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838"},
{file = "rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8"},
{file = "rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad"},
{file = "rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3"},
{file = "rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081"},
{file = "rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6"},
{file = "rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5"},
{file = "rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b"},
{file = "rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964"},
{file = "rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131"},
{file = "rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81"},
{file = "rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47"},
{file = "rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a"},
{file = "rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca"},
{file = "rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a"},
{file = "rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6"},
{file = "rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb"},
{file = "rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291"},
{file = "rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1"},
{file = "rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8"},
{file = "rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2"},
{file = "rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038"},
{file = "rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26"},
{file = "rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd"},
{file = "rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9"},
{file = "rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14"},
{file = "rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01"},
{file = "rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d"},
{file = "rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa"},
{file = "rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325"},
{file = "rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16"},
{file = "rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723"},
{file = "rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41"},
{file = "rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a"},
{file = "rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358"},
{file = "rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb"},
{file = "rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b"},
{file = "rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc"},
{file = "rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015"},
{file = "rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa"},
{file = "rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972"},
{file = "rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66"},
{file = "rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb"},
{file = "rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df"},
{file = "rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3"},
{file = "rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:edf2765d84e42447f112ad877af8fe1db0089aaec5b28e88d6eab45e7fe99cea"},
{file = "rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad3773236e95f7f33991eb125224b7da66f206504d032a253a02da7e134519fb"},
{file = "rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a04df86b3f0fade39ec8fd0e0aab089b1da9fbd2b48df778a57ef96f5e7d38df"},
{file = "rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6142dbd80c4df62a5d899f0d616d417f84e0bc8d32526c8e5589019d75d028a7"},
{file = "rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0b35217adefe87f2fe4db7e9766cabe84744bfe9616d9667be18988928c7f2dc"},
{file = "rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b95d5e11fc712b752081183a55a244c03cd00570489edd7014d8899f8ceb8162"},
{file = "rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141c9498daf2ace9eda35d2b0e376f9ea8b058d84f2aef4f96fccfd449a2f251"},
{file = "rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:6f249f8b860a200ad35193af961183ebe9132710484e6f6ce0cf89fd83c63a9a"},
{file = "rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e4abbf391a70be864920858bf360f4fb380577c9a0f732438a1996726e2c195b"},
{file = "rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:c74005a7bb87752acf351c93897ec63ad77a07a0da7ecad9c050e32e7286ba34"},
{file = "rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:8213afbe8a3a906fb9acb2014423fe3359ee783d0bf90995f70623a3217bfa6c"},
{file = "rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049"},
{file = "rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256"},
]
[[package]]
name = "s3transfer"
version = "0.16.0"
@ -4909,6 +5554,21 @@ files = [
cryptography = ">=2.0"
jeepney = ">=0.6"
[[package]]
name = "segtok"
version = "1.5.11"
description = "sentence segmentation and word tokenization tools"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "segtok-1.5.11-py3-none-any.whl", hash = "sha256:910616b76198c3141b2772df530270d3b706e42ae69a5b30ef115c7bd5d1501a"},
{file = "segtok-1.5.11.tar.gz", hash = "sha256:8ab2dd44245bcbfec25b575dc4618473bbdf2af8c2649698cd5a370f42f3db23"},
]
[package.dependencies]
regex = "*"
[[package]]
name = "setuptools"
version = "82.0.1"
@ -5017,6 +5677,29 @@ files = [
dev = ["build"]
doc = ["sphinx"]
[[package]]
name = "sse-starlette"
version = "3.4.5"
description = "SSE plugin for Starlette"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "sse_starlette-3.4.5-py3-none-any.whl", hash = "sha256:e71bad53323f65573c3864a6c3bd0c1eb6e5f092b2e48082b0c35927d19ca296"},
{file = "sse_starlette-3.4.5.tar.gz", hash = "sha256:83072538bc211a2f68b7b0422226c4af3e9b62e106e07034664b832ca019842a"},
]
[package.dependencies]
anyio = ">=4.7.0"
starlette = ">=0.49.1"
[package.extras]
daphne = ["daphne (>=4.2.0)"]
examples = ["fastapi (>=0.115.12)", "pydantic (>=2)", "uvicorn (>=0.34.0)"]
examples-db = ["aiosqlite (>=0.21.0)", "sqlalchemy[asyncio] (>=2.0.41)"]
granian = ["granian (>=2.3.1)"]
uvicorn = ["uvicorn (>=0.34.0)"]
[[package]]
name = "stack-data"
version = "0.6.3"
@ -5037,6 +5720,25 @@ pure-eval = "*"
[package.extras]
tests = ["cython", "littleutils", "pygments", "pytest", "typeguard"]
[[package]]
name = "starlette"
version = "1.3.1"
description = "The little ASGI library that shines."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6"},
{file = "starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0"},
]
[package.dependencies]
anyio = ">=3.6.2,<5"
typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""}
[package.extras]
full = ["httpx (>=0.27.0,<0.29.0)", "httpx2 (>=2.0.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"]
[[package]]
name = "stevedore"
version = "5.7.0"
@ -5061,6 +5763,21 @@ files = [
{file = "stream_sqlite-0.0.41-py3-none-any.whl", hash = "sha256:3aa1bbf4b50eb67df7e5f56b9bbe828b31750c05c9bd883be29d15b8bdc016f5"},
]
[[package]]
name = "tabulate"
version = "0.10.0"
description = "Pretty-print tabular data"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3"},
{file = "tabulate-0.10.0.tar.gz", hash = "sha256:e2cfde8f79420f6deeffdeda9aaec3b6bc5abce947655d17ac662b126e48a60d"},
]
[package.extras]
widechars = ["wcwidth"]
[[package]]
name = "thefuzz"
version = "0.22.1"
@ -5439,6 +6156,18 @@ tzdata = {version = "*", markers = "platform_system == \"Windows\""}
[package.extras]
devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"]
[[package]]
name = "uritemplate"
version = "4.2.0"
description = "Implementation of RFC 6570 URI Templates"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686"},
{file = "uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e"},
]
[[package]]
name = "url-normalize"
version = "2.2.1"
@ -5474,6 +6203,26 @@ 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 = "uvicorn"
version = "0.49.0"
description = "The lightning-fast ASGI server."
optional = false
python-versions = ">=3.10"
groups = ["main"]
markers = "sys_platform != \"emscripten\""
files = [
{file = "uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f"},
{file = "uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3"},
]
[package.dependencies]
click = ">=7.0"
h11 = ">=0.8"
[package.extras]
standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.8.0)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.20)", "websockets (>=10.4)"]
[[package]]
name = "vadersentiment"
version = "3.3.2"
@ -5776,6 +6525,31 @@ files = [
[package.extras]
test = ["pytest", "pytest-cov"]
[[package]]
name = "yake"
version = "0.7.3"
description = "Keyword extraction Python package"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "yake-0.7.3-py3-none-any.whl", hash = "sha256:38f7f135ff8ed4bcdc05e16b533a9dc93299f1e694b0c308c3c086bab316c5fe"},
{file = "yake-0.7.3.tar.gz", hash = "sha256:8778fb2832e58d26d838d6d7ac967b4947521f1fe8cdf23dd872636161fc53ed"},
]
[package.dependencies]
click = ">=6.0"
jellyfish = "*"
networkx = "*"
numpy = ">=1.24.0"
segtok = "*"
tabulate = "*"
[package.extras]
benchmark = ["matplotlib (>=3.5.0)", "memory-profiler (>=0.60.0)", "pytest-benchmark (>=4.0.0)"]
dev = ["black (>=22.0.0)", "flake8 (>=6.0.0)", "pylint (>=3.3.0)", "pytest (>=7.0.0)", "pytest-cov (>=4.0.0)", "ruff (>=0.1.0)"]
lemmatization = ["nltk (>=3.8.0)", "spacy (>=3.8.0)"]
[[package]]
name = "yarl"
version = "1.23.0"
@ -6055,4 +6829,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt
[metadata]
lock-version = "2.1"
python-versions = ">=3.11,<3.15"
content-hash = "aafab54d3c3d674b917782bf449b7d6324ca2259fb58bff13a08caabe110c342"
content-hash = "beac677c269bb8618ca802e5f92f7558391d8d26f1b2150f3c8a6d3417848cb1"

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "vrobbler"
version = "55.2"
version = "58.5"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]
@ -9,8 +9,9 @@ python = ">=3.11,<3.15"
Django = "^4.0.3"
django-extensions = "^3.1.5"
python-dateutil = "^2.8.2"
python-dotenv = "^0.20.0"
python-dotenv = ">=0.20.0,<2"
python-json-logger = "^2.0.2"
cloudscraper = "^1.2.71"
colorlog = "^6.6.0"
httpx = "<=0.27.2"
djangorestframework = "^3.13.1"
@ -43,6 +44,7 @@ ipython = "^8.14.0"
pendulum = "^3"
trafilatura = "^1.6.3"
django-imagekit = "^5.0.0"
django-mcp-server = "^0.5.7"
thefuzz = "^0.22.1"
dataclass-wizard = "^0.35.0"
webdavclient3 = "^3.14.6"
@ -65,6 +67,7 @@ lxml = ">=5.5.0"
vaderSentiment = "^3.3.2"
sqids = "^0.5.2"
python-amazon-paapi = "^6.3.0"
yake = "^0.7.3"
[tool.poetry.group.test]
optional = true

View File

View File

@ -0,0 +1,63 @@
import tempfile
import pytest
from django.contrib.auth import get_user_model
User = get_user_model()
@pytest.fixture
def user(db):
return User.objects.create(email="golfer@example.com")
@pytest.fixture
def udisc_singles_csv_content():
return """PlayerName,CourseName,LayoutName,StartDate,Hole1,Hole2,Hole3,Total
Par,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",3,3,3,9
Alice,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",4,2,3,9
Bob,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",3,4,5,12
"""
@pytest.fixture
def udisc_singles_csv_file(udisc_singles_csv_content):
with tempfile.NamedTemporaryFile(
mode="w", suffix=".csv", delete=False, encoding="utf-8-sig"
) as f:
f.write(udisc_singles_csv_content)
return f.name
@pytest.fixture
def udisc_teams_csv_content():
return """PlayerName,CourseName,LayoutName,StartDate,Hole1,Hole2,Hole3,Total
Par,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",3,3,3,9
Alice+Bob,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",4,2,3,9
Charlie+Diana,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",3,4,5,12
"""
@pytest.fixture
def udisc_teams_csv_file(udisc_teams_csv_content):
with tempfile.NamedTemporaryFile(
mode="w", suffix=".csv", delete=False, encoding="utf-8-sig"
) as f:
f.write(udisc_teams_csv_content)
return f.name
@pytest.fixture
def udisc_csv_no_par_content():
return """PlayerName,CourseName,LayoutName,StartDate,Hole1,Hole2,Hole3,Total
Alice,Maple Hill,Mountains,"Jun 15, 2026 10:00 AM",4,2,3,9
"""
@pytest.fixture
def udisc_csv_no_par_file(udisc_csv_no_par_content):
with tempfile.NamedTemporaryFile(
mode="w", suffix=".csv", delete=False, encoding="utf-8-sig"
) as f:
f.write(udisc_csv_no_par_content)
return f.name

View File

@ -0,0 +1,102 @@
from discgolf.models import DiscGolfCourse, DiscGolfLogData
from scrobbles.dataclasses import BaseLogData
class TestDiscGolfCourseModel:
def test_create_course(self, db):
course = DiscGolfCourse.objects.create(
title="Maple Hill",
layout_name="Mountains",
number_of_holes=18,
par_total=54,
par_per_hole={"hole_1": 3, "hole_2": 3},
)
assert course.uuid is not None
assert str(course) == "Maple Hill (Mountains)"
assert course.subtitle == "Mountains"
def test_subtitle_fallback(self, db):
course = DiscGolfCourse.objects.create(title="Maple Hill")
assert course.subtitle == ""
def test_logdata_cls(self, db):
course = DiscGolfCourse.objects.create(title="Maple Hill")
assert course.logdata_cls is DiscGolfLogData
assert issubclass(course.logdata_cls, BaseLogData)
def test_strings(self, db):
course = DiscGolfCourse.objects.create(title="Maple Hill")
assert course.strings.verb == "Playing"
assert course.strings.tags == "golf"
def test_primary_image_url(self, db):
course = DiscGolfCourse.objects.create(title="Maple Hill")
assert course.primary_image_url == ""
def test_get_absolute_url(self, db):
course = DiscGolfCourse.objects.create(title="Maple Hill")
url = course.get_absolute_url()
assert str(course.uuid) in url
assert url.startswith("/disc-golf/")
def test_find_or_create_new(self, db):
course = DiscGolfCourse.find_or_create(
"New Course", layout_name="Default"
)
assert course.title == "New Course"
assert course.layout_name == "Default"
def test_find_or_create_existing(self, db):
created = DiscGolfCourse.objects.create(
title="Existing", layout_name="Alpha"
)
found = DiscGolfCourse.find_or_create("Existing", layout_name="Beta")
assert found.id == created.id
assert found.layout_name == "Alpha"
def test_scrobbles_method(self, db, user):
from datetime import datetime
import pytz
from scrobbles.models import Scrobble
course = DiscGolfCourse.objects.create(title="Maple Hill")
dt1 = datetime(2026, 6, 15, 14, 0, 0, tzinfo=pytz.UTC)
dt2 = datetime(2026, 6, 14, 14, 0, 0, tzinfo=pytz.UTC)
s1 = Scrobble.objects.create(
user=user,
disc_golf_course=course,
media_type=Scrobble.MediaType.DISC_GOLF,
timestamp=dt1,
)
s2 = Scrobble.objects.create(
user=user,
disc_golf_course=course,
media_type=Scrobble.MediaType.DISC_GOLF,
timestamp=dt2,
)
qs = course.scrobbles(user.id)
assert list(qs) == [s1, s2]
class TestDiscGolfLogData:
def test_basic_logdata(self):
data = DiscGolfLogData()
assert data.scores is None
assert data.weather is None
assert data.fun_factor is None
assert data.course_name is None
def test_logdata_with_scores(self):
data = DiscGolfLogData(
scores={"Alice": {"person_id": 1, "total": 9}},
weather="Sunny",
fun_factor="High",
course_name="Maple Hill",
par=9,
round_type="Singles",
)
assert data.scores["Alice"]["total"] == 9
assert data.weather == "Sunny"
assert data.round_type == "Singles"

View File

@ -0,0 +1,150 @@
from unittest.mock import patch
from discgolf.models import DiscGolfCourse
from discgolf.utils import _parse_udisc_datetime, import_udisc_csv
from people.models import Person
from scrobbles.models import Scrobble
class TestParserHelpers:
def test_parse_udisc_datetime(self):
dt = _parse_udisc_datetime("Jun 15, 2026 10:00 AM")
assert dt is not None
assert dt.year == 2026
assert dt.month == 6
assert dt.day == 15
assert dt.hour == 10
assert dt.minute == 0
def test_parse_udisc_datetime_date_only(self):
dt = _parse_udisc_datetime("Jun 15, 2026")
assert dt is not None
assert dt.year == 2026
class TestImportUdiscCSV:
def test_import_singles_creates_course(self, user, udisc_singles_csv_file):
import_udisc_csv(udisc_singles_csv_file, user.id)
course = DiscGolfCourse.objects.filter(title="Maple Hill").first()
assert course is not None
assert course.layout_name == "Mountains"
assert course.number_of_holes == 3
assert course.par_total == 9
assert course.par_per_hole == {"hole_1": 3, "hole_2": 3, "hole_3": 3}
def test_import_singles_creates_scrobble(self, user, udisc_singles_csv_file):
import_udisc_csv(udisc_singles_csv_file, user.id)
assert Scrobble.objects.filter(source="uDisc").count() == 1
def test_import_singles_logdata(self, user, udisc_singles_csv_file):
import_udisc_csv(udisc_singles_csv_file, user.id)
scrobble = Scrobble.objects.filter(source="uDisc").first()
log = scrobble.log
assert log["course_name"] == "Maple Hill"
assert log["par"] == 9
assert log["round_type"] == "Singles"
assert "Alice" in log["scores"]
assert "Bob" in log["scores"]
assert log["scores"]["Alice"]["total"] == 9
assert log["scores"]["Bob"]["total"] == 12
def test_import_singles_creates_people(self, user, udisc_singles_csv_file):
import_udisc_csv(udisc_singles_csv_file, user.id)
assert Person.objects.filter(name="Alice").exists()
assert Person.objects.filter(name="Bob").exists()
def test_import_teams_creates_scrobble(self, user, udisc_teams_csv_file):
import_udisc_csv(udisc_teams_csv_file, user.id)
assert Scrobble.objects.filter(source="uDisc").count() == 1
def test_import_teams_logdata(self, user, udisc_teams_csv_file):
import_udisc_csv(udisc_teams_csv_file, user.id)
scrobble = Scrobble.objects.filter(source="uDisc").first()
assert scrobble.log["round_type"] == "Teams"
alice_bob = scrobble.log["scores"]["Alice+Bob"]
assert "person_ids" in alice_bob
assert len(alice_bob["person_ids"]) == 2
def test_import_creates_team_people(self, user, udisc_teams_csv_file):
import_udisc_csv(udisc_teams_csv_file, user.id)
assert Person.objects.filter(name="Alice").exists()
assert Person.objects.filter(name="Bob").exists()
assert Person.objects.filter(name="Charlie").exists()
assert Person.objects.filter(name="Diana").exists()
def test_import_teams_par_per_hole(self, user, udisc_teams_csv_file):
import_udisc_csv(udisc_teams_csv_file, user.id)
course = DiscGolfCourse.objects.get(title="Maple Hill")
assert course.par_per_hole == {"hole_1": 3, "hole_2": 3, "hole_3": 3}
def test_import_no_par_returns_empty(self, user, udisc_csv_no_par_file):
result = import_udisc_csv(udisc_csv_no_par_file, user.id)
assert result == []
def test_import_empty_csv(self, user, db):
import tempfile
with tempfile.NamedTemporaryFile(
mode="w", suffix=".csv", delete=False, encoding="utf-8-sig"
) as f:
f.write("PlayerName,CourseName,LayoutName,StartDate,Hole1,Total\n")
path = f.name
result = import_udisc_csv(path, user.id)
assert result == []
def test_import_idempotent(self, user, udisc_singles_csv_file):
import_udisc_csv(udisc_singles_csv_file, user.id)
import_udisc_csv(udisc_singles_csv_file, user.id)
assert DiscGolfCourse.objects.filter(title="Maple Hill").count() == 1
assert Scrobble.objects.filter(source="uDisc").count() == 2
def test_import_course_defaults_only_on_create(
self, user, udisc_singles_csv_file
):
import_udisc_csv(udisc_singles_csv_file, user.id)
course = DiscGolfCourse.objects.get(title="Maple Hill")
assert course.layout_name == "Mountains"
course.layout_name = "Updated"
course.save()
import_udisc_csv(udisc_singles_csv_file, user.id)
course.refresh_from_db()
assert course.layout_name == "Updated"
@patch("discgolf.utils.ScrobbleNtfyNotification")
def test_import_sends_notification(self, mock_notification_class, user, udisc_singles_csv_file):
import_udisc_csv(udisc_singles_csv_file, user.id)
mock_notification_class.assert_called_once()
mock_notification_class.return_value.send.assert_called_once()
def test_import_hole_scores_per_player(self, user, udisc_singles_csv_file):
import_udisc_csv(udisc_singles_csv_file, user.id)
scrobble = Scrobble.objects.filter(source="uDisc").first()
alice = scrobble.log["scores"]["Alice"]
assert alice["hole_1"] == 4
assert alice["hole_2"] == 2
assert alice["hole_3"] == 3
bob = scrobble.log["scores"]["Bob"]
assert bob["hole_1"] == 3
assert bob["hole_2"] == 4
assert bob["hole_3"] == 5
def test_import_record_error_on_bad_data(self, user, db):
import tempfile
content = """PlayerName,CourseName,LayoutName,StartDate,Hole1,Hole2,Hole3,Total
Par,,Mountains,"Jun 15, 2026 10:00 AM",3,3,3,9
"""
with tempfile.NamedTemporaryFile(
mode="w", suffix=".csv", delete=False, encoding="utf-8-sig"
) as f:
f.write(content)
path = f.name
errors = []
result = import_udisc_csv(path, user.id, record_error=errors.append)
assert len(result) == 1
course = DiscGolfCourse.objects.first()
assert course.title == ""

View File

@ -0,0 +1,58 @@
from datetime import datetime
import pytz
from django.contrib.auth import get_user_model
from django.test import Client
from django.urls import reverse
from discgolf.models import DiscGolfCourse
from scrobbles.models import Scrobble
User = get_user_model()
class TestDiscGolfCourseViews:
def _make_scrobble(self, user, course):
dt = datetime(2026, 6, 15, 14, 0, 0, tzinfo=pytz.UTC)
return Scrobble.objects.create(
user=user,
disc_golf_course=course,
media_type=Scrobble.MediaType.DISC_GOLF,
timestamp=dt,
)
def test_course_list_anonymous(self, db, user):
course = DiscGolfCourse.objects.create(title="Maple Hill")
self._make_scrobble(user, course)
client = Client()
response = client.get(reverse("discgolf:course_list"))
assert response.status_code == 200
def test_course_list_shows_course(self, db, user):
course = DiscGolfCourse.objects.create(title="Maple Hill")
self._make_scrobble(user, course)
client = Client()
response = client.get(reverse("discgolf:course_list"))
assert response.status_code == 200
assert "Maple Hill" in response.content.decode()
def test_course_detail_anonymous(self, db, user):
course = DiscGolfCourse.objects.create(title="Maple Hill")
self._make_scrobble(user, course)
client = Client()
response = client.get(
reverse("discgolf:course_detail", kwargs={"slug": course.uuid})
)
assert response.status_code == 200
def test_course_detail_shows_course(self, db, user):
course = DiscGolfCourse.objects.create(
title="Maple Hill", layout_name="Mountains"
)
self._make_scrobble(user, course)
client = Client()
response = client.get(
reverse("discgolf:course_detail", kwargs={"slug": course.uuid})
)
assert response.status_code == 200
assert "Maple Hill" in response.content.decode()

View File

@ -4,9 +4,15 @@ from unittest.mock import MagicMock, patch
import pytest
from vrobbler import context_processors
from vrobbler.context_processors import version_info
@pytest.fixture(autouse=True)
def reset_git_cache():
context_processors._GIT_COMMIT = None
@pytest.fixture
def mock_request():
return MagicMock()

View File

@ -120,7 +120,12 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
def player_log(self) -> str:
if self.players:
return ", ".join(
[BoardGameScoreLogData(**player).__str__() for player in self.players]
[
BoardGameScoreLogData(
**{k: v for k, v in player.items() if k in BoardGameScoreLogData.__dataclass_fields__}
).__str__()
for player in self.players
]
)
return ""
@ -136,7 +141,9 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
if self.players:
players_html = []
for player_data in self.players:
player = BoardGameScoreLogData(**player_data)
player = BoardGameScoreLogData(
**{k: v for k, v in player_data.items() if k in BoardGameScoreLogData.__dataclass_fields__}
)
player_info = player.name
if player.score:
player_info += f" ({player.score})"

View File

@ -1,8 +1,19 @@
from books.models import Author, Book, Paper
from books.models import Author, Book, Journal, Paper
from django.contrib import admin
from scrobbles.admin import ScrobbleInline
@admin.register(Journal)
class JournalAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = (
"title",
"website_url",
)
search_fields = ("title",)
ordering = ("-created",)
@admin.register(Author)
class AuthorAdmin(admin.ModelAdmin):
date_hierarchy = "created"

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-06-23 14:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("books", "0037_book_volume_book_volume_comicvine_id"),
]
operations = [
migrations.AddField(
model_name="paper",
name="pdf_file",
field=models.FileField(blank=True, null=True, upload_to="papers/pdf/"),
),
]

View File

@ -0,0 +1,92 @@
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
import uuid
def migrate_journal_data(apps, schema_editor):
Paper = apps.get_model("books", "Paper")
Journal = apps.get_model("books", "Journal")
for paper in Paper.objects.all():
old_journal = getattr(paper, "journal", None)
if old_journal:
journal, _ = Journal.objects.get_or_create(title=str(old_journal))
paper._journal_tmp = journal
paper.save(update_fields=["_journal_tmp"])
def reverse_migrate_journal_data(apps, schema_editor):
Paper = apps.get_model("books", "Paper")
for paper in Paper.objects.all():
if paper._journal_tmp:
paper.journal = paper._journal_tmp.title
paper.save(update_fields=["journal"])
class Migration(migrations.Migration):
dependencies = [
("books", "0038_paper_pdf_file"),
]
operations = [
migrations.CreateModel(
name="Journal",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
(
"uuid",
models.UUIDField(
blank=True, default=uuid.uuid4, editable=False, null=True
),
),
("title", models.CharField(max_length=255)),
("description", models.TextField(blank=True, null=True)),
("website_url", models.URLField(blank=True, max_length=500, null=True)),
],
options={
"get_latest_by": "modified",
"abstract": False,
},
),
migrations.AddField(
model_name="paper",
name="_journal_tmp",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="books.journal",
),
),
migrations.RunPython(migrate_journal_data, reverse_migrate_journal_data),
migrations.RemoveField(
model_name="paper",
name="journal",
),
migrations.RenameField(
model_name="paper",
old_name="_journal_tmp",
new_name="journal",
),
]

View File

@ -27,7 +27,11 @@ from books.sources.amazon import lookup_book_from_amazon
from books.sources.openlibrary import (
lookup_book_from_openlibrary as lookup_book_from_ol,
)
from books.sources.semantic import lookup_paper_from_semantic
from books.sources.semantic import (
lookup_paper_from_semantic,
lookup_paper_from_semantic_by_doi,
)
from books.sources.scihub import SciHubService
from books.utils import get_comic_issue_url
from django.conf import settings
from django.contrib.auth import get_user_model
@ -82,6 +86,16 @@ class BookLogData(BaseLogData, LongPlayLogData):
return int(total_duration / len(self.page_data))
class Journal(TimeStampedModel):
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
title = models.CharField(max_length=255)
description = models.TextField(**BNULL)
website_url = models.URLField(max_length=500, **BNULL)
def __str__(self):
return self.title
class Author(TimeStampedModel):
name = models.CharField(max_length=255)
uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
@ -540,6 +554,21 @@ class Book(LongPlayScrobblableMixin):
return progress
@dataclass
class PaperLogData(BaseLogData):
@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)
fields["notes"] = NotesDictField(required=False)
return fields
class Paper(LongPlayScrobblableMixin):
"""Keeps track of Academic Papers"""
@ -559,14 +588,29 @@ class Paper(LongPlayScrobblableMixin):
language = models.CharField(max_length=4, **BNULL)
first_publish_year = models.IntegerField(**BNULL)
publish_date = models.DateField(**BNULL)
journal = models.CharField(max_length=255, **BNULL)
journal = models.ForeignKey(Journal, on_delete=models.DO_NOTHING, **BNULL)
journal_volume = models.CharField(max_length=50, **BNULL)
abstract = models.TextField(**BNULL)
tldr = models.CharField(max_length=255, **BNULL)
openaccess_pdf_url = models.CharField(max_length=255, **BNULL)
pdf_file = models.FileField(upload_to="papers/pdf/", **BNULL)
genre = TaggableManager(through=ObjectWithGenres, blank=True, verbose_name="Genre")
@property
def logdata_cls(self):
return PaperLogData
@property
def scihub_url(self):
if not self.doi_id:
return None
domain = getattr(settings, "SCIHUB_DOMAIN", "sci-hub.st")
return f"https://{domain}/{self.doi_id}"
def get_absolute_url(self):
return reverse("books:paper_detail", kwargs={"slug": self.uuid})
@classmethod
def get_from_semantic(cls, title: str, overwrite: bool = False) -> "Paper":
paper, created = cls.objects.get_or_create(title=title)
@ -577,7 +621,7 @@ class Paper(LongPlayScrobblableMixin):
if created or overwrite:
author_list = []
author_dicts = paper_dict.pop("author_dicts")
author_dicts = paper_dict.pop("author_dicts", None)
if author_dicts:
for author_dict in author_dicts:
if author_dict.get("authorId"):
@ -588,8 +632,11 @@ class Paper(LongPlayScrobblableMixin):
if a_created:
author.name = author_dict.get("name")
author.save()
# TODO enrich author?
...
journal_name = paper_dict.pop("journal_name", None)
if journal_name:
journal, _ = Journal.objects.get_or_create(title=journal_name)
paper.journal = journal
for k, v in paper_dict.items():
setattr(paper, k, v)
@ -601,3 +648,78 @@ class Paper(LongPlayScrobblableMixin):
if genres:
paper.genre.add(*genres)
return paper
@classmethod
def find_or_create_by_doi(cls, doi_url: str) -> "Paper":
doi = doi_url.replace("https://doi.org/", "").split("?")[0].rstrip("/")
paper = cls.objects.filter(doi_id=doi).first()
if paper:
return paper
paper = cls(doi_id=doi, title=f"Paper {doi}")
paper.save()
from books.sources.crossref import lookup_paper_from_crossref
paper_dict = lookup_paper_from_semantic_by_doi(doi)
if not paper_dict or not paper_dict.get("abstract"):
paper_dict = lookup_paper_from_crossref(doi)
if paper_dict:
author_list = []
author_dicts = paper_dict.pop("author_dicts", None)
if author_dicts:
for author_dict in author_dicts:
author_id = author_dict.get("authorId")
if author_id:
author, a_created = Author.objects.get_or_create(
semantic_id=author_id
)
author_list.append(author)
if a_created:
author.name = author_dict.get("name")
author.save()
else:
author_name = author_dict.get("name")
if author_name:
author, a_created = Author.objects.get_or_create(
name=author_name
)
author_list.append(author)
journal_name = paper_dict.pop("journal_name", None)
if journal_name:
journal, _ = Journal.objects.get_or_create(title=journal_name)
paper.journal = journal
for k, v in paper_dict.items():
if v is not None:
setattr(paper, k, v)
paper.save()
if author_list:
paper.authors.add(*author_list)
genres = paper_dict.pop("genres", [])
if genres:
paper.genre.add(*genres)
if not paper.pdf_file:
service = SciHubService()
if paper.openaccess_pdf_url:
pdf_content = service.fetch_from_url(paper.openaccess_pdf_url)
if pdf_content:
filename = f"{doi.replace('/', '_')}.pdf"
paper.pdf_file.save(filename, ContentFile(pdf_content))
if not paper.pdf_file:
try:
pdf_content = service.fetch_pdf(doi)
if pdf_content:
filename = f"{doi.replace('/', '_')}.pdf"
paper.pdf_file.save(filename, ContentFile(pdf_content))
except Exception as e:
logger.error(
"[paper] sci-hub PDF download failed",
extra={"doi": doi, "error": str(e)},
)
return paper

View File

@ -0,0 +1,149 @@
import json
import logging
import re
import requests
import yake
CROSSREF_WORK_URL = "https://api.crossref.org/works/{}"
logger = logging.getLogger(__name__)
_STOPWORDS = {
"this", "that", "these", "those", "the", "a", "an", "in", "on", "at",
"to", "for", "of", "and", "or", "is", "are", "was", "were", "be",
"been", "being", "have", "has", "had", "do", "does", "did", "will",
"would", "can", "could", "may", "might", "shall", "should", "not",
"no", "nor", "with", "from", "by", "as", "at", "but", "if", "because",
"while", "although", "however", "we", "our", "their", "its", "it",
"they", "them", "also", "more", "most", "new", "such", "into",
"across", "between", "through", "about", "after", "before", "during",
"within", "without", "other", "many", "some", "each", "every", "both",
"few", "own", "via",
}
_DROP_PHRASES = {
"paper", "study", "studies", "research", "introduction", "conclusion",
"conclusions", "background", "methods", "results", "findings",
"analysis", "approach", "approaches", "framework", "theory",
"theories", "concept", "concepts", "model", "models", "process",
"processes", "role", "roles", "factor", "factors", "effect",
"effects", "impact", "implication", "implications", "actor", "actors",
"article", "chapter", "section", "discussion", "review", "overview",
"summary", "methodology", "special issue", "implications",
"limitations", "findings", "purpose", "objective", "objectives",
"design", "setting", "participants", "sample", "data",
"contemporary", "little", "empirical", "theoretical",
"organizations", "dissent",
}
def _strip_jats(text: str) -> str:
if not text:
return ""
text = re.sub(r"</?jats:[^>]*>", "", text)
text = re.sub(r"^\s*Abstract\s*", "", text)
return text.strip()
def _extract_genres_from_abstract(abstract: str, max_keywords: int = 8) -> list[str]:
if not abstract or len(abstract) < 50:
return []
kw_extractor = yake.KeywordExtractor(lan="en", n=2, top=max_keywords)
keywords = kw_extractor.extract_keywords(abstract)
genres = []
seen = set()
for kw, score in keywords:
kw_lower = kw.lower().strip()
if kw_lower in seen or kw_lower in _DROP_PHRASES:
continue
words = [w for w in kw_lower.split() if w not in _STOPWORDS]
cleaned = " ".join(words)
if not cleaned or len(cleaned) < 3 or cleaned in seen:
continue
if cleaned in _DROP_PHRASES:
continue
seen.add(cleaned)
genres.append(cleaned)
return genres
def lookup_paper_from_crossref(doi: str) -> dict:
url = CROSSREF_WORK_URL.format(doi)
headers = {"User-Agent": "Vrobbler/1.0 (mailto:hello@example.com)"}
response = requests.get(url, headers=headers)
if response.status_code != 200:
logger.warning(
"Bad response from Crossref",
extra={"doi": doi, "status": response.status_code},
)
return {"doi_id": doi}
try:
data = response.json()
except json.JSONDecodeError:
return {"doi_id": doi}
msg = data.get("message", {})
if not msg:
return {"doi_id": doi}
paper_dict = {"doi_id": doi}
titles = msg.get("title", [])
if titles:
paper_dict["title"] = titles[0]
abstract = msg.get("abstract", "")
if abstract:
stripped = _strip_jats(abstract)
paper_dict["abstract"] = stripped
genres = _extract_genres_from_abstract(stripped)
if genres:
paper_dict["genres"] = genres
author_dicts = []
for author in msg.get("author", []):
given = author.get("given", "")
family = author.get("family", "")
name = f"{given} {family}".strip()
if not name:
continue
entry = {"name": name}
orcid = author.get("ORCID", "")
if orcid:
orcid_id = orcid.replace("https://orcid.org/", "")
entry["authorId"] = orcid_id
author_dicts.append(entry)
if author_dicts:
paper_dict["author_dicts"] = author_dicts
container = msg.get("container-title", [])
if container:
paper_dict["journal_name"] = container[0]
volume = msg.get("volume")
if volume:
paper_dict["journal_volume"] = volume
page = msg.get("page")
if page:
try:
parts = page.split("-")
if len(parts) == 2:
paper_dict["pages"] = int(parts[1]) - int(parts[0])
except (ValueError, IndexError):
pass
for date_field in ("published-print", "published-online", "created"):
date_data = msg.get(date_field)
if date_data and date_data.get("date-parts"):
parts = date_data["date-parts"][0]
if len(parts) >= 1:
paper_dict["first_publish_year"] = int(parts[0])
if len(parts) >= 3:
paper_dict["publish_date"] = f"{parts[0]:04d}-{parts[1]:02d}-{parts[2]:02d}"
break
return paper_dict

View File

@ -0,0 +1,142 @@
import logging
from typing import Optional
from urllib.parse import urljoin
import requests
from bs4 import BeautifulSoup
from django.conf import settings
logger = logging.getLogger(__name__)
SCIHUB_DOMAINS = [
"sci-hub.ru",
"sci-hub.ee",
"sci-hub.st",
"sci-hub.do",
]
class SciHubService:
def __init__(self):
self.session = requests.Session()
self.session.headers.update(
{
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
}
)
def fetch_from_url(self, url: str) -> Optional[bytes]:
try:
resp = self.session.get(url, timeout=60)
if resp.status_code != 200:
logger.warning(
"[pdf] URL download failed",
extra={"status": resp.status_code, "url": url},
)
return None
if not self._looks_like_pdf(resp):
return None
return resp.content
except requests.RequestException as e:
logger.error(
"[pdf] URL download request failed",
extra={"url": url, "error": str(e)},
)
return None
def fetch_pdf(self, doi: str) -> Optional[bytes]:
configured_domain = getattr(settings, "SCIHUB_DOMAIN", None)
domains_to_try = (
[configured_domain] + SCIHUB_DOMAINS
if configured_domain and configured_domain not in SCIHUB_DOMAINS
else SCIHUB_DOMAINS
)
for domain in domains_to_try:
url = f"https://{domain}/{doi}"
logger.info(
"[scihub] trying domain",
extra={"domain": domain, "doi": doi},
)
try:
response = self.session.get(url, timeout=30)
if response.status_code != 200:
continue
pdf_url = self._extract_pdf_url(response.text, url)
if not pdf_url:
continue
pdf_response = self.session.get(pdf_url, timeout=60)
if pdf_response.status_code != 200:
continue
if not self._looks_like_pdf(pdf_response):
continue
logger.info(
"[scihub] PDF downloaded successfully",
extra={
"domain": domain,
"doi": doi,
"size": len(pdf_response.content),
},
)
return pdf_response.content
except requests.RequestException as e:
logger.debug(
"[scihub] domain failed",
extra={"domain": domain, "doi": doi, "error": str(e)},
)
continue
logger.warning(
"[scihub] all domains failed",
extra={"doi": doi, "tried": domains_to_try},
)
return None
def _looks_like_pdf(self, response: requests.Response) -> bool:
content_type = response.headers.get("Content-Type", "")
if "application/pdf" in content_type:
return True
if content_type.startswith("application/octet"):
return True
if response.url.endswith(".pdf"):
return True
return False
def _extract_pdf_url(self, html: str, page_url: str) -> Optional[str]:
soup = BeautifulSoup(html, "html.parser")
iframe = soup.find("iframe", {"id": "pdf"})
if iframe and iframe.get("src"):
src = iframe["src"]
if src.startswith("http"):
return src
return urljoin(page_url, src)
embed = soup.find("embed", {"type": "application/pdf"})
if embed and embed.get("src"):
src = embed["src"]
if src.startswith("http"):
return src
return urljoin(page_url, src)
download_div = soup.find("div", {"id": "download"})
if download_div:
link = download_div.find("a")
if link and link.get("href"):
href = link["href"]
if href.startswith("http"):
return href
return urljoin(page_url, href)
for link in soup.find_all("a", href=True):
href = link["href"]
if ".pdf" in href:
if href.startswith("http"):
return href
return urljoin(page_url, href)
return None

View File

@ -9,6 +9,7 @@ PAPER_SEARCH_URL = (
"https://api.semanticscholar.org/graph/v1/paper/search/match?query={}"
)
PAPER_DETAIL_URL = "https://api.semanticscholar.org/graph/v1/paper/{}?fields=title,authors,url,year,abstract,externalIds,citationCount,referenceCount,journal,fieldsOfStudy,publicationDate,openAccessPdf"
PAPER_DOI_URL = "https://api.semanticscholar.org/graph/v1/paper/DOI:{}?fields=title,authors,url,year,abstract,externalIds,citationCount,referenceCount,journal,fieldsOfStudy,publicationDate,openAccessPdf"
logger = logging.getLogger(__name__)
@ -39,6 +40,18 @@ def lookup_paper_from_semantic(title: str) -> dict:
if not result:
return paper_dict
paper_dict.update(_parse_semantic_result(result))
paper_dict.setdefault("title", title)
if paper_dict.get("publish_date"):
paper_dict["publish_date"] = datetime.strptime(
paper_dict["publish_date"], "%Y-%m-%d"
)
return paper_dict
def _parse_semantic_result(result: dict) -> dict:
paper_dict = {}
page_str = result.get("journal", {}).get("pages")
if page_str:
try:
@ -55,12 +68,10 @@ def lookup_paper_from_semantic(title: str) -> dict:
paper_dict["corpus_id"] = result.get("externalIds", {}).get("CorpusId")
paper_dict["semantic_title"] = result.get("title")
paper_dict["first_publish_year"] = result.get("year")
paper_dict["publish_date"] = datetime.strptime(
result.get("publicationDate", "1950-01-01"), "%Y-%m-%d"
)
paper_dict["publish_date"] = result.get("publicationDate")
paper_dict["abstract"] = result.get("abstract")
paper_dict["tldr"] = result.get("bib", {}).get("abstract")
paper_dict["journal"] = result.get("journal", {}).get("name")
paper_dict["journal_name"] = result.get("journal", {}).get("name")
paper_dict["journal_volume"] = result.get("journal", {}).get("volume")
paper_dict["openaccess_pdf_url"] = result.get("openAccessPdf", {}).get("url")
paper_dict["base_run_time_seconds"] = paper_dict.get("pages", 10) * getattr(
@ -68,5 +79,19 @@ def lookup_paper_from_semantic(title: str) -> dict:
)
paper_dict["author_dicts"] = result.get("authors")
paper_dict["genres"] = result.get("fieldsOfStudy")
return paper_dict
def lookup_paper_from_semantic_by_doi(doi: str) -> dict:
response = get_api_result(PAPER_DOI_URL.format(doi))
if not response:
return {"doi_id": doi}
result = json.loads(response.content)
if not result:
return {"doi_id": doi}
paper_dict = _parse_semantic_result(result)
if not paper_dict.get("title"):
paper_dict["title"] = result.get("title", f"Paper {doi}")
return paper_dict

View File

@ -16,4 +16,15 @@ urlpatterns = [
views.AuthorDetailView.as_view(),
name="author_detail",
),
path("papers/", views.PaperListView.as_view(), name="paper_list"),
path(
"papers/<slug:slug>/",
views.PaperDetailView.as_view(),
name="paper_detail",
),
path(
"papers/<slug:slug>/upload_pdf/",
views.PaperUploadPdfView.as_view(),
name="paper_upload_pdf",
),
]

View File

@ -1,5 +1,8 @@
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.views import View
from django.views import generic
from books.models import Book, Author
from books.models import Book, Author, Paper
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
@ -15,3 +18,24 @@ class BookDetailView(ScrobbleableDetailView):
class AuthorDetailView(generic.DetailView):
model = Author
slug_field = "uuid"
class PaperListView(ScrobbleableListView):
model = Paper
class PaperDetailView(ScrobbleableDetailView):
model = Paper
class PaperUploadPdfView(View):
def post(self, request, slug):
paper = Paper.objects.filter(uuid=slug).first()
if not paper or not request.user.is_authenticated:
return HttpResponseRedirect(reverse("books:paper_detail", args=[slug]))
pdf_file = request.FILES.get("pdf_file")
if pdf_file:
paper.pdf_file.save(pdf_file.name, pdf_file)
return HttpResponseRedirect(reverse("books:paper_detail", args=[slug]))

View File

@ -0,0 +1,109 @@
# Generated by Django 4.2.29 on 2026-06-19 16:25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("charts", "0002_chartrecord_charts_char_user_id_1adcde_idx_and_more"),
]
operations = [
migrations.AddConstraint(
model_name="chartrecord",
constraint=models.UniqueConstraint(
condition=models.Q(("artist__isnull", False)),
fields=("user", "year", "month", "week", "day", "artist"),
name="unique_chart_artist_period",
),
),
migrations.AddConstraint(
model_name="chartrecord",
constraint=models.UniqueConstraint(
condition=models.Q(("album__isnull", False)),
fields=("user", "year", "month", "week", "day", "album"),
name="unique_chart_album_period",
),
),
migrations.AddConstraint(
model_name="chartrecord",
constraint=models.UniqueConstraint(
condition=models.Q(("track__isnull", False)),
fields=("user", "year", "month", "week", "day", "track"),
name="unique_chart_track_period",
),
),
migrations.AddConstraint(
model_name="chartrecord",
constraint=models.UniqueConstraint(
condition=models.Q(("tv_series__isnull", False)),
fields=("user", "year", "month", "week", "day", "tv_series"),
name="unique_chart_tv_series_period",
),
),
migrations.AddConstraint(
model_name="chartrecord",
constraint=models.UniqueConstraint(
condition=models.Q(("video__isnull", False)),
fields=("user", "year", "month", "week", "day", "video"),
name="unique_chart_video_period",
),
),
migrations.AddConstraint(
model_name="chartrecord",
constraint=models.UniqueConstraint(
condition=models.Q(("podcast__isnull", False)),
fields=("user", "year", "month", "week", "day", "podcast"),
name="unique_chart_podcast_period",
),
),
migrations.AddConstraint(
model_name="chartrecord",
constraint=models.UniqueConstraint(
condition=models.Q(("podcast_episode__isnull", False)),
fields=("user", "year", "month", "week", "day", "podcast_episode"),
name="unique_chart_podcast_episode_period",
),
),
migrations.AddConstraint(
model_name="chartrecord",
constraint=models.UniqueConstraint(
condition=models.Q(("board_game__isnull", False)),
fields=("user", "year", "month", "week", "day", "board_game"),
name="unique_chart_board_game_period",
),
),
migrations.AddConstraint(
model_name="chartrecord",
constraint=models.UniqueConstraint(
condition=models.Q(("trail__isnull", False)),
fields=("user", "year", "month", "week", "day", "trail"),
name="unique_chart_trail_period",
),
),
migrations.AddConstraint(
model_name="chartrecord",
constraint=models.UniqueConstraint(
condition=models.Q(("geo_location__isnull", False)),
fields=("user", "year", "month", "week", "day", "geo_location"),
name="unique_chart_geo_location_period",
),
),
migrations.AddConstraint(
model_name="chartrecord",
constraint=models.UniqueConstraint(
condition=models.Q(("food__isnull", False)),
fields=("user", "year", "month", "week", "day", "food"),
name="unique_chart_food_period",
),
),
migrations.AddConstraint(
model_name="chartrecord",
constraint=models.UniqueConstraint(
condition=models.Q(("book__isnull", False)),
fields=("user", "year", "month", "week", "day", "book"),
name="unique_chart_book_period",
),
),
]

View File

@ -2,6 +2,7 @@ import calendar
from django.contrib.auth import get_user_model
from django.db import models
from django.db.models import Q
from django.urls import reverse
from django_extensions.db.models import TimeStampedModel
@ -84,6 +85,68 @@ class ChartRecord(TimeStampedModel):
models.Index(fields=["user", "year", "month", "day", "album", "rank"]),
models.Index(fields=["user", "year", "month", "day", "tv_series", "rank"]),
]
constraints = [
models.UniqueConstraint(
fields=["user", "year", "month", "week", "day", "artist"],
condition=Q(artist__isnull=False),
name="unique_chart_artist_period",
),
models.UniqueConstraint(
fields=["user", "year", "month", "week", "day", "album"],
condition=Q(album__isnull=False),
name="unique_chart_album_period",
),
models.UniqueConstraint(
fields=["user", "year", "month", "week", "day", "track"],
condition=Q(track__isnull=False),
name="unique_chart_track_period",
),
models.UniqueConstraint(
fields=["user", "year", "month", "week", "day", "tv_series"],
condition=Q(tv_series__isnull=False),
name="unique_chart_tv_series_period",
),
models.UniqueConstraint(
fields=["user", "year", "month", "week", "day", "video"],
condition=Q(video__isnull=False),
name="unique_chart_video_period",
),
models.UniqueConstraint(
fields=["user", "year", "month", "week", "day", "podcast"],
condition=Q(podcast__isnull=False),
name="unique_chart_podcast_period",
),
models.UniqueConstraint(
fields=["user", "year", "month", "week", "day", "podcast_episode"],
condition=Q(podcast_episode__isnull=False),
name="unique_chart_podcast_episode_period",
),
models.UniqueConstraint(
fields=["user", "year", "month", "week", "day", "board_game"],
condition=Q(board_game__isnull=False),
name="unique_chart_board_game_period",
),
models.UniqueConstraint(
fields=["user", "year", "month", "week", "day", "trail"],
condition=Q(trail__isnull=False),
name="unique_chart_trail_period",
),
models.UniqueConstraint(
fields=["user", "year", "month", "week", "day", "geo_location"],
condition=Q(geo_location__isnull=False),
name="unique_chart_geo_location_period",
),
models.UniqueConstraint(
fields=["user", "year", "month", "week", "day", "food"],
condition=Q(food__isnull=False),
name="unique_chart_food_period",
),
models.UniqueConstraint(
fields=["user", "year", "month", "week", "day", "book"],
condition=Q(book__isnull=False),
name="unique_chart_book_period",
),
]
@property
def media_obj(self):

View File

@ -6,6 +6,7 @@ from typing import Optional
import pytz
from django.apps import apps
from django.conf import settings
from django.db import transaction
from django.db.models import Count, Q
from django.utils import timezone
@ -186,60 +187,64 @@ def build_charts(
ranks = {count: rank for rank, count in enumerate(unique_counts, start=1)}
media_field = f"{media_type}_id"
records_to_create = []
records_to_update = []
existing = ChartRecord.objects.filter(
period_filter, user=user, **{media_field + "__isnull": False}
)
existing_by_media_id = {getattr(r, media_field): r for r in existing}
found_media_ids = set()
with transaction.atomic():
records_to_create = []
records_to_update = []
for result in results:
media_id = result[config["values"]]
if media_id is None:
continue
found_media_ids.add(media_id)
chart_record_data = {
"user_id": user.id,
"year": year,
"month": month,
"week": week,
"day": day,
"rank": ranks[result["scrobble_count"]],
"count": result["scrobble_count"],
}
chart_record_data[media_field] = media_id
if media_id in existing_by_media_id:
existing_record = existing_by_media_id[media_id]
existing_record.rank = chart_record_data["rank"]
existing_record.count = chart_record_data["count"]
records_to_update.append(existing_record)
else:
records_to_create.append(ChartRecord(**chart_record_data))
ids_to_delete = [
r.id for r in existing if getattr(r, media_field) not in found_media_ids
]
if ids_to_delete:
ChartRecord.objects.filter(id__in=ids_to_delete).delete()
if records_to_update:
ChartRecord.objects.bulk_update(
records_to_update, ["rank", "count"], batch_size=500
existing = ChartRecord.objects.select_for_update().filter(
period_filter, user=user, **{media_field + "__isnull": False}
)
existing_by_media_id = {getattr(r, media_field): r for r in existing}
found_media_ids = set()
if records_to_create:
ChartRecord.objects.bulk_create(records_to_create, batch_size=500)
for result in results:
media_id = result[config["values"]]
if media_id is None:
continue
logger.info(
f"Built {len(records_to_create)} new, {len(records_to_update)} updated "
f"chart records for {media_type}, period "
f"{year}-{month or ''}-{week or ''}-{day or ''} for {user}"
)
found_media_ids.add(media_id)
chart_record_data = {
"user_id": user.id,
"year": year,
"month": month,
"week": week,
"day": day,
"rank": ranks[result["scrobble_count"]],
"count": result["scrobble_count"],
}
chart_record_data[media_field] = media_id
if media_id in existing_by_media_id:
existing_record = existing_by_media_id[media_id]
existing_record.rank = chart_record_data["rank"]
existing_record.count = chart_record_data["count"]
records_to_update.append(existing_record)
else:
records_to_create.append(ChartRecord(**chart_record_data))
ids_to_delete = [
r.id
for r in existing
if getattr(r, media_field) not in found_media_ids
]
if ids_to_delete:
ChartRecord.objects.filter(id__in=ids_to_delete).delete()
if records_to_update:
ChartRecord.objects.bulk_update(
records_to_update, ["rank", "count"], batch_size=500
)
if records_to_create:
ChartRecord.objects.bulk_create(records_to_create, batch_size=500)
logger.info(
f"Built {len(records_to_create)} new, {len(records_to_update)} updated "
f"chart records for {media_type}, period "
f"{year}-{month or ''}-{week or ''}-{day or ''} for {user}"
)
def build_yesterdays_charts(user, media_types: Optional[list] = None) -> None:

View File

@ -114,6 +114,51 @@ class ChartRecordView(TemplateView):
context["current_week"] = current_week
context["current_day"] = current_day
# Resolve date parameters
if date_param:
parts = date_param.split("-")
year = int(parts[0])
week = None
month = None
day = None
if len(parts) >= 2 and parts[1].startswith("W"):
week = int(parts[1].lstrip("W"))
elif len(parts) >= 2 and parts[1]:
try:
month = int(parts[1])
except ValueError:
pass
if len(parts) >= 3:
if parts[2].startswith("W"):
week = int(parts[2].lstrip("W"))
elif not parts[2].startswith("W"):
day = int(parts[2])
context["period"] = "historical"
context["year"] = year
context["month"] = month
context["month_name"] = calendar.month_name[month] if month else None
context["week"] = week
context["day"] = day
period_str = str(year)
if month:
period_str = f"{calendar.month_name[month]} {period_str}"
if week:
period_str = f"Week {week}, {period_str}"
if day:
period_str = f"{calendar.month_name[month]} {day}, {year}"
context["period_str"] = period_str
else:
year = current_year
month = current_month
week = current_week
day = current_day
context["period"] = "current"
context["year"] = current_year
context["month"] = current_month
context["month_name"] = calendar.month_name[current_month]
context["week"] = current_week
context["day"] = current_day
context["chart_keys"] = {
"today": "Today",
"week": "This Week",
@ -126,295 +171,120 @@ class ChartRecordView(TemplateView):
"artist": {
"today": list(
self.get_charts_for_period(
user,
"artist",
year=current_year,
month=current_month,
day=current_day,
user, "artist", year=year, month=month, day=day,
)
),
"week": list(
self.get_charts_for_period(
user,
"artist",
year=current_year,
week=current_week,
user, "artist", year=year, week=week,
)
),
"month": list(
self.get_charts_for_period(
user,
"artist",
year=current_year,
month=current_month,
user, "artist", year=year, month=month,
)
),
"year": list(
self.get_charts_for_period(user, "artist", year=current_year)
self.get_charts_for_period(user, "artist", year=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,
user, "album", year=year, month=month, day=day,
)
),
"week": list(
self.get_charts_for_period(
user, "album", year=current_year, week=current_week
user, "album", year=year, week=week,
)
),
"month": list(
self.get_charts_for_period(
user,
"album",
year=current_year,
month=current_month,
user, "album", year=year, month=month,
)
),
"year": list(
self.get_charts_for_period(user, "album", year=current_year)
self.get_charts_for_period(user, "album", year=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,
user, "tv_series", year=year, month=month, day=day,
)
),
"week": list(
self.get_charts_for_period(
user,
"tv_series",
year=current_year,
week=current_week,
user, "tv_series", year=year, week=week,
)
),
"month": list(
self.get_charts_for_period(
user,
"tv_series",
year=current_year,
month=current_month,
user, "tv_series", year=year, month=month,
)
),
"year": list(
self.get_charts_for_period(user, "tv_series", year=current_year)
self.get_charts_for_period(user, "tv_series", year=year)
),
"all": list(self.get_charts_for_period(user, "tv_series")),
},
}
if not date_param:
context["period"] = "current"
context["year"] = current_year
context["month"] = current_month
context["month_name"] = calendar.month_name[current_month]
context["week"] = current_week
context["day"] = current_day
context["charts"] = {
"artist": list(
self.get_charts_for_period(
user, "artist", year=current_year, limit=20
)
),
"album": list(
self.get_charts_for_period(
user, "album", year=current_year, limit=20
)
),
"track": list(
self.get_charts_for_period(
user, "track", year=current_year, limit=20
)
),
"tv_series": list(
self.get_charts_for_period(
user, "tv_series", year=current_year, limit=20
)
),
"video": list(
self.get_charts_for_period(
user, "video", year=current_year, limit=20
)
),
"board_game": list(
self.get_charts_for_period(
user, "board_game", year=current_year, limit=20
)
),
"book": list(
self.get_charts_for_period(
user, "book", year=current_year, limit=20
)
),
"food": list(
self.get_charts_for_period(
user, "food", year=current_year, limit=20
)
),
"podcast": list(
self.get_charts_for_period(
user, "podcast", year=current_year, limit=20
)
),
"trail": list(
self.get_charts_for_period(
user, "trail", year=current_year, limit=20
)
),
}
else:
parts = date_param.split("-")
year = int(parts[0])
week = None
month = None
day = None
if len(parts) >= 2 and parts[1].startswith("W"):
week = int(parts[1].lstrip("W"))
elif len(parts) >= 2 and parts[1]:
try:
month = int(parts[1])
except ValueError:
pass
if len(parts) >= 3:
if parts[2].startswith("W"):
week = int(parts[2].lstrip("W"))
elif not parts[2].startswith("W"):
day = int(parts[2])
context["period"] = "historical"
context["year"] = year
context["month"] = month
context["month_name"] = calendar.month_name[month] if month else None
context["week"] = week
context["day"] = day
period_str = str(year)
if month:
period_str = f"{calendar.month_name[month]} {period_str}"
if week:
period_str = f"Week {week}, {period_str}"
if day:
period_str = f"{calendar.month_name[month]} {day}, {year}"
context["period_str"] = period_str
context["charts"] = {
"artist": list(
self.get_charts_for_period(
user,
"artist",
year=year,
month=month,
week=week,
day=day,
)
),
"album": list(
self.get_charts_for_period(
user,
"album",
year=year,
month=month,
week=week,
day=day,
)
),
"track": list(
self.get_charts_for_period(
user,
"track",
year=year,
month=month,
week=week,
day=day,
)
),
"tv_series": list(
self.get_charts_for_period(
user,
"tv_series",
year=year,
month=month,
week=week,
day=day,
)
),
"video": list(
self.get_charts_for_period(
user,
"video",
year=year,
month=month,
week=week,
day=day,
)
),
"board_game": list(
self.get_charts_for_period(
user,
"board_game",
year=year,
month=month,
week=week,
day=day,
)
),
"book": list(
self.get_charts_for_period(
user,
"book",
year=year,
month=month,
week=week,
day=day,
)
),
"food": list(
self.get_charts_for_period(
user,
"food",
year=year,
month=month,
week=week,
day=day,
)
),
"podcast": list(
self.get_charts_for_period(
user,
"podcast",
year=year,
month=month,
week=week,
day=day,
)
),
"trail": list(
self.get_charts_for_period(
user,
"trail",
year=year,
month=month,
week=week,
day=day,
)
),
}
context["charts"] = {
"artist": list(
self.get_charts_for_period(
user, "artist", year=year, month=month, week=week, day=day, limit=20
)
),
"album": list(
self.get_charts_for_period(
user, "album", year=year, month=month, week=week, day=day, limit=20
)
),
"track": list(
self.get_charts_for_period(
user, "track", year=year, month=month, week=week, day=day, limit=20
)
),
"tv_series": list(
self.get_charts_for_period(
user, "tv_series", year=year, month=month, week=week, day=day, limit=20
)
),
"video": list(
self.get_charts_for_period(
user, "video", year=year, month=month, week=week, day=day, limit=20
)
),
"board_game": list(
self.get_charts_for_period(
user, "board_game", year=year, month=month, week=week, day=day, limit=20
)
),
"book": list(
self.get_charts_for_period(
user, "book", year=year, month=month, week=week, day=day, limit=20
)
),
"food": list(
self.get_charts_for_period(
user, "food", year=year, month=month, week=week, day=day, limit=20
)
),
"podcast": list(
self.get_charts_for_period(
user, "podcast", year=year, month=month, week=week, day=day, limit=20
)
),
"trail": list(
self.get_charts_for_period(
user, "trail", year=year, month=month, week=week, day=day, limit=20
)
),
}
bird_data = self.get_bird_chart_data(
user,

View File

View File

@ -0,0 +1,14 @@
from discgolf.models import DiscGolfCourse
from django.contrib import admin
from scrobbles.admin import ScrobbleInline
@admin.register(DiscGolfCourse)
class DiscGolfCourseAdmin(admin.ModelAdmin):
date_hierarchy = "created"
list_display = ("title", "layout_name", "number_of_holes", "par_total", "pdga_slug", "udisc_id")
raw_id_fields = ("trail",)
search_fields = ("title", "layout_name")
inlines = [
ScrobbleInline,
]

View File

View File

@ -0,0 +1,14 @@
from discgolf import models
from rest_framework import serializers
class DiscGolfCourseSerializer(serializers.HyperlinkedModelSerializer):
pdga_link = serializers.ReadOnlyField()
udisc_link = serializers.ReadOnlyField()
class Meta:
model = models.DiscGolfCourse
fields = "__all__"

View File

@ -0,0 +1,13 @@
from rest_framework import permissions, viewsets
from discgolf.api import serializers
from discgolf import models
class DiscGolfCourseViewSet(viewsets.ModelViewSet):
queryset = models.DiscGolfCourse.objects.all().order_by("-created")
serializer_class = serializers.DiscGolfCourseSerializer
permission_classes = [permissions.IsAuthenticated]

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class DiscgolfConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "discgolf"

View File

@ -0,0 +1,83 @@
# Generated by Django 4.2.29 on 2026-06-20 04:19
from django.db import migrations, models
import django_extensions.db.fields
import taggit.managers
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
("taggit", "0004_alter_taggeditem_content_type_alter_taggeditem_tag"),
("scrobbles", "0098_scrobble_long_play_last_scrobble"),
]
operations = [
migrations.CreateModel(
name="DiscGolfCourse",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
(
"uuid",
models.UUIDField(
blank=True, default=uuid.uuid4, editable=False, null=True
),
),
("title", models.CharField(blank=True, max_length=255, null=True)),
("base_run_time_seconds", models.IntegerField(blank=True, null=True)),
("description", models.TextField(blank=True, null=True)),
(
"layout_name",
models.CharField(blank=True, max_length=255, null=True),
),
("number_of_holes", models.IntegerField(blank=True, null=True)),
("par_total", models.IntegerField(blank=True, null=True)),
(
"genre",
taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="scrobbles.ObjectWithGenres",
to="scrobbles.Genre",
verbose_name="Genre",
),
),
(
"tags",
taggit.managers.TaggableManager(
blank=True,
help_text="A comma-separated list of tags.",
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="Tags",
),
),
],
options={
"abstract": False,
},
),
]

View File

@ -0,0 +1,25 @@
# Generated by Django 4.2.29 on 2026-06-20 04:29
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("trails", "0009_trail_route_waypoint"),
("discgolf", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="discgolfcourse",
name="trail",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="trails.trail",
),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-06-20 04:35
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("discgolf", "0002_discgolfcourse_trail"),
]
operations = [
migrations.AddField(
model_name="discgolfcourse",
name="par_per_hole",
field=models.JSONField(blank=True, null=True),
),
]

View File

@ -0,0 +1,23 @@
# Generated by Django 4.2.29 on 2026-06-21 04:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("discgolf", "0003_discgolfcourse_par_per_hole"),
]
operations = [
migrations.AddField(
model_name="discgolfcourse",
name="pdga_slug",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="discgolfcourse",
name="udisc_id",
field=models.CharField(blank=True, max_length=255, null=True),
),
]

View File

@ -0,0 +1,90 @@
from dataclasses import dataclass
from typing import Optional, TypedDict
from django.apps import apps
from django.db import models
from django.urls import reverse
from scrobbles.dataclasses import BaseLogData, WithPeopleLogData
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
BNULL = {"blank": True, "null": True}
class DiscGolfSingleScores(TypedDict, total=False):
person_id: int
total: int
class DiscGolfTeamScores(TypedDict, total=False):
person_ids: list[int]
total: int
@dataclass
class DiscGolfLogData(BaseLogData, WithPeopleLogData):
scores: Optional[dict[str, DiscGolfSingleScores | DiscGolfTeamScores]] = None
weather: Optional[str] = None
fun_factor: Optional[str] = None
course_name: Optional[str] = None
par: Optional[int] = None
round_type: Optional[str] = None
class DiscGolfCourse(ScrobblableMixin):
description = models.TextField(**BNULL)
layout_name = models.CharField(max_length=255, **BNULL)
number_of_holes = models.IntegerField(**BNULL)
par_total = models.IntegerField(**BNULL)
par_per_hole = models.JSONField(**BNULL)
trail = models.ForeignKey(
"trails.Trail", on_delete=models.DO_NOTHING, **BNULL
)
pdga_slug = models.CharField(max_length=255, **BNULL)
udisc_id = models.CharField(max_length=255, **BNULL)
@property
def pdga_link(self) -> str:
if self.pdga_slug:
return f"https://www.pdga.com/course-directory/course/{self.pdga_slug}/"
return ""
@property
def udisc_link(self) -> str:
if self.udisc_id:
return f"https://udisc.com/courses/{self.udisc_id}/"
return ""
def get_absolute_url(self) -> str:
return reverse("discgolf:course_detail", kwargs={"slug": self.uuid})
def __str__(self):
return f"{self.title} ({self.layout_name or 'Default'})"
@property
def logdata_cls(self):
return DiscGolfLogData
@property
def subtitle(self):
return self.layout_name or ""
@property
def strings(self) -> ScrobblableConstants:
return ScrobblableConstants(verb="Playing", tags="golf")
@property
def primary_image_url(self) -> str:
return ""
@classmethod
def find_or_create(cls, name: str, **defaults) -> "DiscGolfCourse":
course = cls.objects.filter(title=name).first()
if not course:
course = cls.objects.create(title=name, **defaults)
return course
def scrobbles(self, user_id):
Scrobble = apps.get_model("scrobbles", "Scrobble")
return Scrobble.objects.filter(user_id=user_id, disc_golf_course=self).order_by(
"-timestamp"
)

View File

@ -0,0 +1,14 @@
from django.urls import path
from discgolf import views
app_name = "discgolf"
urlpatterns = [
path("disc-golf/", views.DiscGolfCourseListView.as_view(), name="course_list"),
path(
"disc-golf/<slug:slug>/",
views.DiscGolfCourseDetailView.as_view(),
name="course_detail",
),
]

View File

@ -0,0 +1,129 @@
import csv
import logging
from datetime import datetime
from dateutil.parser import parse as parse_datetime
from django.utils import timezone
from people.models import Person
from scrobbles.models import Scrobble
from scrobbles.notifications import ScrobbleNtfyNotification
logger = logging.getLogger(__name__)
def _parse_udisc_datetime(raw: str) -> datetime:
return parse_datetime(raw)
def _resolve_player(name: str, user_id: int) -> Person:
name = name.strip()
existing = Person.objects.filter(name=name, created_by_id=user_id).first()
if existing:
return existing
return Person.objects.create(name=name, created_by_id=user_id)
def import_udisc_csv(
file_path: str, user_id: int, record_error=None
) -> list[Scrobble]:
from discgolf.models import DiscGolfCourse
new_scrobbles = []
with open(file_path, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
rows = list(reader)
if not rows:
return []
par_row = None
player_rows = []
for row in rows:
name = row.get("PlayerName", "").strip()
if name.lower() == "par":
par_row = row
else:
player_rows.append(row)
if not par_row:
return []
course_name = par_row.get("CourseName", "").strip()
layout_name = par_row.get("LayoutName", "").strip()
start_date_raw = par_row.get("StartDate", "").strip()
start_dt = _parse_udisc_datetime(start_date_raw) if start_date_raw else timezone.now()
number_of_holes = sum(1 for k in par_row if k.startswith("Hole") and k[4:].isdigit())
par_total_str = par_row.get("Total", "").strip()
par_total = int(par_total_str) if par_total_str.isdigit() else None
par_per_hole = {}
for k, v in par_row.items():
if k.startswith("Hole") and k[4:].isdigit() and v:
hole_num = int(k[4:])
try:
par_per_hole[f"hole_{hole_num}"] = int(v)
except (ValueError, TypeError):
pass
course, _ = DiscGolfCourse.objects.get_or_create(
title=course_name,
defaults={
"layout_name": layout_name,
"number_of_holes": number_of_holes,
"par_total": par_total,
"par_per_hole": par_per_hole or None,
},
)
is_teams = "+" in player_rows[0].get("PlayerName", "") if player_rows else False
round_type = "Teams" if is_teams else "Singles"
scores = {}
for row in player_rows:
player_name = row.get("PlayerName", "").strip()
hole_scores = {}
for k, v in row.items():
if k.startswith("Hole") and k[4:].isdigit() and v:
hole_num = int(k[4:])
try:
hole_scores[f"hole_{hole_num}"] = int(v)
except (ValueError, TypeError):
pass
total_str = row.get("Total", "").strip()
total = int(total_str) if total_str.isdigit() else None
if total is not None:
hole_scores["total"] = total
if is_teams:
people = player_name.split("+")
person_ids = [_resolve_player(p.strip(), user_id).id for p in people]
hole_scores["person_ids"] = person_ids
else:
person = _resolve_player(player_name, user_id)
hole_scores["person_id"] = person.id
scores[player_name] = hole_scores
log = {
"scores": scores,
"course_name": course_name,
"par": par_total,
"round_type": round_type,
}
scrobble_dict = {
"user_id": user_id,
"timestamp": start_dt,
"source": "uDisc",
"playback_position_seconds": 0,
"log": log,
}
scrobble = Scrobble.create_or_update(course, user_id, scrobble_dict)
if scrobble:
new_scrobbles.append(scrobble)
ScrobbleNtfyNotification(scrobble).send()
return new_scrobbles

View File

@ -0,0 +1,32 @@
from django.apps import apps
from discgolf.models import DiscGolfCourse
from scrobbles.views import (
ScrobbleableListView,
ScrobbleableDetailView,
ChartContextMixin,
)
class DiscGolfCourseListView(ScrobbleableListView):
model = DiscGolfCourse
class DiscGolfCourseDetailView(ScrobbleableDetailView, ChartContextMixin):
model = DiscGolfCourse
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
Scrobble = apps.get_model("scrobbles", "Scrobble")
context["trail_gpx_url"] = None
latest = (
Scrobble.objects.filter(
trail=self.object.trail, gpx_file__isnull=False
)
.order_by("-timestamp")
.first()
)
if latest and latest.gpx_file:
context["trail_gpx_url"] = latest.gpx_file.url
return context

View File

@ -1,4 +1,7 @@
import time
from django.contrib import admin
from django.http import HttpRequest
from locations.models import GeoLocation
@ -14,9 +17,29 @@ class GeoLocationAdmin(admin.ModelAdmin):
"lon",
"title",
"altitude",
"city",
"state_province",
"country",
)
ordering = ("-created",)
search_fields = ("title",)
actions = ["reverse_geocode_selected"]
inlines = [
ScrobbleInline,
]
@admin.action(description="Reverse geocode selected locations")
def reverse_geocode_selected(self, request: HttpRequest, queryset):
updated = 0
errors = 0
for i, location in enumerate(queryset.iterator()):
if location.reverse_geocode():
updated += 1
else:
errors += 1
if i < queryset.count() - 1:
time.sleep(1.1)
msg = f"Reverse geocoded {updated} locations"
if errors:
msg += f", {errors} failed"
self.message_user(request, msg)

View File

@ -0,0 +1,38 @@
# Generated by Django 4.2.29 on 2026-06-21 04:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("locations", "0010_clean_start"),
]
operations = [
migrations.AddField(
model_name="geolocation",
name="city",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="geolocation",
name="country",
field=models.CharField(blank=True, max_length=100, null=True),
),
migrations.AddField(
model_name="geolocation",
name="postal_code",
field=models.CharField(blank=True, max_length=20, null=True),
),
migrations.AddField(
model_name="geolocation",
name="state_province",
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name="geolocation",
name="street",
field=models.TextField(blank=True, null=True),
),
]

View File

@ -45,6 +45,11 @@ class GeoLocation(ScrobblableMixin):
truncated_lat = models.FloatField(**BNULL)
truncated_lon = models.FloatField(**BNULL)
altitude = models.FloatField(**BNULL)
street = models.TextField(**BNULL)
city = models.CharField(max_length=255, **BNULL)
state_province = models.CharField(max_length=255, **BNULL)
postal_code = models.CharField(max_length=20, **BNULL)
country = models.CharField(max_length=100, **BNULL)
class Meta:
unique_together = [["lat", "lon", "altitude"]]
@ -55,6 +60,11 @@ class GeoLocation(ScrobblableMixin):
return f"{self.lat} x {self.lon}"
@property
def display_address(self) -> str:
parts = [self.street, self.city, self.state_province, self.postal_code, self.country]
return ", ".join(p for p in parts if p)
def get_absolute_url(self):
return reverse("locations:geolocation_detail", kwargs={"slug": self.uuid})
@ -121,6 +131,17 @@ class GeoLocation(ScrobblableMixin):
return fetch_current_weather(self.lat, self.lon)
def reverse_geocode(self) -> bool:
from locations.utils import reverse_geocode
result = reverse_geocode(self.lat, self.lon)
if result is None:
return False
for field, value in result.items():
setattr(self, field, value)
self.save(update_fields=list(result.keys()))
return True
def loc_diff(self, old_lat_lon: tuple) -> tuple:
return (
abs(Decimal(old_lat_lon[0]) - Decimal(self.lat)),

View File

@ -201,6 +201,50 @@ def detect_movement(
return result
NOMINATIM_URL = "https://nominatim.openstreetmap.org/reverse"
USER_AGENT = "Vrobbler/1.0 (https://github.com/secstate/vrobbler)"
def reverse_geocode(lat: float, lon: float) -> Optional[dict]:
"""Reverse geocode lat/lon to an address using Nominatim.
Returns a dict with address fields, or None on failure.
Nominatim usage policy: max 1 request per second.
"""
params = {
"lat": lat,
"lon": lon,
"format": "json",
}
headers = {"User-Agent": USER_AGENT}
try:
resp = requests.get(NOMINATIM_URL, params=params, headers=headers, timeout=10)
resp.raise_for_status()
except requests.RequestException as e:
logger.warning("Failed to reverse geocode %s,%s: %s", lat, lon, e)
return None
data = resp.json()
if "error" in data:
logger.warning("Nominatim error for %s,%s: %s", lat, lon, data["error"])
return None
address = data.get("address", {})
return {
"street": address.get("road")
or address.get("pedestrian")
or address.get("footway"),
"city": address.get("city")
or address.get("town")
or address.get("village")
or address.get("hamlet"),
"state_province": address.get("state"),
"postal_code": address.get("postcode"),
"country": address.get("country"),
}
NWS_URL = "https://forecast.weather.gov/MapClick.php"

View File

@ -1,8 +1,22 @@
from django.core.cache import cache
from music.models import Artist, Album
CACHE_TTL = 300
def music_lists(request):
artist_list = cache.get("music_lists_artist_list")
if artist_list is None:
artist_list = list(Artist.objects.all().only("id", "name"))
cache.set("music_lists_artist_list", artist_list, CACHE_TTL)
album_list = cache.get("music_lists_album_list")
if album_list is None:
album_list = list(Album.objects.all().only("id", "name"))
cache.set("music_lists_album_list", album_list, CACHE_TTL)
return {
"artist_list": Artist.objects.all(),
"album_list": Album.objects.all(),
"artist_list": artist_list,
"album_list": album_list,
}

View File

@ -236,19 +236,6 @@ class Artist(TimeStampedModel):
)
artist.fix_metadata()
# TODO: See if this alt_names stuff actually works or causes hard to debug problems
# If we did find our artist, but the found name is slightly differnt, record that
# if artist and alt_name:
# if not artist.alt_names:
# artist.alt_names = alt_name
# else:
# artist.alt_names += f"\\{alt_name}"
# logger.info(
# f"Add alt_name {alt_name} to artist {artist}",
# extra={"alt_name": alt_name, "artist_id": artist.id},
# )
# artist.save(update_fields=["alt_names"])
return artist

View File

@ -40,7 +40,9 @@ class UserProfileForm(forms.ModelForm):
"enable_public_widgets",
"widget_custom_css",
"home_scrobble_limit",
"live_now_playing",
"weigh_in_units",
"trends_disabled",
]
widgets = {
"lastfm_password": forms.PasswordInput(render_value=True),

View File

@ -0,0 +1,18 @@
# Generated by Django 4.2.29 on 2026-06-22 02:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0038_userprofile_media_type_visibility"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="live_now_playing",
field=models.BooleanField(default=False),
),
]

View File

@ -0,0 +1,27 @@
# Generated by Django 4.2.29 on 2026-06-25 20:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("profiles", "0039_userprofile_live_now_playing"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="disabled_trends",
field=models.JSONField(
blank=True,
default=list,
help_text="List of trend slugs the user has disabled",
),
),
migrations.AddField(
model_name="userprofile",
name="trends_disabled",
field=models.BooleanField(default=False),
),
]

View File

@ -98,12 +98,22 @@ class UserProfile(TimeStampedModel):
home_scrobble_limit = models.IntegerField(default=20)
live_now_playing = models.BooleanField(default=False)
weigh_in_units = models.CharField(
max_length=16,
choices=WeighUnit.choices,
default=WeighUnit.METRIC,
)
trends_disabled = models.BooleanField(default=False)
disabled_trends = models.JSONField(
default=list,
blank=True,
help_text="List of trend slugs the user has disabled",
)
def __str__(self):
return f"User profile for {self.user}"

View File

@ -12,6 +12,7 @@ from scrobbles.models import (
Scrobble,
ShareViewLog,
TrailGPXImport,
UDiscCSVImport,
)
from scrobbles.mixins import Genre
@ -73,6 +74,10 @@ class ScaleCSVImportAdmin(ImportBaseAdmin): ...
class TrailGPXImportAdmin(ImportBaseAdmin): ...
@admin.register(UDiscCSVImport)
class UDiscCSVImportAdmin(ImportBaseAdmin): ...
@admin.register(Genre)
class GenreAdmin(admin.ModelAdmin):
list_display = (
@ -118,6 +123,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
"web_page",
"life_event",
"birding_location",
"disc_golf_course",
"long_play_last_scrobble",
)
list_filter = (
@ -179,4 +185,5 @@ class FavoriteMediaAdmin(admin.ModelAdmin):
"web_page",
"life_event",
"birding_location",
"disc_golf_course",
)

View File

@ -14,6 +14,7 @@ LONG_PLAY_MEDIA = {
"books": "Book",
"bricksets": "BrickSet",
"tasks": "Task",
"papers": "Paper",
}
# Media types that should just be finished if they go over time
@ -25,17 +26,26 @@ AUTO_FINISH_MEDIA = {
}
PLAY_AGAIN_MEDIA = {
"videogames": "VideoGame",
"videos": "Video",
"music": "Track",
"podcasts": "PodcastEpisode",
"sports": "SportEvent",
"books": "Book",
"videogames": "VideoGame",
"boardgames": "BoardGame",
"moods": "Mood",
"bricksets": "BrickSet",
"locations": "GeoLocation",
"trails": "Trail",
"beers": "Beer",
"puzzles": "Puzzle",
"foods": "Food",
"locations": "GeoLocation",
"videos": "Video",
"tasks": "Task",
"webpages": "WebPage",
"lifeevents": "LifeEvent",
"moods": "Mood",
"bricksets": "BrickSet",
"channels": "Channel",
"birds": "BirdingLocation",
"discgolf": "DiscGolfCourse",
}
MEDIA_END_PADDING_SECONDS = {
@ -52,6 +62,7 @@ SCROBBLE_CONTENT_URLS = {
"-b": ["https://www.amazon.com/"],
"-t": ["https://app.todoist.com/app/task/{id}"],
"-p": ["https://www.ipdb.plus/IPDb/puzzle.php?id="],
"-pp": ["https://doi.org/"],
"-l": ["https://brickset.com/sets/"],
"-c": ["https://readcomicsonline.ru"],
"-h": ["https://www.twitch.tv/"],
@ -73,6 +84,8 @@ MANUAL_SCROBBLE_FNS = {
"-c": "manual_scrobble_book",
"-f": "manual_scrobble_food",
"-h": "manual_scrobble_twitch_channel",
"-dg": "manual_scrobble_discgolf",
"-pp": "manual_scrobble_paper",
}

View File

@ -27,15 +27,19 @@ def month_color(request):
def now_playing(request):
user = request.user
now = timezone.now()
if not user.is_authenticated:
return {}
return {
"now_playing_list": Scrobble.objects.filter(
in_progress=True,
is_paused=False,
user=user,
).exclude(
media_type__in=EXCLUDE_FROM_NOW_PLAYING,
)
"now_playing_list": list(
Scrobble.objects.filter(
in_progress=True,
is_paused=False,
user=user,
)
.exclude(
media_type__in=EXCLUDE_FROM_NOW_PLAYING,
)
.select_related("track", "video", "podcast_episode")
),
}

View File

@ -19,6 +19,7 @@ DEFAULT_RETROARCH_PATH = "var/retroarch/"
DEFAULT_BGSTATS_PATH = "var/bgstats/"
DEFAULT_EBIRD_PATH = "var/ebird/"
DEFAULT_SCALE_PATH = "var/scale/"
DEFAULT_UDISC_PATH = "var/udisc/"
def import_from_webdav_for_all_users(
@ -48,6 +49,7 @@ def import_from_webdav_for_all_users(
bgstats_count = 0
ebird_count = 0
scale_count = 0
udisc_count = 0
for user_id in webdav_enabled_user_ids:
client = get_webdav_client(user_id)
@ -78,15 +80,20 @@ def import_from_webdav_for_all_users(
)
logger.info("Scanning WebDAV scale for user %s", user_id)
scale_count += scan_webdav_for_scale(client, user_id)
logger.info("Scanning WebDAV udisc for user %s", user_id)
udisc_count += scan_webdav_for_udisc(
client, user_id, include_processed=include_processed
)
logger.info(
"Started %d KOReader, %d Trail GPX, %d Retroarch, %d BGStats, %d eBird, %d Scale WebDAV imports",
"Started %d KOReader, %d Trail GPX, %d Retroarch, %d BGStats, %d eBird, %d Scale, %d uDisc WebDAV imports",
ko_count,
gpx_count,
retro_count,
bgstats_count,
ebird_count,
scale_count,
udisc_count,
extra={
"koreader": ko_count,
"trail_gpx": gpx_count,
@ -94,9 +101,10 @@ def import_from_webdav_for_all_users(
"bgstats": bgstats_count,
"ebird": ebird_count,
"scale": scale_count,
"udisc": udisc_count,
},
)
return ko_count, gpx_count, retro_count, bgstats_count, ebird_count, scale_count
return ko_count, gpx_count, retro_count, bgstats_count, ebird_count, scale_count, udisc_count
def scan_webdav_for_koreader(
@ -811,3 +819,115 @@ def scan_webdav_for_scale(webdav_client, user_id):
os.unlink(tmp.name)
return new_imports
def scan_webdav_for_udisc(webdav_client, user_id, include_processed=False):
"""Download .csv files from WebDAV var/udisc/ and queue imports for new files.
After importing, files are moved to var/udisc/processed/ so they are
not re-imported on subsequent scans unless *include_processed* is True.
"""
from scrobbles.models import UDiscCSVImport
from scrobbles.tasks import process_udisc_csv_import
udisc_path = DEFAULT_UDISC_PATH
try:
webdav_client.info(udisc_path)
except:
logger.info("No var/udisc/ directory on webdav", extra={"user_id": user_id})
return 0
try:
files = webdav_client.list(udisc_path)
except Exception as e:
logger.warning(
"Could not list var/udisc/",
extra={"user_id": user_id, "error": str(e)},
)
return 0
processed_dir = f"{udisc_path}processed/"
try:
webdav_client.mkdir(processed_dir, recursive=True)
except Exception:
pass
new_imports = 0
already_imported = set(
UDiscCSVImport.objects.filter(user_id=user_id).values_list(
"original_filename", flat=True
)
)
for fname in files:
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
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname)
try:
webdav_client.download_sync(
remote_path=f"{udisc_path}{fname}", local_path=tmp.name
)
imp = UDiscCSVImport.objects.create(
user_id=user_id,
original_filename=fname,
)
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"{udisc_path}{fname}",
f"{processed_dir}{stem}_{ts}{ext}",
)
process_udisc_csv_import.delay(imp.id)
new_imports += 1
except Exception as e:
logger.error(f"Failed to import uDisc CSV file {fname}: {e}")
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/udisc/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 = UDiscCSVImport.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_udisc_csv_import.delay(imp.id)
new_imports += 1
except Exception as e:
logger.error(
f"Failed to import processed uDisc CSV file {fname}: {e}"
)
finally:
os.unlink(tmp.name)
return new_imports

View File

@ -0,0 +1,518 @@
from mcp_server import MCPToolset
from scrobbles.models import Scrobble
from scrobbles.constants import LONG_PLAY_MEDIA
class ScrobbleToolset(MCPToolset):
def list_recent_scrobbles(
self,
days: int = 7,
media_type: str | None = None,
limit: int = 50,
) -> list[dict]:
"""List scrobbles from the last N days, optionally filtered by media type.
Valid media_type values: Video, Track, PodcastEpisode, SportEvent, Book,
Paper, VideoGame, BoardGame, GeoLocation, Trail, Beer, Puzzle, Food, Task,
WebPage, LifeEvent, Mood, BrickSet, Channel, BirdingLocation, DiscGolfCourse
"""
qs = (
Scrobble.objects.filter(user=self.request.user)
.select_related(
"video", "track", "book", "video_game", "board_game",
"beer", "puzzle", "food", "trail", "task", "web_page",
"life_event", "mood", "brick_set", "podcast_episode",
"sport_event", "geo_location", "birding_location",
"disc_golf_course", "channel",
)
.order_by("-timestamp")
)
from django.utils import timezone
import datetime
qs = qs.filter(timestamp__gte=timezone.now() - datetime.timedelta(days=days))
if media_type:
qs = qs.filter(media_type=media_type)
qs = qs[:limit]
return [_scrobble_to_dict(s) for s in qs]
def get_scrobble(self, uuid: str) -> dict | None:
"""Get a single scrobble by its UUID."""
try:
s = Scrobble.objects.filter(user=self.request.user).get(uuid=uuid)
except Scrobble.DoesNotExist:
return None
return _scrobble_to_dict(s)
def search_scrobbles(
self, query: str, media_type: str | None = None, limit: int = 20
) -> list[dict]:
"""Search scrobbles by text in their log data or related media titles."""
from django.db.models import Q
qs = Scrobble.objects.filter(user=self.request.user).order_by("-timestamp")
if media_type:
qs = qs.filter(media_type=media_type)
qs = qs.filter(
Q(log__icontains=query)
| Q(video__title__icontains=query)
| Q(track__title__icontains=query)
| Q(book__title__icontains=query)
| Q(video_game__title__icontains=query)
| Q(board_game__title__icontains=query)
| Q(beer__title__icontains=query)
| Q(food__title__icontains=query)
| Q(trail__title__icontains=query)
| Q(task__title__icontains=query)
| Q(web_page__title__icontains=query)
| Q(life_event__title__icontains=query)
| Q(puzzle__title__icontains=query)
| Q(brick_set__title__icontains=query)
| Q(podcast_episode__title__icontains=query)
)[:limit]
return [_scrobble_to_dict(s) for s in qs]
def get_scrobbles_by_date(
self, date: str, media_type: str | None = None
) -> list[dict]:
"""Get scrobbles for a specific date (YYYY-MM-DD format)."""
import datetime
try:
dt = datetime.datetime.strptime(date, "%Y-%m-%d").date()
except ValueError:
return []
qs = Scrobble.objects.filter(
user=self.request.user,
timestamp__date=dt,
).order_by("-timestamp")
if media_type:
qs = qs.filter(media_type=media_type)
return [_scrobble_to_dict(s) for s in qs]
def get_in_progress_scrobbles(
self, media_type: str | None = None
) -> list[dict]:
"""Get scrobbles currently in progress (started but not finished).
These are long-play items like books, video games, brick sets, or tasks."""
qs = Scrobble.objects.filter(
user=self.request.user,
in_progress=True,
).order_by("-timestamp")
if media_type:
qs = qs.filter(media_type=media_type)
return [_scrobble_to_dict(s) for s in qs]
def get_long_play_scrobbles(
self, status: str = "in_progress", media_type: str | None = None
) -> list[dict]:
"""Get long-play scrobbles (books, video games, brick sets, tasks).
Status can be 'in_progress' or 'completed'."""
types = list(LONG_PLAY_MEDIA.values())
qs = Scrobble.objects.filter(
user=self.request.user,
media_type__in=types,
).order_by("-timestamp")
if media_type:
qs = qs.filter(media_type=media_type)
if status == "in_progress":
qs = qs.filter(in_progress=True)
elif status == "completed":
qs = qs.filter(in_progress=False)
return [_scrobble_to_dict(s) for s in qs]
class MediaToolset(MCPToolset):
def get_book(self, uuid: str) -> dict | None:
"""Get a book by UUID."""
from books.models import Book
try:
b = Book.objects.get(uuid=uuid)
except Book.DoesNotExist:
return None
return _media_to_dict(b, fields=["title", "pages", "language",
"first_publish_year", "isbn_13",
"publisher", "summary"])
def list_books(self, author: str | None = None, limit: int = 20) -> list[dict]:
"""List books, optionally filtered by author name."""
from books.models import Book
qs = Book.objects.all().order_by("title")
if author:
qs = qs.filter(authors__name__icontains=author)
return [_media_to_dict(b, fields=["title", "pages", "language",
"first_publish_year", "isbn_13",
"publisher"]) for b in qs[:limit]]
def get_track(self, uuid: str) -> dict | None:
"""Get a music track by UUID."""
from music.models import Track
try:
t = Track.objects.select_related("artist_fk").get(uuid=uuid)
except Track.DoesNotExist:
return None
return _media_to_dict(t, fields=["title", "base_run_time_seconds",
"artist_fk__name", "genre"])
def list_tracks(self, artist: str | None = None, limit: int = 20) -> list[dict]:
"""List music tracks, optionally filtered by artist name."""
from music.models import Track
qs = Track.objects.select_related("artist_fk").all().order_by("title")
if artist:
qs = qs.filter(artist_fk__name__icontains=artist)
return [_media_to_dict(t, fields=["title", "base_run_time_seconds",
"artist_fk__name", "genre"])
for t in qs[:limit]]
def get_video(self, uuid: str) -> dict | None:
"""Get a video by UUID."""
from videos.models import Video
try:
v = Video.objects.select_related("tv_series", "channel").get(uuid=uuid)
except Video.DoesNotExist:
return None
return _media_to_dict(v, fields=["title", "year", "overview",
"imdb_id", "imdb_rating",
"tv_series__name", "channel__title",
"season_number", "episode_number"])
def list_videos(self, series: str | None = None, limit: int = 20) -> list[dict]:
"""List videos, optionally filtered by series name."""
from videos.models import Video
qs = Video.objects.select_related("tv_series", "channel").all().order_by("title")
if series:
qs = qs.filter(tv_series__name__icontains=series)
return [_media_to_dict(v, fields=["title", "year", "overview",
"tv_series__name", "channel__title",
"season_number", "episode_number"])
for v in qs[:limit]]
def get_board_game(self, uuid: str) -> dict | None:
"""Get a board game by UUID."""
from boardgames.models import BoardGame
try:
bg = BoardGame.objects.get(uuid=uuid)
except BoardGame.DoesNotExist:
return None
return _media_to_dict(bg, fields=["title", "genre"])
def list_board_games(self, limit: int = 20) -> list[dict]:
"""List board games."""
from boardgames.models import BoardGame
qs = BoardGame.objects.all().order_by("title")[:limit]
return [_media_to_dict(bg, fields=["title", "genre"]) for bg in qs]
def get_podcast_episode(self, uuid: str) -> dict | None:
"""Get a podcast episode by UUID."""
from podcasts.models import PodcastEpisode
try:
pe = PodcastEpisode.objects.select_related("podcast", "producer").get(
uuid=uuid
)
except PodcastEpisode.DoesNotExist:
return None
return _media_to_dict(pe, fields=["title", "podcast__title",
"producer__name", "base_run_time_seconds"])
def get_beer(self, uuid: str) -> dict | None:
"""Get a beer by UUID."""
from beers.models import Beer
try:
b = Beer.objects.select_related("style", "producer").get(uuid=uuid)
except Beer.DoesNotExist:
return None
return _media_to_dict(b, fields=["title", "style__name",
"producer__name", "abv"])
def get_brick_set(self, uuid: str) -> dict | None:
"""Get a brick set (LEGO) by UUID."""
from bricksets.models import BrickSet
try:
bs = BrickSet.objects.get(uuid=uuid)
except BrickSet.DoesNotExist:
return None
return _media_to_dict(bs, fields=["title", "piece_count", "set_number"])
def get_video_game(self, uuid: str) -> dict | None:
"""Get a video game by UUID."""
from videogames.models import VideoGame
try:
vg = VideoGame.objects.get(uuid=uuid)
except VideoGame.DoesNotExist:
return None
return _media_to_dict(vg, fields=["title", "genre",
"base_run_time_seconds"])
def get_puzzle(self, uuid: str) -> dict | None:
"""Get a puzzle by UUID."""
from puzzles.models import Puzzle
try:
p = Puzzle.objects.select_related("manufacturer").get(uuid=uuid)
except Puzzle.DoesNotExist:
return None
return _media_to_dict(p, fields=["title", "piece_count",
"manufacturer__name"])
def get_web_page(self, uuid: str) -> dict | None:
"""Get a web page by UUID."""
from webpages.models import WebPage
try:
wp = WebPage.objects.select_related("domain").get(uuid=uuid)
except WebPage.DoesNotExist:
return None
return _media_to_dict(wp, fields=["title", "url", "domain__name"])
def get_task(self, uuid: str) -> dict | None:
"""Get a task by UUID."""
from tasks.models import Task
try:
t = Task.objects.get(uuid=uuid)
except Task.DoesNotExist:
return None
return _media_to_dict(t, fields=["title", "completed"])
def get_trail(self, uuid: str) -> dict | None:
"""Get a trail by UUID."""
from trails.models import Trail
try:
t = Trail.objects.get(uuid=uuid)
except Trail.DoesNotExist:
return None
return _media_to_dict(t, fields=["title", "genre", "base_run_time_seconds"])
def get_geo_location(self, uuid: str) -> dict | None:
"""Get a geo location by UUID."""
from locations.models import GeoLocation
try:
gl = GeoLocation.objects.get(uuid=uuid)
except GeoLocation.DoesNotExist:
return None
return _media_to_dict(gl, fields=["title", "latitude", "longitude"])
def get_life_event(self, uuid: str) -> dict | None:
"""Get a life event by UUID."""
from lifeevents.models import LifeEvent
try:
le = LifeEvent.objects.get(uuid=uuid)
except LifeEvent.DoesNotExist:
return None
return _media_to_dict(le, fields=["title", "event_date", "genre"])
def get_mood(self, uuid: str) -> dict | None:
"""Get a mood entry by UUID."""
from moods.models import Mood
try:
m = Mood.objects.get(uuid=uuid)
except Mood.DoesNotExist:
return None
return _media_to_dict(m, fields=["title", "mood_type", "mood_quality"])
def get_food(self, uuid: str) -> dict | None:
"""Get a food entry by UUID."""
from foods.models import Food
try:
f = Food.objects.select_related("category").get(uuid=uuid)
except Food.DoesNotExist:
return None
return _media_to_dict(f, fields=["title", "category__name"])
def get_bird_sighting(self, uuid: str) -> dict | None:
"""Get a bird sighting by UUID."""
from birds.models import BirdSighting
try:
bs = BirdSighting.objects.select_related("bird").get(uuid=uuid)
except BirdSighting.DoesNotExist:
return None
return _media_to_dict(bs, fields=["title", "bird__common_name",
"bird__scientific_name", "location"])
def get_disc_golf_course(self, uuid: str) -> dict | None:
"""Get a disc golf course by UUID."""
from discgolf.models import DiscGolfCourse
try:
dg = DiscGolfCourse.objects.get(uuid=uuid)
except DiscGolfCourse.DoesNotExist:
return None
return _media_to_dict(dg, fields=["title", "holes", "location"])
class StatsToolset(MCPToolset):
def get_scrobble_counts(self, days: int = 30) -> list[dict]:
"""Get scrobble counts grouped by media type for the last N days."""
from django.utils import timezone
import datetime
from django.db.models import Count
cutoff = timezone.now() - datetime.timedelta(days=days)
qs = (
Scrobble.objects.filter(user=self.request.user, timestamp__gte=cutoff)
.values("media_type")
.annotate(count=Count("id"))
.order_by("-count")
)
return list(qs)
def get_top_media(
self, media_type: str, days: int = 30, limit: int = 10
) -> list[dict]:
"""Get the most-scrobbled items of a given media type in the last N days.
Valid media_type values: Video, Track, Book, BoardGame, Beer, etc."""
from django.utils import timezone
import datetime
from django.db.models import Count
cutoff = timezone.now() - datetime.timedelta(days=days)
rel_field = _media_type_to_rel_field(media_type)
if not rel_field:
return []
qs = (
Scrobble.objects.filter(
user=self.request.user,
media_type=media_type,
timestamp__gte=cutoff,
)
.values(rel_field)
.annotate(count=Count("id"))
.order_by("-count")
)[:limit]
results = []
for row in qs:
obj_id = row[rel_field]
if obj_id is None:
continue
results.append({"id": obj_id, "count": row["count"]})
return results
def _media_type_to_rel_field(media_type: str) -> str | None:
mapping = {
"Video": "video",
"Track": "track",
"PodcastEpisode": "podcast_episode",
"SportEvent": "sport_event",
"Book": "book",
"Paper": "paper",
"VideoGame": "video_game",
"BoardGame": "board_game",
"GeoLocation": "geo_location",
"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",
"DiscGolfCourse": "disc_golf_course",
}
return mapping.get(media_type)
def _scrobble_to_dict(s: Scrobble) -> dict:
result = {
"uuid": str(s.uuid),
"media_type": s.media_type,
"timestamp": s.timestamp.isoformat() if s.timestamp else None,
"stop_timestamp": s.stop_timestamp.isoformat() if s.stop_timestamp else None,
"in_progress": s.in_progress,
"played_to_completion": s.played_to_completion,
"source": s.source,
"visibility": s.visibility,
"timezone": s.timezone,
}
if s.log:
result["log"] = s.log
rel = _scrobble_related_to_dict(s)
if rel:
result["media"] = rel
return result
def _scrobble_related_to_dict(s: Scrobble) -> dict | None:
if s.video:
return _media_to_dict(s.video, fields=["title", "year", "imdb_id",
"imdb_rating"])
if s.track:
return _media_to_dict(s.track, fields=["title",
"base_run_time_seconds"])
if s.book:
return _media_to_dict(s.book, fields=["title", "pages"])
if s.video_game:
return _media_to_dict(s.video_game, fields=["title",
"base_run_time_seconds"])
if s.board_game:
return _media_to_dict(s.board_game, fields=["title"])
if s.beer:
return _media_to_dict(s.beer, fields=["title"])
if s.puzzle:
return _media_to_dict(s.puzzle, fields=["title", "piece_count"])
if s.food:
return _media_to_dict(s.food, fields=["title"])
if s.trail:
return _media_to_dict(s.trail, fields=["title",
"base_run_time_seconds"])
if s.task:
return _media_to_dict(s.task, fields=["title"])
if s.web_page:
return _media_to_dict(s.web_page, fields=["title", "url"])
if s.life_event:
return _media_to_dict(s.life_event, fields=["title", "event_date"])
if s.mood:
return _media_to_dict(s.mood, fields=["title"])
if s.brick_set:
return _media_to_dict(s.brick_set, fields=["title", "set_number"])
if s.podcast_episode:
return _media_to_dict(s.podcast_episode, fields=["title"])
if s.sport_event:
return {"title": str(s.sport_event)}
if s.geo_location:
return _media_to_dict(s.geo_location, fields=["title", "latitude",
"longitude"])
if s.birding_location:
return _media_to_dict(s.birding_location, fields=["title"])
if s.disc_golf_course:
return _media_to_dict(s.disc_golf_course, fields=["title", "holes"])
if s.channel:
return _media_to_dict(s.channel, fields=["title"])
return None
def _media_to_dict(obj, fields: list[str] | None = None) -> dict:
if obj is None:
return {}
result = {}
if hasattr(obj, "uuid"):
result["uuid"] = str(obj.uuid)
if hasattr(obj, "title"):
result["title"] = obj.title
if fields is None:
return result
resolved = _resolve_fields(obj, fields)
for k, v in resolved.items():
if k not in result:
result[k] = v
return result
def _resolve_fields(obj, fields: list[str]) -> dict:
result = {}
for field in fields:
parts = field.split("__")
val = obj
try:
for part in parts:
val = getattr(val, part)
except AttributeError:
continue
if val is not None:
if hasattr(val, "all"):
val = [str(v) for v in val.all()]
result[field] = val
return result

View File

@ -0,0 +1,156 @@
# Generated by Django 4.2.29 on 2026-06-20 04:19
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django_extensions.db.fields
import scrobbles.models
import uuid
class Migration(migrations.Migration):
dependencies = [
("discgolf", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("scrobbles", "0098_scrobble_long_play_last_scrobble"),
]
operations = [
migrations.AddField(
model_name="favoritemedia",
name="disc_golf",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="discgolf.discgolfcourse",
),
),
migrations.AddField(
model_name="scrobble",
name="disc_golf",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
to="discgolf.discgolfcourse",
),
),
migrations.AlterField(
model_name="favoritemedia",
name="media_type",
field=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"),
("DiscGolf", "Disc golf"),
],
max_length=20,
),
),
migrations.AlterField(
model_name="scrobble",
name="media_type",
field=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"),
("DiscGolf", "Disc golf"),
],
default="Video",
max_length=20,
),
),
migrations.CreateModel(
name="UDiscCSVImport",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"created",
django_extensions.db.fields.CreationDateTimeField(
auto_now_add=True, verbose_name="created"
),
),
(
"modified",
django_extensions.db.fields.ModificationDateTimeField(
auto_now=True, verbose_name="modified"
),
),
("uuid", models.UUIDField(default=uuid.uuid4, editable=False)),
("processing_started", models.DateTimeField(blank=True, null=True)),
("processed_finished", models.DateTimeField(blank=True, null=True)),
("process_log", models.TextField(blank=True, null=True)),
("process_count", models.IntegerField(blank=True, null=True)),
("error_log", models.TextField(blank=True, null=True)),
(
"csv_file",
models.FileField(
blank=True,
null=True,
upload_to=scrobbles.models.UDiscCSVImport.get_path,
),
),
(
"original_filename",
models.CharField(blank=True, max_length=255, null=True),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.DO_NOTHING,
related_name="scrobbles_udisccsvimport_set",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "uDisc CSV Import",
},
),
]

View File

@ -0,0 +1,84 @@
# Generated by Django 4.2.29 on 2026-06-20 04:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("scrobbles", "0099_favoritemedia_disc_golf_scrobble_disc_golf_and_more"),
]
operations = [
migrations.RenameField(
model_name="favoritemedia",
old_name="disc_golf",
new_name="disc_golf_course",
),
migrations.RenameField(
model_name="scrobble",
old_name="disc_golf",
new_name="disc_golf_course",
),
migrations.AlterField(
model_name="favoritemedia",
name="media_type",
field=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"),
("DiscGolfCourse", "Disc golf"),
],
max_length=20,
),
),
migrations.AlterField(
model_name="scrobble",
name="media_type",
field=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"),
("DiscGolfCourse", "Disc golf"),
],
default="Video",
max_length=20,
),
),
]

View File

@ -11,6 +11,7 @@ import pendulum
import pytz
from beers.models import Beer
from birds.models import BirdingLocation
from discgolf.models import DiscGolfCourse
from boardgames.models import BoardGame
from books.koreader import process_koreader_sqlite_file
from books.models import Book, BookLogData, BookPageLogData, Paper
@ -617,6 +618,60 @@ class EBirdCSVImport(BaseFileImportMixin):
self.mark_finished()
class UDiscCSVImport(BaseFileImportMixin):
class Meta:
verbose_name = "uDisc CSV Import"
user = models.ForeignKey(
User,
on_delete=models.DO_NOTHING,
**BNULL,
related_name="scrobbles_udisccsvimport_set",
)
@property
def import_type(self) -> str:
return "uDisc"
def get_absolute_url(self):
return reverse("scrobbles:udisc-csv-import-detail", kwargs={"slug": self.uuid})
def get_path(instance, filename):
extension = filename.split(".")[-1]
uuid = instance.uuid
return f"udisc-csv-uploads/{uuid}.{extension}"
@property
def upload_file_path(self):
if getattr(settings, "USE_S3_STORAGE"):
path = self.csv_file.url
else:
path = self.csv_file.path
return path
csv_file = models.FileField(upload_to=get_path, **BNULL)
original_filename = models.CharField(max_length=255, **BNULL)
def process(self, force=False):
from discgolf.utils import import_udisc_csv
if self.processed_finished and not force:
logger.info(f"{self} already processed on {self.processed_finished}")
return
self.mark_started()
try:
scrobbles = import_udisc_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()
TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = {
"Video": ("video",),
"Track": ("track", "track__artist_fk"),
@ -638,6 +693,7 @@ TYPE_FK_PREFETCHES: dict[str, tuple[str, ...]] = {
"BrickSet": ("brick_set",),
"Channel": ("channel",),
"BirdingLocation": ("birding_location",),
"DiscGolfCourse": ("disc_golf_course",),
}
@ -665,6 +721,7 @@ class ScrobbleQuerySet(models.QuerySet):
"mood",
"brick_set",
"birding_location",
"disc_golf_course",
)
def with_related_for_types(self, media_types: list[str]):
@ -715,6 +772,7 @@ class Scrobble(TimeStampedModel):
BRICKSET = "BrickSet", "Brick set"
CHANNEL = "Channel", "Channel"
BIRDING_LOCATION = "BirdingLocation", "Birding location"
DISC_GOLF = "DiscGolfCourse", "Disc golf"
@classmethod
def list(cls):
@ -745,6 +803,9 @@ class Scrobble(TimeStampedModel):
birding_location = models.ForeignKey(
BirdingLocation, on_delete=models.DO_NOTHING, **BNULL
)
disc_golf_course = models.ForeignKey(
DiscGolfCourse, on_delete=models.DO_NOTHING, **BNULL
)
media_type = models.CharField(
max_length=20, choices=MediaType.choices, default=MediaType.VIDEO
)
@ -1316,6 +1377,10 @@ class Scrobble(TimeStampedModel):
media_obj = self.channel
if self.birding_location:
media_obj = self.birding_location
if self.paper:
media_obj = self.paper
if self.disc_golf_course:
media_obj = self.disc_golf_course
return media_obj
def __str__(self):
@ -1874,6 +1939,9 @@ class FavoriteMedia(TimeStampedModel):
birding_location = models.ForeignKey(
BirdingLocation, on_delete=models.CASCADE, **BNULL
)
disc_golf_course = models.ForeignKey(
DiscGolfCourse, on_delete=models.CASCADE, **BNULL
)
media_type = models.CharField(max_length=20, choices=Scrobble.MediaType.choices)
sent_to_mopidy = models.BooleanField(default=False)
@ -1924,6 +1992,8 @@ class FavoriteMedia(TimeStampedModel):
media_obj = self.channel
if self.birding_location:
media_obj = self.birding_location
if self.disc_golf_course:
media_obj = self.disc_golf_course
return media_obj
@classmethod
@ -1953,6 +2023,7 @@ class FavoriteMedia(TimeStampedModel):
"Mood": "mood",
"BrickSet": "brick_set",
"BirdingLocation": "birding_location",
"DiscGolfCourse": "disc_golf_course",
}
fk = fk_map.get(media_type)

View File

@ -9,10 +9,11 @@ import requests
from beers.models import Beer
from boardgames.models import BoardGame, BoardGameDesigner, BoardGameLocation
from books.constants import READCOMICSONLINE_URL
from books.models import Book, BookLogData, BookPageLogData
from books.models import Book, BookLogData, BookPageLogData, Paper
from books.utils import parse_readcomicsonline_uri
from bricksets.models import BrickSet
from dateutil.parser import parse
from discgolf.models import DiscGolfCourse
from django.utils import timezone
from foods.models import Food
from foods.sources.rscraper import RecipeScraperService
@ -330,8 +331,6 @@ def manual_scrobble_book(
source = READCOMICSONLINE_URL.replace("https://", "")
# TODO: Check for scrobble of this book already and if so, update the page count
book = Book.find_or_create(title, url=url, enrich=True)
scrobble_dict = {
@ -642,6 +641,8 @@ def manual_scrobble_from_url(
item_id = "tt" + str(item_id)
elif content_key == "-h" and "twitch.tv" in url:
item_id = url
elif content_key == "-pp" and "doi.org" in url:
item_id = url
scrobble_fn = MANUAL_SCROBBLE_FNS[content_key]
return eval(scrobble_fn)(item_id, user_id, source=source, action=action)
@ -996,6 +997,38 @@ def manual_scrobble_task(
return scrobble
def manual_scrobble_paper(
doi_url: str,
user_id: int,
source: str = "Bookmarklet",
action: Optional[str] = None,
):
paper = Paper.find_or_create_by_doi(doi_url)
scrobble_dict = {
"user_id": user_id,
"timestamp": timezone.now(),
"playback_position_seconds": 0,
"source": source,
}
logger.info(
"[vrobbler-scrobble] paper scrobble request received",
extra={
"paper_id": paper.id,
"user_id": user_id,
"scrobble_dict": scrobble_dict,
"media_type": Scrobble.MediaType.PAPER,
},
)
scrobble = Scrobble.create_or_update(paper, user_id, scrobble_dict)
if action == "stop":
scrobble.stop(force_finish=True)
return scrobble
def manual_scrobble_webpage(
url: str,
user_id: int,
@ -1329,3 +1362,32 @@ def manual_scrobble_food(
)
return Scrobble.create_or_update(food, user_id, scrobble_dict)
def manual_scrobble_discgolf(
item_id: str,
user_id: int,
source: str = "Vrobbler",
action: Optional[str] = None,
):
from discgolf.models import DiscGolfCourse
course, _ = DiscGolfCourse.objects.get_or_create(title=item_id.strip())
scrobble_dict = {
"user_id": user_id,
"timestamp": timezone.now(),
"playback_position_seconds": 0,
"source": source,
}
logger.info(
"[scrobblers] manual disc golf scrobble request received",
extra={
"course_id": course.id,
"user_id": user_id,
"scrobble_dict": scrobble_dict,
},
)
return Scrobble.create_or_update(course, user_id, scrobble_dict)

View File

@ -1,5 +1,6 @@
import logging
from django.core.cache import cache
from django.db.models.signals import post_delete, post_save
from django.dispatch import receiver
from django.utils import timezone
@ -9,6 +10,7 @@ from scrobbles.tasks import (
add_favorite_to_mopidy_playlist,
CHARTABLE_MEDIA_TYPES,
remove_favorite_from_mopidy_playlist,
reverse_geocode_geolocation,
SCROBBLES_WITHOUT_CHARTS,
update_charts_for_timestamp,
)
@ -52,6 +54,11 @@ def _update_charts_for_timestamp(user, ts):
if ts is None:
return
lock_key = f"chart_update_{user.id}"
if not cache.add(lock_key, "locked", timeout=30):
logger.info(f"Chart update already queued for user {user.id}, skipping")
return
if timezone.is_naive(ts):
ts = timezone.make_aware(ts)
@ -80,6 +87,31 @@ def add_tags_from_task_title(sender, instance, **kwargs):
instance.tags.add(tag)
@receiver(post_save, sender=Scrobble)
def reverse_geocode_on_scrobble_creation(sender, instance, created, **kwargs):
if not created:
return
if not instance.geo_location_id:
logger.info(
"Skipping reverse geocode: scrobble %s has no geo_location",
instance.id,
)
return
if instance.geo_location.postal_code:
logger.info(
"Skipping reverse geocode: geo_location %s already has postal_code %s",
instance.geo_location_id,
instance.geo_location.postal_code,
)
return
logger.info(
"Enqueuing reverse geocode for geo_location %s",
instance.geo_location_id,
)
reverse_geocode_geolocation.delay(instance.geo_location_id)
@receiver(post_save, sender=FavoriteMedia)
def add_to_mopidy_playlist_on_favorite(sender, instance, created, **kwargs):
if not created:

View File

@ -12,6 +12,7 @@ from charts.utils import (
from django.apps import apps
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.db import models
from django.utils import timezone
@ -170,6 +171,16 @@ def process_ebird_csv_import(import_id):
birding_import.process()
@shared_task
def process_udisc_csv_import(import_id):
UDiscCSVImport = apps.get_model("scrobbles", "UDiscCSVImport")
udisc_import = UDiscCSVImport.objects.filter(id=import_id).first()
if not udisc_import:
logger.warn(f"UDiscCSVImport not found with id {import_id}")
return
udisc_import.process()
@shared_task
def process_scale_csv_import(import_id):
ScaleCSVImport = apps.get_model("scrobbles", "ScaleCSVImport")
@ -241,6 +252,11 @@ def update_charts_for_timestamp(user_id, year, month, day, week):
logger.error(f"User with id {user_id} not found")
return
lock_key = f"chart_update_running_{user_id}"
if not cache.add(lock_key, "locked", timeout=300):
logger.info(f"Chart update already running for user {user_id}, skipping")
return
try:
build_daily_charts(user, year, month, day, CHARTABLE_MEDIA_TYPES)
build_weekly_charts(user, year, week, CHARTABLE_MEDIA_TYPES)
@ -250,6 +266,8 @@ def update_charts_for_timestamp(user_id, year, month, day, week):
logger.info(f"[charts] Updated charts for {user} on {date_str}")
except Exception as e:
logger.error(f"[charts] Failed to update charts: {e}")
finally:
cache.delete(lock_key)
@shared_task
@ -708,3 +726,41 @@ def add_scrobble_to_mopidy_monthly_playlist(scrobble_id):
break
add_track_to_mopidy_monthly_playlist(scrobble)
@shared_task
def reverse_geocode_geolocation(geo_location_id):
from locations.models import GeoLocation
location = GeoLocation.objects.filter(id=geo_location_id).first()
if not location:
logger.info(
"Skipping reverse geocode: geo_location %s not found",
geo_location_id,
)
return
if location.postal_code:
logger.info(
"Skipping reverse geocode: geo_location %s already has postal_code %s",
geo_location_id,
location.postal_code,
)
return
logger.info(
"Reverse geocoding geo_location %s (%s, %s)",
geo_location_id,
location.lat,
location.lon,
)
if location.reverse_geocode():
logger.info(
"Reverse geocode succeeded for geo_location %s: %s",
geo_location_id,
location.display_address,
)
else:
logger.warning(
"Reverse geocode failed for geo_location %s",
geo_location_id,
)

View File

@ -5,6 +5,7 @@ from tasks.webhooks import EmacsWebhookView, TodoistWebhookView
app_name = "scrobbles"
urlpatterns = [
path("now-playing/", views.NowPlayingPartialView.as_view(), name="now-playing-partial"),
path("calendar/", views.ScrobbleCalendarView.as_view(), name="calendar"),
path("search/", views.ScrobbleSearchView.as_view(), name="search"),
path("status/", views.ScrobbleStatusView.as_view(), name="status"),
@ -147,6 +148,11 @@ urlpatterns = [
views.ScrobbleBirdingCSVImportDetailView.as_view(),
name="ebird-csv-import-detail",
),
path(
"imports/udisc-csv/<slug:slug>/",
views.ScrobbleUDiscCSVImportDetailView.as_view(),
name="udisc-csv-import-detail",
),
path(
"long-plays/",
views.ScrobbleLongPlaysView.as_view(),

View File

@ -154,10 +154,11 @@ def import_lastfm_for_all_users(restart=False):
last_processed = lfm_import.processed_finished
else:
logger.info(
f"Not resuming failed LastFM import {lfm_import.id} for user {user_id}, use restart=True to restart"
"No existing LastFM import, we should start a monthly parsing of lastFm for this user going back to 2002"
"No existing LastFM import for user %s, "
"starting a full parse",
user_id,
)
continue
last_processed = None
lfm_client = LastFM(user=get_user_model().objects.filter(id=user_id).first())

View File

@ -87,6 +87,7 @@ from scrobbles.models import (
ScrobbleQuerySet,
ShareViewLog,
TrailGPXImport,
UDiscCSVImport,
)
from scrobbles.scrobblers import *
from scrobbles.tasks import (
@ -360,11 +361,35 @@ class RecentScrobbleList(ListView):
return Scrobble.objects.all().order_by("-timestamp")
class NowPlayingPartialView(LoginRequiredMixin, TemplateView):
template_name = "scrobbles/_now_playing.html"
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
from scrobbles.constants import EXCLUDE_FROM_NOW_PLAYING
ctx["now_playing_list"] = list(
Scrobble.objects.filter(
in_progress=True,
is_paused=False,
user=self.request.user,
)
.exclude(media_type__in=EXCLUDE_FROM_NOW_PLAYING)
.select_related("track", "video", "podcast_episode")
)
return ctx
class ScrobbleListView(LoginRequiredMixin, ListView):
model = Scrobble
paginate_by = 100
template_name = "scrobbles/scrobble_all_list.html"
def get_template_names(self):
if self.request.headers.get("HX-Request"):
return ["scrobbles/_scrobble_all_content.html"]
return ["scrobbles/scrobble_all_list.html"]
def get_queryset(self):
qs = Scrobble.objects.filter(user=self.request.user).order_by("-timestamp")
tags_param = self.request.GET.get("tags", "")
@ -535,6 +560,8 @@ class BaseScrobbleImportDetailView(DetailView):
title = "Scale CSV Import"
if self.model == TrailGPXImport:
title = "Trail GPX Import"
if self.model == UDiscCSVImport:
title = "uDisc CSV Import"
context_data["title"] = title
return context_data
@ -571,6 +598,10 @@ class ScrobbleBirdingCSVImportDetailView(BaseScrobbleImportDetailView):
model = EBirdCSVImport
class ScrobbleUDiscCSVImportDetailView(BaseScrobbleImportDetailView):
model = UDiscCSVImport
class ManualScrobbleView(FormView):
form_class = ScrobbleForm
template_name = "scrobbles/manual_form.html"
@ -579,7 +610,12 @@ class ManualScrobbleView(FormView):
item_str = form.cleaned_data.get("item_id")
logger.debug(f"Looking for scrobblable media with input {item_str}")
key, item_id = item_str[:2], item_str[3:]
if len(item_str) > 2 and item_str[:3] in MANUAL_SCROBBLE_FNS:
key = item_str[:3]
item_id = item_str[4:]
else:
key = item_str[:2]
item_id = item_str[3:]
scrobble_fn = MANUAL_SCROBBLE_FNS[key]
scrobble = eval(scrobble_fn)(item_id, self.request.user.id)
@ -887,7 +923,7 @@ def scrobble_start(request, media_uuid):
if last_scrobble and last_scrobble.logdata:
next_page = last_scrobble.logdata.page_end + 1
log_data = {"page_start": next_page}
media_obj.scrobble_for_user(user_id, log=log_data)
scrobble = media_obj.scrobble_for_user(user_id, log=log_data)
if scrobble:
messages.add_message(
@ -1108,6 +1144,7 @@ def toggle_favorite(request, media_type, object_id):
"Mood": ("moods", "Mood"),
"BrickSet": ("bricksets", "BrickSet"),
"BirdingLocation": ("birds", "BirdingLocation"),
"DiscGolfCourse": ("discgolf", "DiscGolfCourse"),
}
app_label, model_name = app_model_map.get(media_type, (None, None))

View File

@ -38,15 +38,36 @@ class Command(BaseCommand):
overall_start = timezone.now()
ok_count = 0
fail_count = 0
skipped_count = 0
for user in users:
total_trends = len(TREND_REGISTRY)
self.stdout.write(f" {user} ({user.id}): {total_trends} trends...")
try:
profile = user.profile
if profile.trends_disabled:
self.stdout.write(
self.style.WARNING(
f" {user} ({user.id}): trends disabled globally, skipping"
)
)
skipped_count += len(TREND_REGISTRY)
continue
disabled_trends = set(profile.disabled_trends or [])
except Exception:
disabled_trends = set()
active_slugs = [
s for s in TREND_REGISTRY if s not in disabled_trends
]
total_trends = len(active_slugs)
self.stdout.write(
f" {user} ({user.id}): {total_trends} trends ("
f"{len(disabled_trends & set(TREND_REGISTRY.keys()))} disabled)..."
)
user_start = timezone.now()
user_ok = 0
user_fail = 0
for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):
for idx, slug in enumerate(active_slugs, start=1):
periods = get_supported_periods(slug)
self.stdout.write(f" [{idx}/{total_trends}] {slug}...\n")
for period in periods:
@ -76,7 +97,7 @@ class Command(BaseCommand):
overall_elapsed = (timezone.now() - overall_start).total_seconds()
self.stdout.write(
self.style.SUCCESS(
f"Done! {ok_count} OK, {fail_count} failed "
f"Done! {ok_count} OK, {fail_count} failed, {skipped_count} skipped "
f"({overall_elapsed:.1f}s across {total_users} user(s))"
)
)

View File

@ -0,0 +1,29 @@
# Generated by Django 4.2.29 on 2026-06-22 02:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"trends",
"0002_alter_trendresult_unique_together_trendresult_period_and_more",
),
]
operations = [
migrations.AlterField(
model_name="trendresult",
name="period",
field=models.CharField(
choices=[
("last_30", "Last 30 days"),
("last_90", "Last 90 days"),
("last_year", "Last year"),
],
default="last_30",
max_length=20,
),
),
]

View File

@ -26,7 +26,17 @@ def compute_user_trends(user_id):
logger.warning("User %s not found, skipping trends", user_id)
return
total = len(TREND_REGISTRY)
try:
profile = user.profile
if profile.trends_disabled:
logger.info("User %s (%d) has trends disabled, skipping", user, user_id)
return
disabled = set(profile.disabled_trends or [])
except Exception:
disabled = set()
active_slugs = [s for s in TREND_REGISTRY if s not in disabled]
total = len(active_slugs)
logger.info(
"Computing %d trends for user %s (%d)",
total,
@ -34,7 +44,7 @@ def compute_user_trends(user_id):
user_id,
)
for idx, (slug, _) in enumerate(TREND_REGISTRY.items(), start=1):
for idx, slug in enumerate(active_slugs, start=1):
compute_single_trend.delay(user_id, slug)
logger.info("Dispatched all %d trends for user %s (%d)", total, user, user_id)
@ -52,6 +62,21 @@ def compute_single_trend(user_id, slug):
logger.warning("Unknown trend slug '%s' for user %d", slug, user_id)
return
try:
profile = user.profile
if profile.trends_disabled:
logger.info(
"User %d has trends disabled, skipping '%s'", user_id, slug
)
return
if slug in (profile.disabled_trends or []):
logger.info(
"User %d has trend '%s' disabled, skipping", user_id, slug
)
return
except Exception:
pass
periods = get_supported_periods(slug)
for period in periods:

View File

@ -0,0 +1,65 @@
<div class="row">
<div class="col-12">
{% if data.total and data.total > 0 %}
<h5>Overall</h5>
<div class="table-responsive mb-4">
<table class="table table-striped table-sm">
<thead>
<tr>
<th>Category</th>
<th class="text-end">Scrobbles</th>
<th class="text-end">%</th>
</tr>
</thead>
<tbody>
{% for slug, info in data.categories.items %}
<tr>
<td>{{ info.label }}</td>
<td class="text-end">{{ info.count }}</td>
<td class="text-end">{{ info.pct }}%</td>
</tr>
{% endfor %}
<tr class="table-secondary">
<td><strong>Total</strong></td>
<td class="text-end"><strong>{{ data.total }}</strong></td>
<td class="text-end"></td>
</tr>
</tbody>
</table>
</div>
<h5>By Media Type</h5>
{% for mt, mt_data in data.by_media_type.items %}
<h6 class="mt-3">{{ mt }}</h6>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th>Category</th>
<th class="text-end">Scrobbles</th>
<th class="text-end">%</th>
</tr>
</thead>
<tbody>
{% for slug, info in mt_data.categories.items %}
<tr>
<td>{{ info.label }}</td>
<td class="text-end">{{ info.count }}</td>
<td class="text-end">{{ info.pct }}%</td>
</tr>
{% endfor %}
<tr class="table-secondary">
<td><strong>Total</strong></td>
<td class="text-end"><strong>{{ mt_data.total }}</strong></td>
<td class="text-end"></td>
</tr>
</tbody>
</table>
</div>
{% endfor %}
{% else %}
<p class="text-muted">No data found for Books, Trails, Birding Locations, or Board Games in this period.</p>
{% endif %}
</div>
</div>

View File

@ -3,6 +3,10 @@
{% block title %}{{ trend.title }}{% endblock %}
{% block lists %}
{% if trend_not_found %}
<div class="alert alert-warning">Trend not found.</div>
{% else %}
<div class="row mb-3">
<div class="col-12">
<a href="{% url 'trends:trends-home' %}" class="btn btn-sm btn-outline-secondary mb-2">&larr; All Trends</a>
@ -35,13 +39,21 @@
{% if computed_at %}
<small class="text-muted">Last computed: {{ computed_at|date:"F j, Y H:i" }}</small>
{% endif %}
<div class="mt-2">
<form method="post" action="{% url 'trends:trend-toggle-disabled' trend.slug %}" class="d-inline">
{% csrf_token %}
{% if trend_disabled %}
<button type="submit" class="btn btn-sm btn-outline-success">Enable this trend</button>
{% else %}
<button type="submit" class="btn btn-sm btn-outline-danger">Disable this trend</button>
{% endif %}
</form>
</div>
</div>
</div>
{% if trend_not_found %}
<div class="alert alert-warning">Trend not found.</div>
{% elif data is None %}
{% if data is None %}
<div class="alert alert-info">
No data computed yet for this period. Trends are updated once daily, check back later.
</div>
@ -55,6 +67,9 @@
{% elif trend.slug == "reading-pace-vs-activity" %}
{% include "trends/_reading_pace.html" %}
{% elif trend.slug == "time-of-day-categories" %}
{% include "trends/_time_of_day_categories.html" %}
{% elif trend.slug == "trending-up" %}
{% include "trends/_trending_up.html" %}
@ -82,5 +97,6 @@
{% elif trend.slug == "mood-weather" %}
{% include "trends/_mood_weather.html" %}
{% endif %}
{% endif %}
{% endblock %}

View File

@ -25,11 +25,23 @@
</a>
</h5>
<p class="card-text text-muted">{{ trend.description }}</p>
{% if trend.computed_at %}
<small class="text-muted">Last computed: {{ trend.computed_at|date:"M j, Y H:i" }}</small>
{% else %}
<span class="badge bg-warning text-dark">Pending</span>
{% endif %}
<div class="d-flex justify-content-between align-items-center">
<div>
{% if trend.computed_at %}
<small class="text-muted">Last computed: {{ trend.computed_at|date:"M j, Y H:i" }}</small>
{% else %}
<span class="badge bg-warning text-dark">Pending</span>
{% endif %}
</div>
<form method="post" action="{% url 'trends:trend-toggle-disabled' trend.slug %}" class="m-0">
{% csrf_token %}
{% if trend.disabled %}
<button type="submit" class="btn btn-sm btn-outline-success">Enable</button>
{% else %}
<button type="submit" class="btn btn-sm btn-outline-danger">Disable</button>
{% endif %}
</form>
</div>
</div>
</div>
</div>

View File

@ -15,6 +15,7 @@ from trends.trends.mood import (
compute_mood_weather,
)
from trends.trends.reading import compute_reading_pace_vs_activity
from trends.trends.time_of_day import compute_time_of_day_categories
from trends.trends.trending import compute_trending_up
TREND_REGISTRY = {}
@ -44,5 +45,8 @@ compute_peak_hours = register("peak-hours")(compute_peak_hours)
compute_reading_pace_vs_activity = register("reading-pace-vs-activity")(
compute_reading_pace_vs_activity
)
compute_time_of_day_categories = register("time-of-day-categories")(
compute_time_of_day_categories
)
compute_trending_up = register("trending-up")(compute_trending_up)
compute_weekly_rhythm = register("weekly-rhythm")(compute_weekly_rhythm)

View File

@ -0,0 +1,89 @@
from collections import OrderedDict
from django.db.models import Count, Q
from django.db.models.functions import Extract
from scrobbles.models import Scrobble
TARGET_MEDIA_TYPES = ["Book", "Trail", "BirdingLocation", "BoardGame"]
CATEGORIES = OrderedDict(
[
("early_bird", {"label": "Early Bird", "hours": {5, 6, 7, 8, 9, 10}}),
("day_jay", {"label": "Day Jay", "hours": {11, 12, 13, 14, 15, 16, 17, 18}}),
("night_owl", {"label": "Night Owl", "hours": {19, 20, 21, 22, 23, 0, 1, 2, 3, 4}}),
]
)
def _categorize_hour(hour):
for slug, cat in CATEGORIES.items():
if hour in cat["hours"]:
return slug
return None
def compute_time_of_day_categories(user, period="last_30"):
from trends.utils import get_date_range
start, end = get_date_range(period)
filters = Q(user=user, media_type__in=TARGET_MEDIA_TYPES, timestamp__isnull=False)
if start:
filters &= Q(timestamp__gte=start)
if end:
filters &= Q(timestamp__lte=end)
qs = (
Scrobble.objects.filter(filters)
.annotate(hour=Extract("timestamp", "hour"))
.values("media_type", "hour")
.annotate(count=Count("id"))
.order_by("media_type", "hour")
)
raw = {}
for row in qs:
mt = row["media_type"]
raw.setdefault(mt, {})[row["hour"]] = row["count"]
by_media_type = {}
grand_totals = {"early_bird": 0, "day_jay": 0, "night_owl": 0}
grand_total = 0
for mt in TARGET_MEDIA_TYPES:
mt_data = raw.get(mt, {})
cat_counts = {"early_bird": 0, "day_jay": 0, "night_owl": 0}
mt_total = 0
for hour, count in mt_data.items():
slug = _categorize_hour(hour)
if slug:
cat_counts[slug] += count
mt_total += count
by_media_type[mt] = {
"total": mt_total,
"categories": {},
}
for slug in CATEGORIES:
c = cat_counts[slug]
by_media_type[mt]["categories"][slug] = {
"count": c,
"pct": round((c / mt_total * 100), 1) if mt_total else 0,
"label": CATEGORIES[slug]["label"],
}
grand_totals[slug] += c
grand_total += mt_total
categories = {}
for slug in CATEGORIES:
c = grand_totals[slug]
categories[slug] = {
"count": c,
"pct": round((c / grand_total * 100), 1) if grand_total else 0,
"label": CATEGORIES[slug]["label"],
}
return {
"categories": categories,
"total": grand_total,
"by_media_type": by_media_type,
}

View File

@ -1,9 +1,18 @@
from django.urls import path
from trends.views import TrendDetailView, TrendListView
from trends.views import (
ToggleTrendDisabledView,
TrendDetailView,
TrendListView,
)
app_name = "trends"
urlpatterns = [
path("trends/", TrendListView.as_view(), name="trends-home"),
path("trends/<slug:trend_slug>/", TrendDetailView.as_view(), name="trend-detail"),
path(
"trends/<slug:trend_slug>/toggle/",
ToggleTrendDisabledView.as_view(),
name="trend-toggle-disabled",
),
]

View File

@ -3,6 +3,7 @@ from datetime import timedelta
from django.utils import timezone
from trends.models import PERIOD_CHOICES, TrendResult
from trends.trends import TREND_REGISTRY
logger = logging.getLogger(__name__)
@ -25,6 +26,7 @@ TIME_BOUND_TRENDS = {
"mood-weather",
"peak-hours",
"reading-pace-vs-activity",
"time-of-day-categories",
"trending-up",
"weekly-rhythm",
}
@ -34,6 +36,13 @@ TREND_PERIOD_OVERRIDES = {
}
def get_disabled_trends(user):
profile = user.profile
if profile.trends_disabled:
return set(TREND_REGISTRY.keys())
return set(profile.disabled_trends or [])
def get_supported_periods(trend_slug):
if trend_slug in TREND_PERIOD_OVERRIDES:
slugs = TREND_PERIOD_OVERRIDES[trend_slug]

View File

@ -1,8 +1,11 @@
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import TemplateView
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.views.generic import TemplateView, View
from trends.models import TrendResult
from trends.trends import TREND_REGISTRY
from trends.utils import get_period_nav, get_supported_periods
from trends.utils import get_disabled_trends, get_period_nav, get_supported_periods
TREND_METADATA = {
"activity-distribution": {
@ -55,6 +58,11 @@ TREND_METADATA = {
"description": "Compare how long you read per session with and without concurrent music.",
"icon": "📊",
},
"time-of-day-categories": {
"title": "Time of Day Categories",
"description": "Are you an early bird, day jay, or night owl? Categorized by Books, Trails, Birding Locations, and Board Games.",
"icon": "🦉",
},
"trending-up": {
"title": "Trending Media Types",
"description": "Which media types have you been consuming more or less of recently?",
@ -73,6 +81,8 @@ class TrendListView(LoginRequiredMixin, TemplateView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
disabled = get_disabled_trends(self.request.user)
results = TrendResult.objects.filter(
user=self.request.user,
).order_by("trend_slug", "-computed_at")
@ -84,8 +94,11 @@ class TrendListView(LoginRequiredMixin, TemplateView):
trends = []
for slug in TREND_REGISTRY:
if slug in disabled:
continue
meta = TREND_METADATA.get(slug, {})
result = latest_by_slug.get(slug)
is_disabled = slug in (self.request.user.profile.disabled_trends or [])
trends.append(
{
"slug": slug,
@ -94,6 +107,7 @@ class TrendListView(LoginRequiredMixin, TemplateView):
"icon": meta.get("icon", ""),
"computed_at": result.computed_at if result else None,
"has_data": result is not None,
"disabled": is_disabled,
}
)
ctx["trends"] = trends
@ -143,4 +157,25 @@ class TrendDetailView(LoginRequiredMixin, TemplateView):
ctx["computed_at"] = None
ctx["data"] = None
disabled = get_disabled_trends(self.request.user)
ctx["trend_disabled"] = slug in disabled
ctx["globally_disabled"] = self.request.user.profile.trends_disabled
return ctx
class ToggleTrendDisabledView(LoginRequiredMixin, View):
def post(self, request, trend_slug):
profile = request.user.profile
disabled = set(profile.disabled_trends or [])
if trend_slug in disabled:
disabled.discard(trend_slug)
messages.success(request, f"Trend re-enabled.")
else:
disabled.add(trend_slug)
messages.success(request, f"Trend disabled.")
profile.disabled_trends = list(disabled)
profile.save(update_fields=["disabled_trends"])
return HttpResponseRedirect(
request.META.get("HTTP_REFERER", reverse("trends:trends-home"))
)

View File

@ -15,13 +15,11 @@ def natural_duration(value):
seconds = remainder % 60
parts = []
if days:
parts.append(f"{days} day{'s' if days != 1 else ''}")
parts.append(f"{days} d")
if hours:
parts.append(f"{hours} hour{'s' if hours != 1 else ''}")
parts.append(f"{hours} hr")
if minutes:
parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}")
parts.append(f"{minutes} min")
if seconds or not parts:
parts.append(f"{seconds} second{'s' if seconds != 1 else ''}")
if len(parts) == 1:
return parts[0]
return ", ".join(parts[:-1]) + " and " + parts[-1]
parts.append(f"{seconds} sec")
return ", ".join(parts)

View File

@ -1,8 +1,24 @@
from django.core.cache import cache
from videos.models import Video, Series
CACHE_TTL = 300
def video_lists(request):
movie_list = cache.get("video_lists_movie_list")
if movie_list is None:
movie_list = list(
Video.objects.filter(video_type=Video.VideoType.MOVIE).only("id", "title")
)
cache.set("video_lists_movie_list", movie_list, CACHE_TTL)
series_list = cache.get("video_lists_series_list")
if series_list is None:
series_list = list(Series.objects.all().only("id", "name"))
cache.set("video_lists_series_list", series_list, CACHE_TTL)
return {
"movie_list": Video.objects.filter(video_type=Video.VideoType.MOVIE),
"series_list": Series.objects.all(),
"movie_list": movie_list,
"series_list": series_list,
}

View File

@ -45,6 +45,37 @@ class Domain(TimeStampedModel):
)
def _fetch_url_raw(url: str) -> Optional[str]:
"""Fetch raw HTML for a URL.
Tries two strategies in order:
1. trafilatura (standard, works for most sites)
2. cloudscraper (handles Cloudflare JS challenges)
cloudscraper is lazy-imported so deployments that cannot compile
its dependencies (e.g. FreeBSD) still work.
"""
raw = trafilatura.fetch_url(url)
if raw:
return raw
logger.debug("trafilatura returned nothing for %s, trying cloudscraper", url)
try:
import cloudscraper
except ImportError:
logger.debug("cloudscraper not available")
else:
try:
scraper = cloudscraper.create_scraper()
resp = scraper.get(url, timeout=30)
resp.raise_for_status()
return resp.text
except Exception as exc:
logger.debug("cloudscraper failed for %s: %s", url, exc)
return None
class WebPage(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, "WEBSITE_COMPLETION_PERCENT", 100)
@ -80,7 +111,7 @@ class WebPage(ScrobblableMixin):
def _update_extract_from_web(self, raw_text: str = "", force=True):
if not raw_text:
raw_text = requests.get(self.url, headers=headers).text
raw_text = _fetch_url_raw(self.url)
if not self.extract or force:
self.extract = trafilatura.extract(raw_text)
self.save(update_fields=["extract"])
@ -259,7 +290,7 @@ class WebPage(ScrobblableMixin):
return False
def fetch_data_from_web(self, save=True, force=True):
raw_text = trafilatura.fetch_url(self.url)
raw_text = _fetch_url_raw(self.url)
if not self.extract or force:
self.extract = trafilatura.extract(
raw_text,

View File

@ -4,46 +4,55 @@ from importlib.metadata import version as get_version
from pathlib import Path
_GIT_COMMIT = None
def version_info(request):
global _GIT_COMMIT
try:
app_version = get_version("vrobbler")
except Exception:
app_version = "unknown"
commit = os.environ.get("VROBBLER_COMMIT")
if not commit:
# Try to import from _commit.py module first
if commit:
return {"app_version": app_version, "git_commit": commit}
if _GIT_COMMIT is not None:
return {"app_version": app_version, "git_commit": _GIT_COMMIT}
try:
from vrobbler._commit import commit as _commit
except ImportError:
pass
else:
if _commit and _commit != "unknown":
_GIT_COMMIT = _commit
return {"app_version": app_version, "git_commit": _GIT_COMMIT}
commit_file = Path("/var/lib/vrobbler/commit.txt")
if commit_file.exists():
try:
from vrobbler._commit import commit as _commit
except ImportError:
commit = commit_file.read_text().strip()
if commit:
_GIT_COMMIT = commit
return {"app_version": app_version, "git_commit": _GIT_COMMIT}
except OSError:
pass
else:
if _commit and _commit != "unknown":
return {"app_version": app_version, "git_commit": _commit}
# Try to read from commit file (written during deploy)
commit_file = Path("/var/lib/vrobbler/commit.txt")
if commit_file.exists():
try:
commit = commit_file.read_text().strip()
if commit:
return {"app_version": app_version, "git_commit": commit}
except OSError:
pass
# Fall back to git command
PROJECT_ROOT = Path(__file__).resolve().parent.parent
try:
commit = (
subprocess.check_output(
["git", "rev-parse", "--short", "HEAD"],
cwd=PROJECT_ROOT,
stderr=subprocess.DEVNULL,
)
.decode("utf-8")
.strip()
PROJECT_ROOT = Path(__file__).resolve().parent.parent
try:
_GIT_COMMIT = (
subprocess.check_output(
["git", "rev-parse", "--short", "HEAD"],
cwd=PROJECT_ROOT,
stderr=subprocess.DEVNULL,
)
except (subprocess.SubprocessError, FileNotFoundError):
commit = "unknown"
.decode("utf-8")
.strip()
)
except (subprocess.SubprocessError, FileNotFoundError):
_GIT_COMMIT = "unknown"
return {"app_version": app_version, "git_commit": commit}
return {"app_version": app_version, "git_commit": _GIT_COMMIT}

View File

@ -122,6 +122,15 @@ CELERY_ACCEPT_CONTENT = ["json"]
CELERY_RESULT_EXTENDED = True
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
CELERY_TASK_CREATE_MISSING_QUEUES = True
CELERY_TASK_ROUTES = {
"scrobbles.tasks.update_charts_for_timestamp": {"queue": "charts"},
"scrobbles.tasks.create_yesterdays_charts": {"queue": "charts"},
"scrobbles.tasks.rebuild_weekly_charts": {"queue": "charts"},
"scrobbles.tasks.rebuild_monthly_charts": {"queue": "charts"},
"scrobbles.tasks.rebuild_yearly_charts": {"queue": "charts"},
}
CELERY_BEAT_SCHEDULE = {
"build-yesterdays-charts": {
"task": "scrobbles.tasks.create_yesterdays_charts",
@ -219,6 +228,7 @@ INSTALLED_APPS = [
"foods",
"lifeevents",
"moods",
"discgolf",
"birds",
"mathfilters",
"rest_framework",
@ -226,6 +236,7 @@ INSTALLED_APPS = [
"allauth.account",
"allauth.socialaccount",
"django_celery_results",
"mcp_server",
]
SITE_ID = 1
@ -319,6 +330,13 @@ REST_FRAMEWORK = {
"PAGE_SIZE": 200,
}
DJANGO_MCP_AUTHENTICATION_CLASSES = [
"oauth2_provider.contrib.rest_framework.OAuth2Authentication",
"rest_framework.authentication.TokenAuthentication",
"rest_framework.authentication.SessionAuthentication",
]
DJANGO_MCP_GET_SERVER_INSTRUCTIONS_TOOL = True
LOGIN_REDIRECT_URL = "/"
AUTH_PASSWORD_VALIDATORS = [
@ -352,11 +370,11 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.1/howto/static-files/
#
from storages.backends import s3boto3
USE_S3_STORAGE = os.getenv("VROBBLER_USE_S3", "False").lower() in TRUTHY
if USE_S3_STORAGE:
from storages.backends import s3boto3
AWS_S3_ENDPOINT_URL = os.getenv("AWS_S3_ENDPOINT_URL", "")
AWS_STORAGE_BUCKET_NAME = os.getenv("AWS_STORAGE_BUCKET_NAME", "")
AWS_S3_ACCESS_KEY_ID = os.getenv("AWS_S3_ACCESS_KEY_ID")
@ -379,6 +397,8 @@ else:
MEDIA_URL = os.getenv("VROBBLER_MEDIA_URL", "/media/")
SCIHUB_DOMAIN = os.getenv("VROBBLER_SCIHUB_DOMAIN", "sci-hub.st")
JSON_LOGGING = os.getenv("VROBBLER_JSON_LOGGING", "false").lower() in TRUTHY
LOG_TYPE = "json" if JSON_LOGGING else "log"

View File

@ -13,6 +13,7 @@
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.12.9/dist/umd/popper.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.10/clipboard.min.js"></script>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<style type="text/css">
dl {
display: flex;
@ -310,26 +311,13 @@
{% endblock %}
{% if now_playing_list and user.is_authenticated %}
<ul>
<b>Now playing</b>
{% for scrobble in now_playing_list %}
<div class="now-playing">
{% if scrobble.media_obj.primary_image_url %}<div style="float:left;padding-right:10px;padding-bottom:10px;"><img src="{{scrobble.media_obj.primary_image_url}}" /></div>{% endif %}
<p><a href="{{scrobble.get_absolute_url}}">{{scrobble.media_obj}}</a></p>
{% if scrobble.logdata %}{% if scrobble.logdata.title%}<p><em>{{scrobble.logdata.title}}</em></p>{% endif %}{% endif %}
<p><small>{{scrobble.local_timestamp|naturaltime}} from {{scrobble.source}}</small></p>
<div class="progress-bar" style="margin-right:5px;">
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
</div>
<p class="action-buttons">
<a href="{% url "scrobbles:cancel" scrobble.id %}">Cancel</a>
<a class="right" href="{% url "scrobbles:finish" scrobble.id %}">Finish</a>
</p>
{% if not forloop.last %}<hr/>{% endif %}
</div>
{% endfor %}
</ul>
{% if now_playing_list|length > 1 %}<hr/>{% endif %}
{% if user.profile.live_now_playing %}
<div id="now-playing-container" hx-get="{% url 'scrobbles:now-playing-partial' %}" hx-trigger="every 7s" hx-swap="innerHTML">
{% include "scrobbles/_now_playing.html" %}
</div>
{% else %}
{% include "scrobbles/_now_playing.html" %}
{% endif %}
{% endif %}
{% if active_imports %}

View File

@ -0,0 +1,62 @@
{% extends "base_list.html" %}
{% block title %}{{object.title}}{% endblock %}
{% block lists %}
<div class="row">
<div class="col">
<h1>{{object.title}}</h1>
{% if object.authors.all %}
<p>{{object.authors.all|join:", "}}</p>
{% endif %}
{% if object.journal %}
<p><em>{{object.journal.title}}{% if object.journal_volume %}, vol. {{object.journal_volume}}{% endif %}</em></p>
{% endif %}
{% if object.doi_id %}
<p><a href="https://doi.org/{{object.doi_id}}">doi: {{object.doi_id}}</a></p>
{% endif %}
{% if object.abstract %}
<p>{{object.abstract|linebreaks|truncatewords:200}}</p>
{% endif %}
{% if object.pdf_file %}
<button class="btn btn-outline-secondary btn-sm" onclick="togglePdf()">Show/Hide PDF</button>
<div id="pdf-embed" style="display:none; margin-top:0.5rem;">
<iframe src="{{object.pdf_file.url}}" style="width:100%;height:600px;border:1px solid #ccc;"></iframe>
</div>
<script>
function togglePdf() {
var el = document.getElementById('pdf-embed');
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
</script>
{% endif %}
{% if object.openaccess_pdf_url %}
<p><a href="{{object.openaccess_pdf_url}}">Open Access PDF</a></p>
{% endif %}
{% if object.pdf_file %}
<a href="{{object.pdf_file.url}}">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="text-danger" viewBox="0 0 16 16">
<path d="M14 14V4.5L9.5 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2zM9.5 3A1.5 1.5 0 0 0 11 4.5h2V14a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1h5.5v2z"/>
<path d="M4.603 14.087a.81.81 0 0 1-.438-.42c-.195-.388-.13-.776.08-1.102.197-.307.526-.568.897-.707.07-.024.15-.023.222 0l.044.014a.27.27 0 0 1 .152.295.7.7 0 0 1-.128.416c-.159.206-.344.388-.544.555-.357.299-.592.527-.406.623.08.04.272.045.578-.057a.93.93 0 0 0 .363-.226.8.8 0 0 0 .194-.277.28.28 0 0 1 .414-.112.3.3 0 0 1 .065.422 1.3 1.3 0 0 1-.67.522c-.38.147-.746.103-1.04.02zM7.12 11.5c.16-.186.34-.34.486-.514.294-.35.628-.617.947-.786.204-.108.546-.206.715-.153.087.027.135.068.16.126a.6.6 0 0 1-.003.27 1 1 0 0 1-.158.354c-.163.242-.349.47-.7.769-.332.283-.598.483-.793.607a1.1 1.1 0 0 1-.582.214c-.136 0-.234-.038-.298-.11-.05-.056-.076-.134-.07-.236a.99.99 0 0 1 .098-.45c.08-.17.21-.35.378-.57zm5.09 2.013c-.135.06-.277.104-.428.116-.205.015-.39-.048-.553-.177-.104-.082-.226-.196-.317-.325a1 1 0 0 1-.17-.572c0-.15.035-.27.095-.36.04-.063.089-.098.153-.112.138-.028.316.04.477.174.074.061.145.136.228.232.174.2.302.37.397.515.108.164.153.285.121.345a.25.25 0 0 1-.003.053c0 .079-.05.147-.198.262z"/>
</svg>
</a>
{% endif %}
{% if object.scihub_url %}
<p><a href="{{object.scihub_url}}">View on Sci-Hub</a></p>
{% endif %}
</div>
</div>
<hr>
<div class="row">
<div class="col">
<form method="post" enctype="multipart/form-data" action="{% url 'books:paper_upload_pdf' slug=object.uuid %}">
{% csrf_token %}
<div class="form-group">
<label for="pdf_file">Upload PDF</label>
<input type="file" class="form-control-file" id="pdf_file" name="pdf_file" accept=".pdf,application/pdf">
</div>
<button type="submit" class="btn btn-primary btn-sm">Upload</button>
</form>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,13 @@
{% extends "base_list.html" %}
{% block title %}Papers{% endblock %}
{% block lists %}
<div class="row">
<div class="col-md">
<div class="table-responsive">
{% include "_longplay_scrobblable_list.html" %}
</div>
</div>
</div>
{% endblock %}

View File

@ -122,7 +122,61 @@
{% include "scrobbles/_top_charts.html" %}
<div class="row">
<div class="row mt-4">
{% if charts.artist %}
<div class="col-md-6 col-lg-4 chart-section">
<h3>🎤 Top Artists</h3>
<ul class="list-group">
{% for chart in charts.artist|slice:":20" %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
<a href="{{chart.artist.get_absolute_url}}">{{chart.artist.name}}</a>
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
</li>
{% endfor %}
<li class="list-group-item">
<a href="{% url 'charts:chart-detail' 'artist' %}">View all &raquo;</a>
</li>
</ul>
</div>
{% endif %}
{% if charts.album %}
<div class="col-md-6 col-lg-4 chart-section">
<h3>💿 Top Albums</h3>
<ul class="list-group">
{% for chart in charts.album|slice:":20" %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
<a href="{{chart.album.get_absolute_url}}">{{chart.album.name}}</a>
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
</li>
{% endfor %}
<li class="list-group-item">
<a href="{% url 'charts:chart-detail' 'album' %}">View all &raquo;</a>
</li>
</ul>
</div>
{% endif %}
{% if charts.tv_series %}
<div class="col-md-6 col-lg-4 chart-section">
<h3>📺 Top TV Series</h3>
<ul class="list-group">
{% for chart in charts.tv_series|slice:":20" %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span class="me-2"><strong>#{{chart.rank}}</strong></span>
<a href="{{chart.tv_series.get_absolute_url}}">{{chart.tv_series.name}}</a>
<span class="badge bg-primary rounded-pill">{{chart.count}}</span>
</li>
{% endfor %}
<li class="list-group-item">
<a href="{% url 'charts:chart-detail' 'tv_series' %}">View all &raquo;</a>
</li>
</ul>
</div>
{% endif %}
{% if charts.track %}
<div class="col-md-6 col-lg-4 chart-section">
<h3>🎵 Top Tracks</h3>

View File

@ -0,0 +1,101 @@
{% extends "base_list.html" %}
{% load static %}
{% block title %}{{object.title}}{% endblock %}
{% block head_extra %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
#map {
height: 400px;
}
</style>
{% endblock %}
{% block lists %}
{% if object.description %}
<div class="row">
<div class="col-md">
<p>{{object.description}}</p>
</div>
</div>
{% endif %}
{% if trail_gpx_url %}
<div class="row">
<div class="col-md mb-3">
<div id="map"></div>
</div>
</div>
{% endif %}
<div class="row">
<div class="col-md">
{% if object.trail.trailhead_location.display_address %}
<p>{{ object.trail.trailhead_location.display_address }}</p>
{% endif %}
{% if object.pdga_link or object.udisc_link %}
<p>
{% if object.pdga_link %}
<a href="{{ object.pdga_link }}" target="_blank">PDGA</a>
{% endif %}
{% if object.udisc_link %}
<a href="{{ object.udisc_link }}" target="_blank">uDisc</a>
{% endif %}
</p>
{% endif %}
</div>
</div>
{% if charts %}
<div class="row">
<div class="col-md">
{% include "scrobbles/_chart_links.html" %}
</div>
</div>
{% endif %}
<div class="row">
<div class="col-md">
<h3>Last scrobbles</h3>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Notes</th>
</tr>
</thead>
<tbody>
{% for scrobble in scrobbles.all %}
<tr>
<td><a href={{scrobble.get_absolute_url}}>{{scrobble.local_timestamp}}</a></td>
<td>{{scrobble.logdata.notes_as_str}}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
{{ block.super }}
{% if trail_gpx_url %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://cdn.jsdelivr.net/npm/leaflet-gpx@2.2.0/gpx.min.js"></script>
<script>
var map = L.map('map');
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',
referrerPolicy: 'origin'
}).addTo(map);
var gpx = new L.GPX("{{ trail_gpx_url|escapejs }}", {
async: true,
polyline_options: { color: '#e74c3c' }
});
gpx.on('loaded', function(e) {
map.fitBounds(e.target.getBounds());
});
gpx.addTo(map);
</script>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,13 @@
{% extends "base_list.html" %}
{% block title %}Disc Golf Rounds{% endblock %}
{% block lists %}
<div class="row">
<div class="col-md">
<div class="table-responsive">
{% include "_scrobblable_list.html" %}
</div>
</div>
</div>
{% endblock %}

View File

@ -130,6 +130,11 @@
</ul>
</div>
{% endif %}
{% elif field.name == "trends_disabled" %}
<p class="checkbox-row">
{{ field }}
{{ field.label_tag }}
</p>
{% elif field.name == "home_scrobble_limit" %}
<p>
{{ field.label_tag }}

View File

@ -81,6 +81,15 @@
<p>No trails hiked today</p>
{% endif %}
<h3><a href="{% url 'discgolf:course_list' %}">Disc Golf</a></h3>
{% if DiscGolfCourse %}
{% with scrobbles=DiscGolfCourse count=DiscGolfCourse_count time=DiscGolfCourse_time %}
{% include "scrobbles/_scrobble_table.html" %}
{% endwith %}
{% else %}
<p>No courses played today</p>
{% endif %}
</div>
<div class="col-md">

View File

@ -0,0 +1,21 @@
{% load humanize %}
<ul>
<b>Now playing</b>
{% for scrobble in now_playing_list %}
<div class="now-playing">
{% if scrobble.media_obj.primary_image_url %}<div style="float:left;padding-right:10px;padding-bottom:10px;"><img src="{{scrobble.media_obj.primary_image_url}}" /></div>{% endif %}
<p><a href="{{scrobble.get_absolute_url}}">{{scrobble.media_obj}}</a></p>
{% if scrobble.logdata %}{% if scrobble.logdata.title%}<p><em>{{scrobble.logdata.title}}</em></p>{% endif %}{% endif %}
<p><small>{{scrobble.local_timestamp|naturaltime}} from {{scrobble.source}}</small></p>
<div class="progress-bar" style="margin-right:5px;">
<span class="progress-bar-fill" style="width: {{scrobble.percent_played}}%;"></span>
</div>
<p class="action-buttons">
<a href="{% url "scrobbles:cancel" scrobble.id %}">Cancel</a>
<a class="right" href="{% url "scrobbles:finish" scrobble.id %}">Finish</a>
</p>
{% if not forloop.last %}<hr/>{% endif %}
</div>
{% endfor %}
</ul>
{% if now_playing_list|length > 1 %}<hr/>{% endif %}

View File

@ -0,0 +1,146 @@
{% load humanize %}
{% load naturalduration %}
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4" hx-get="{% url 'scrobbles:scrobble-list' %}{% if request.META.QUERY_STRING %}?{{ request.META.QUERY_STRING }}{% endif %}" hx-trigger="every 7s" hx-swap="outerHTML">
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<div>
<h1 class="h2">All Scrobbles</h1>
{% if tag_list %}
<h6 class="text-muted">Tagged {{ tag_list|join:", " }}</h6>
{% if total_time_seconds %}
<p class="text-muted small mb-0">Total time: {{ total_time_seconds|natural_duration }}</p>
{% endif %}
{% endif %}
{% if request.GET.visibility %}
<h6 class="text-muted">Filter: {{ request.GET.visibility|title }} scrobbles only</h6>
{% endif %}
</div>
</div>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Type</th>
<th scope="col">Title</th>
<th scope="col">Time</th>
</tr>
</thead>
<tbody>
{% for scrobble in object_list %}
<tr class="{% if scrobble.id in overlap_map %}{{ overlap_map.scrobble.id }}{% endif %}">
<td>
{% if scrobble.id in overlap_map %}⏱ {% endif %}
<a href="{{scrobble.get_absolute_url}}">{{ scrobble.timestamp|naturaltime }}</a>
</td>
<td>
{% if scrobble.video %}
🎬 Video
{% elif scrobble.track %}
🎵 Track
{% elif scrobble.podcast_episode %}
🎙️ Podcast episode
{% elif scrobble.sport_event %}
⚽ Sport event
{% elif scrobble.book %}
📖 Book
{% elif scrobble.paper %}
📄 Paper
{% elif scrobble.video_game %}
🎮 Video game
{% elif scrobble.board_game %}
🎲 Board game
{% elif scrobble.geo_location %}
📍 GeoLocation
{% elif scrobble.trail %}
🥾 Trail
{% elif scrobble.beer %}
🍺 Beer
{% elif scrobble.puzzle %}
🧩 Puzzle
{% elif scrobble.food %}
🍔 Food
{% elif scrobble.task %}
✅ Task
{% elif scrobble.web_page %}
🌐 Web Page
{% elif scrobble.life_event %}
🎉 Life event
{% elif scrobble.mood %}
😊 Mood
{% elif scrobble.brick_set %}
🧱 Brick set
{% elif scrobble.channel %}
📺 Channel
{% else %}
Unknown
{% endif %}
</td>
<td>
{% if scrobble.video %}
<a href="{% url 'videos:video_detail' scrobble.video.uuid %}">{{ scrobble.video.title }}</a>
{% elif scrobble.track %}
<a href="{% url 'music:track_detail' scrobble.track.uuid %}">{{ scrobble.track.title }}</a>
{% elif scrobble.video_game %}
<a href="{% url 'videogames:videogame_detail' scrobble.video_game.uuid %}">{{ scrobble.video_game.title }}</a>
{% elif scrobble.book %}
<a href="{% url 'books:book_detail' scrobble.book.uuid %}">{{ scrobble.book.title }}</a>
{% elif scrobble.food %}
<a href="{% url 'foods:food_detail' scrobble.food.uuid %}">{{ scrobble.food.title }}</a>
{% elif scrobble.beer %}
<a href="{% url 'beers:beer_detail' scrobble.beer.uuid %}">{{ scrobble.beer.title }}</a>
{% elif scrobble.web_page %}
<a href="{% url 'webpages:webpage_detail' scrobble.web_page.uuid %}">{{ scrobble.web_page.title }}</a>
{% elif scrobble.podcast_episode %}
<a href="{% url 'podcasts:podcast_detail' scrobble.podcast_episode.podcast.id %}">{{ scrobble.podcast_episode.title }}</a>
{% elif scrobble.board_game %}
<a href="{% url 'boardgames:boardgame_detail' scrobble.board_game.uuid %}">{{ scrobble.board_game.title }}</a>
{% elif scrobble.trail %}
<a href="{% url 'trails:trail_detail' scrobble.trail.uuid %}">{{ scrobble.trail.title }}</a>
{% elif scrobble.puzzle %}
<a href="{% url 'puzzles:puzzle_detail' scrobble.puzzle.uuid %}">{{ scrobble.puzzle.title }}</a>
{% elif scrobble.brick_set %}
<a href="{% url 'bricksets:brickset_detail' scrobble.brick_set.uuid %}">{{ scrobble.brick_set.title }}</a>
{% elif scrobble.task %}
<a href="{% url 'tasks:task_detail' scrobble.task.uuid %}">{{scrobble.media_obj}}{% if scrobble.log.title %} - {{ scrobble.log.title }}{% endif %}</a>
{% elif scrobble.life_event %}
<a href="{% url 'lifeevents:lifeevent_detail' scrobble.life_event.uuid %}">{{ scrobble.life_event.title }}</a>
{% elif scrobble.mood %}
<a href="{% url 'moods:mood_detail' scrobble.mood.uuid %}">{{ scrobble.mood.title}}</a>
{% elif scrobble.geo_location %}
<a href="{% url 'locations:geolocation_detail' scrobble.geo_location.uuid %}">{{ scrobble.geo_location.title }}</a>
{% else %}
Unknown
{% endif %}
</td>
<td>
{% if scrobble.in_progress and not scrobble.played_to_completion %}
In progress ...
{% elif scrobble.playback_position_seconds %}
{{ scrobble.playback_position_seconds|natural_duration }}
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="4">No scrobbles found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if page_obj.has_previous or page_obj.has_next %}
<nav>
<ul class="pagination">
{% if page_obj.has_previous %}
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if tags_param %}&tags={{ tags_param }}{% endif %}">Previous</a></li>
{% endif %}
<li class="page-item"><span class="page-link">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span></li>
{% if page_obj.has_next %}
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}{% if tags_param %}&tags={{ tags_param }}{% endif %}">Next</a></li>
{% endif %}
</ul>
</nav>
{% endif %}
</main>

View File

@ -49,11 +49,12 @@
</div>
<div style="float:left; width:300px;">
<div style="display:flex; flex-wrap: wrap;">
{% for i in "67891011121314" %}
{% with artists|get_item:forloop.counter|add:5 as artist %}
{% for i in "123456789" %}
{% with forloop.counter|add:4 as idx %}
{% with artists|get_item:idx as artist %}
{% if artist %}
<div class="image-wrapper" style="width:33%">
<div class="caption-small">#{{forloop.counter|add:6}} {{artist.artist.name}}</div>
<div class="caption-small">#{{forloop.counter|add:5}} {{artist.artist.name}}</div>
{% if artist.artist.thumbnail %}
<a href="{{artist.artist.get_absolute_url}}"><img src="{{artist.artist.thumbnail_medium.url}}" width="100px"></a>
{% else %}
@ -62,6 +63,7 @@
</div>
{% endif %}
{% endwith %}
{% endwith %}
{% endfor %}
</div>
</div>
@ -123,11 +125,12 @@
</div>
<div style="float:left; width:300px;">
<div style="display:flex; flex-wrap: wrap;">
{% for i in "67891011121314" %}
{% with albums|get_item:forloop.counter|add:5 as album %}
{% for i in "123456789" %}
{% with forloop.counter|add:4 as idx %}
{% with albums|get_item:idx as album %}
{% if album %}
<div class="image-wrapper" style="width:33%">
<div class="caption-small">#{{forloop.counter|add:6}} {{album.album.title}}</div>
<div class="caption-small">#{{forloop.counter|add:5}} {{album.album.title}}</div>
{% if album.album.cover_image %}
<a href="{{album.album.get_absolute_url}}"><img src="{{album.album.cover_image_medium.url}}" width="100px"></a>
{% else %}
@ -136,6 +139,7 @@
</div>
{% endif %}
{% endwith %}
{% endwith %}
{% endfor %}
</div>
</div>
@ -197,11 +201,12 @@
</div>
<div style="float:left; width:300px;">
<div style="display:flex; flex-wrap: wrap;">
{% for i in "67891011121314" %}
{% with shows|get_item:forloop.counter|add:5 as show %}
{% for i in "123456789" %}
{% with forloop.counter|add:4 as idx %}
{% with shows|get_item:idx as show %}
{% if show %}
<div class="image-wrapper" style="width:33%">
<div class="caption-small">#{{forloop.counter|add:6}} {{show.tv_series.name}}</div>
<div class="caption-small">#{{forloop.counter|add:5}} {{show.tv_series.name}}</div>
{% if show.tv_series.cover_image %}
<a href="{{show.tv_series.get_absolute_url}}"><img src="{{show.tv_series.cover_small.url}}" width="100px" height="100px" style="object-fit: cover"></a>
{% else %}
@ -210,6 +215,7 @@
</div>
{% endif %}
{% endwith %}
{% endwith %}
{% endfor %}
</div>
</div>

View File

@ -1,148 +1,4 @@
{% extends "base.html" %}
{% load humanize %}
{% load naturalduration %}
{% block content %}
<main class="col-md-9 ms-sm-auto col-lg-10 px-md-4">
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<div>
<h1 class="h2">All Scrobbles</h1>
{% if tag_list %}
<h6 class="text-muted">Tagged {{ tag_list|join:", " }}</h6>
{% if total_time_seconds %}
<p class="text-muted small mb-0">Total time: {{ total_time_seconds|natural_duration }}</p>
{% endif %}
{% endif %}
{% if request.GET.visibility %}
<h6 class="text-muted">Filter: {{ request.GET.visibility|title }} scrobbles only</h6>
{% endif %}
</div>
</div>
<div class="table-responsive">
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Type</th>
<th scope="col">Title</th>
<th scope="col">Time</th>
</tr>
</thead>
<tbody>
{% for scrobble in object_list %}
<tr class="{% if scrobble.id in overlap_map %}{{ overlap_map.scrobble.id }}{% endif %}">
<td>
{% if scrobble.id in overlap_map %}⏱ {% endif %}
<a href="{{scrobble.get_absolute_url}}">{{ scrobble.timestamp|naturaltime }}</a>
</td>
<td>
{% if scrobble.video %}
🎬 Video
{% elif scrobble.track %}
🎵 Track
{% elif scrobble.podcast_episode %}
🎙️ Podcast episode
{% elif scrobble.sport_event %}
⚽ Sport event
{% elif scrobble.book %}
📖 Book
{% elif scrobble.paper %}
📄 Paper
{% elif scrobble.video_game %}
🎮 Video game
{% elif scrobble.board_game %}
🎲 Board game
{% elif scrobble.geo_location %}
📍 GeoLocation
{% elif scrobble.trail %}
🥾 Trail
{% elif scrobble.beer %}
🍺 Beer
{% elif scrobble.puzzle %}
🧩 Puzzle
{% elif scrobble.food %}
🍔 Food
{% elif scrobble.task %}
✅ Task
{% elif scrobble.web_page %}
🌐 Web Page
{% elif scrobble.life_event %}
🎉 Life event
{% elif scrobble.mood %}
😊 Mood
{% elif scrobble.brick_set %}
🧱 Brick set
{% elif scrobble.channel %}
📺 Channel
{% else %}
Unknown
{% endif %}
</td>
<td>
{% if scrobble.video %}
<a href="{% url 'videos:video_detail' scrobble.video.uuid %}">{{ scrobble.video.title }}</a>
{% elif scrobble.track %}
<a href="{% url 'music:track_detail' scrobble.track.uuid %}">{{ scrobble.track.title }}</a>
{% elif scrobble.video_game %}
<a href="{% url 'videogames:videogame_detail' scrobble.video_game.uuid %}">{{ scrobble.video_game.title }}</a>
{% elif scrobble.book %}
<a href="{% url 'books:book_detail' scrobble.book.uuid %}">{{ scrobble.book.title }}</a>
{% elif scrobble.food %}
<a href="{% url 'foods:food_detail' scrobble.food.uuid %}">{{ scrobble.food.title }}</a>
{% elif scrobble.beer %}
<a href="{% url 'beers:beer_detail' scrobble.beer.uuid %}">{{ scrobble.beer.title }}</a>
{% elif scrobble.web_page %}
<a href="{% url 'webpages:webpage_detail' scrobble.web_page.uuid %}">{{ scrobble.web_page.title }}</a>
{% elif scrobble.podcast_episode %}
<a href="{% url 'podcasts:podcast_detail' scrobble.podcast_episode.podcast.id %}">{{ scrobble.podcast_episode.title }}</a>
{% elif scrobble.board_game %}
<a href="{% url 'boardgames:boardgame_detail' scrobble.board_game.uuid %}">{{ scrobble.board_game.title }}</a>
{% elif scrobble.trail %}
<a href="{% url 'trails:trail_detail' scrobble.trail.uuid %}">{{ scrobble.trail.title }}</a>
{% elif scrobble.puzzle %}
<a href="{% url 'puzzles:puzzle_detail' scrobble.puzzle.uuid %}">{{ scrobble.puzzle.title }}</a>
{% elif scrobble.brick_set %}
<a href="{% url 'bricksets:brickset_detail' scrobble.brick_set.uuid %}">{{ scrobble.brick_set.title }}</a>
{% elif scrobble.task %}
<a href="{% url 'tasks:task_detail' scrobble.task.uuid %}">{{scrobble.media_obj}}{% if scrobble.log.title %} - {{ scrobble.log.title }}{% endif %}</a>
{% elif scrobble.life_event %}
<a href="{% url 'lifeevents:lifeevent_detail' scrobble.life_event.uuid %}">{{ scrobble.life_event.title }}</a>
{% elif scrobble.mood %}
<a href="{% url 'moods:mood_detail' scrobble.mood.uuid %}">{{ scrobble.mood.title}}</a>
{% elif scrobble.geo_location %}
<a href="{% url 'locations:geolocation_detail' scrobble.geo_location.uuid %}">{{ scrobble.geo_location.title }}</a>
{% else %}
Unknown
{% endif %}
</td>
<td>
{% if scrobble.playback_position_seconds %}
{{ scrobble.playback_position_seconds|natural_duration }}
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="4">No scrobbles found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if page_obj.has_previous or page_obj.has_next %}
<nav>
<ul class="pagination">
{% if page_obj.has_previous %}
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}{% if tags_param %}&tags={{ tags_param }}{% endif %}">Previous</a></li>
{% endif %}
<li class="page-item"><span class="page-link">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span></li>
{% if page_obj.has_next %}
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}{% if tags_param %}&tags={{ tags_param }}{% endif %}">Next</a></li>
{% endif %}
</ul>
</nav>
{% endif %}
</main>
{% include "scrobbles/_scrobble_all_content.html" %}
{% endblock %}

View File

@ -73,24 +73,21 @@
<div class="btn-group me-2">
<a href="{% url 'charts:charts-home' %}" class="btn btn-sm btn-outline-secondary">Charts</a>
</div>
<div class="btn-group me-2">
<a href="{% url 'scrobbles:scrobble-list' %}" class="btn btn-sm btn-outline-secondary">Live</a>
</div>
{% if not user.profile.trends_disabled %}
<div class="btn-group me-2">
<a href="{% url 'trends:trends-home' %}" class="btn btn-sm btn-outline-secondary">Trends</a>
</div>
{% endif %}
<div class="btn-group me-2">
{% if user.profile.lastfm_username and not user.profile.lastfm_auto_import %}
<form action="{% url 'scrobbles:lastfm-import' %}" method="get">
<button type="submit" class="btn btn-sm btn-outline-secondary">Last.fm Sync</button>
</form>
{% endif %}
{% if prev_link %}
<a type="button" class="btn btn-sm btn-outline-secondary" href="{{prev_link}}"
data-bs-target="#">Previous</a>
{% endif %}
{% if today_link %}
<a type="button" class="btn btn-sm btn-outline-secondary" href="{{today_link}}"
data-bs-target="#">Today</a>
{% endif %}
{% if next_link %}
<a type="button" class="btn btn-sm btn-outline-secondary" href="{{next_link}}"
data-bs-target="#">Next</a>
{% endif %}
</div>
<div class="btn-group me-2">
{% if user.profile.lastfm_username and not user.profile.lastfm_auto_import %}
@ -105,19 +102,6 @@
data-bs-target="#exportModal">Export</button>
</div>
{% endif %}
<div class="dropdown">
<button type="button" class="btn btn-sm btn-outline-secondary dropdown-toggle" id="graphDateButton"
data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<div data-feather="calendar"></div>
{{title}}
</button>
<div class="dropdown-menu" data-bs-toggle="#graphDataChange" aria-labelledby="graphDateButton">
<a class="dropdown-item" href="?date=today">Today</a>
<a class="dropdown-item" href="?date=this_week">This week</a>
<a class="dropdown-item" href="?date=this_month">This month</a>
<a class="dropdown-item" href="?date=this_year">This year</a>
</div>
</div>
</div>
</div>

View File

@ -7,6 +7,9 @@ from rest_framework import routers
import vrobbler.apps.scrobbles.views as scrobbles_views
from vrobbler.apps.discgolf import urls as discgolf_urls
from vrobbler.apps.discgolf.api.views import DiscGolfCourseViewSet
from vrobbler.apps.boardgames import urls as boardgame_urls
from vrobbler.apps.boardgames.api.views import (
BoardGameViewSet,
@ -153,7 +156,7 @@ router.register(r"lifeevents", LifeEventViewSet)
router.register(r"birds", BirdViewSet)
router.register(r"birding-locations", BirdingLocationViewSet)
router.register(r"disc-golf-courses", DiscGolfCourseViewSet)
urlpatterns = [
path("api/v1/", include(router.urls)),
path("api/v1/auth", include("rest_framework.urls")),
@ -171,6 +174,7 @@ urlpatterns = [
path("", include(locations_urls, namespace="locations")),
path("", include(trails_urls, namespace="trails")),
path("", include(beers_urls, namespace="beers")),
path("", include(discgolf_urls, namespace="discgolf")),
path("", include(foods_urls, namespace="foods")),
path("", include(puzzles_urls, namespace="puzzles")),
path("", include(tasks_urls, namespace="tasks")),
@ -184,6 +188,7 @@ urlpatterns = [
path("", include(people_urls, namespace="people")),
path("", include(charts_urls, namespace="charts")),
path("", include(trends_urls, namespace="trends")),
path("", include("mcp_server.urls")),
path("", scrobbles_views.RecentScrobbleList.as_view(), name="vrobbler-home"),
]
if settings.DEBUG: