Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f2b67b38dc | |||
| 662ebe66b9 | |||
| 5e0dffdc7a | |||
| 2283a6c640 | |||
| 327ba94c63 | |||
| ee59cde882 | |||
| c7b4656679 | |||
| 04f9e00c9c | |||
| c2dabd1dac | |||
| 7a0cb8b9d0 | |||
| 1c2c570c4b | |||
| 0671ab432f | |||
| 893867419a | |||
| d9dfec81aa | |||
| 948fbc19bf | |||
| 7d708ad8a6 | |||
| e0505cb82c | |||
| ab6459e4b0 | |||
| c001248d1b | |||
| f1c777d4ef | |||
| 931488c288 | |||
| ab897fd848 | |||
| 4f189b4d66 | |||
| 1487504318 | |||
| 0655363a0d | |||
| dccc80c615 | |||
| 4f91d5b40b | |||
| cb01781615 | |||
| 1f5fada8b1 | |||
| 31888a85cb | |||
| 22d8b0787e | |||
| 8cc559752b | |||
| db3f9696fa | |||
| 407d570c82 | |||
| 033239260f | |||
| 9f854dc735 | |||
| f29272a853 | |||
| 4e56d9420a | |||
| 852a257159 | |||
| 68ff230f13 | |||
| 57a952a6d1 | |||
| 718fcf7392 | |||
| 52adcf83c7 | |||
| 0061623f7e |
219
PROJECT.org
219
PROJECT.org
@ -88,7 +88,8 @@ fetching and simple saving.
|
||||
*** Metadata sources
|
||||
**** Scraper
|
||||
|
||||
* Backlog [0/20] :vrobbler:project:personal:
|
||||
* Backlog [0/22] :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.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:
|
||||
:ID: 8cb34852-b18f-e794-cd9b-fb1ecad70a0d
|
||||
:END:
|
||||
** DONE [#A] Video game cleanup script should clear out broken images :metadata:videogames:
|
||||
:PROPERTIES:
|
||||
:ID: ca1f1ea9-0f79-082c-5ff7-867671faff4b
|
||||
:END:
|
||||
|
||||
* Version 55.1 [1/1]
|
||||
** DONE [#A] Clean up metadata scrapping for video games :metadata:videogames:
|
||||
:PROPERTIES:
|
||||
:ID: fbc421b5-21a3-4aed-9062-c59192ead065
|
||||
:END:
|
||||
|
||||
* Version 55.0 [3/3]
|
||||
** DONE [#B] Use pk ID for scrobble detail view, not uuid :scrobbles:
|
||||
:PROPERTIES:
|
||||
|
||||
2
Procfile
2
Procfile
@ -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
784
poetry.lock
generated
@ -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"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "55.0"
|
||||
version = "58.3"
|
||||
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
|
||||
|
||||
0
tests/discgolf_tests/__init__.py
Normal file
0
tests/discgolf_tests/__init__.py
Normal file
63
tests/discgolf_tests/conftest.py
Normal file
63
tests/discgolf_tests/conftest.py
Normal 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
|
||||
102
tests/discgolf_tests/test_models.py
Normal file
102
tests/discgolf_tests/test_models.py
Normal 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"
|
||||
150
tests/discgolf_tests/test_utils.py
Normal file
150
tests/discgolf_tests/test_utils.py
Normal 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 == ""
|
||||
58
tests/discgolf_tests/test_views.py
Normal file
58
tests/discgolf_tests/test_views.py
Normal 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()
|
||||
@ -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()
|
||||
|
||||
@ -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"
|
||||
|
||||
18
vrobbler/apps/books/migrations/0038_paper_pdf_file.py
Normal file
18
vrobbler/apps/books/migrations/0038_paper_pdf_file.py
Normal 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/"),
|
||||
),
|
||||
]
|
||||
@ -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",
|
||||
),
|
||||
]
|
||||
@ -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
|
||||
|
||||
149
vrobbler/apps/books/sources/crossref.py
Normal file
149
vrobbler/apps/books/sources/crossref.py
Normal 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
|
||||
142
vrobbler/apps/books/sources/scihub.py
Normal file
142
vrobbler/apps/books/sources/scihub.py
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
),
|
||||
]
|
||||
|
||||
@ -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]))
|
||||
|
||||
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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):
|
||||
|
||||
@ -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:
|
||||
|
||||
0
vrobbler/apps/discgolf/__init__.py
Normal file
0
vrobbler/apps/discgolf/__init__.py
Normal file
14
vrobbler/apps/discgolf/admin.py
Normal file
14
vrobbler/apps/discgolf/admin.py
Normal 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,
|
||||
]
|
||||
0
vrobbler/apps/discgolf/api/__init__.py
Normal file
0
vrobbler/apps/discgolf/api/__init__.py
Normal file
14
vrobbler/apps/discgolf/api/serializers.py
Normal file
14
vrobbler/apps/discgolf/api/serializers.py
Normal 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__"
|
||||
|
||||
|
||||
|
||||
13
vrobbler/apps/discgolf/api/views.py
Normal file
13
vrobbler/apps/discgolf/api/views.py
Normal 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]
|
||||
|
||||
|
||||
|
||||
6
vrobbler/apps/discgolf/apps.py
Normal file
6
vrobbler/apps/discgolf/apps.py
Normal file
@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DiscgolfConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "discgolf"
|
||||
0
vrobbler/apps/discgolf/management/__init__.py
Normal file
0
vrobbler/apps/discgolf/management/__init__.py
Normal file
83
vrobbler/apps/discgolf/migrations/0001_initial.py
Normal file
83
vrobbler/apps/discgolf/migrations/0001_initial.py
Normal 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,
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
0
vrobbler/apps/discgolf/migrations/__init__.py
Normal file
0
vrobbler/apps/discgolf/migrations/__init__.py
Normal file
90
vrobbler/apps/discgolf/models.py
Normal file
90
vrobbler/apps/discgolf/models.py
Normal 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"
|
||||
)
|
||||
14
vrobbler/apps/discgolf/urls.py
Normal file
14
vrobbler/apps/discgolf/urls.py
Normal 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",
|
||||
),
|
||||
]
|
||||
129
vrobbler/apps/discgolf/utils.py
Normal file
129
vrobbler/apps/discgolf/utils.py
Normal 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
|
||||
32
vrobbler/apps/discgolf/views.py
Normal file
32
vrobbler/apps/discgolf/views.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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)),
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -40,6 +40,7 @@ class UserProfileForm(forms.ModelForm):
|
||||
"enable_public_widgets",
|
||||
"widget_custom_css",
|
||||
"home_scrobble_limit",
|
||||
"live_now_playing",
|
||||
"weigh_in_units",
|
||||
]
|
||||
widgets = {
|
||||
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -98,6 +98,8 @@ 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,
|
||||
|
||||
@ -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",
|
||||
)
|
||||
|
||||
@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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")
|
||||
),
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
518
vrobbler/apps/scrobbles/mcp.py
Normal file
518
vrobbler/apps/scrobbles/mcp.py
Normal 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
|
||||
@ -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",
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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())
|
||||
|
||||
|
||||
@ -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))
|
||||
@ -1717,6 +1754,7 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView):
|
||||
for scrobble in day_map[day_num]:
|
||||
day_scrobbles.append(
|
||||
{
|
||||
"id": scrobble.pk,
|
||||
"uuid": scrobble.uuid,
|
||||
"emoji": self.MEDIA_EMOJI.get(scrobble.media_type, "📌"),
|
||||
"title": (
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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>
|
||||
@ -55,6 +55,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" %}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
89
vrobbler/apps/trends/trends/time_of_day.py
Normal file
89
vrobbler/apps/trends/trends/time_of_day.py
Normal 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,
|
||||
}
|
||||
@ -25,6 +25,7 @@ TIME_BOUND_TRENDS = {
|
||||
"mood-weather",
|
||||
"peak-hours",
|
||||
"reading-pace-vs-activity",
|
||||
"time-of-day-categories",
|
||||
"trending-up",
|
||||
"weekly-rhythm",
|
||||
}
|
||||
|
||||
@ -55,6 +55,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?",
|
||||
|
||||
@ -10,26 +10,27 @@ def hrs_to_secs(hrs: float) -> int:
|
||||
return int(hrs * 60 * 60)
|
||||
|
||||
|
||||
def lookup_game_from_hltb(name_or_id: str) -> Optional[dict]:
|
||||
def lookup_game_from_hltb(name_or_id: str, search_by_title: bool = False) -> Optional[dict]:
|
||||
"""Lookup game on HowLongToBeat.com via HLtB ID or a name string and return
|
||||
the data in a dictonary mapped to our internal game fields
|
||||
|
||||
"""
|
||||
hltb_game = {}
|
||||
|
||||
try:
|
||||
hltb_id = int(name_or_id)
|
||||
except ValueError:
|
||||
hltb_id = None
|
||||
if not search_by_title:
|
||||
try:
|
||||
hltb_id = int(name_or_id)
|
||||
except ValueError:
|
||||
hltb_id = None
|
||||
|
||||
if hltb_id:
|
||||
hltb_game = HowLongToBeat().search_from_id(hltb_id)
|
||||
logger.info(f"Found game on HLtB for ID {hltb_id}")
|
||||
if hltb_id:
|
||||
hltb_game = HowLongToBeat().search_from_id(hltb_id)
|
||||
logger.info(f"Found game on HLtB for ID {hltb_id}")
|
||||
|
||||
if not hltb_game:
|
||||
results = HowLongToBeat().search(name_or_id)
|
||||
if not results:
|
||||
logger.warn(f"Lookup of game on HLtB failed for ID {name_or_id}")
|
||||
logger.warn(f"Lookup of game on HLtB failed via search {name_or_id!r}")
|
||||
return
|
||||
|
||||
hltb_game = results[0]
|
||||
|
||||
@ -19,6 +19,7 @@ GAMES_URL = "https://api.igdb.com/v4/games"
|
||||
ALT_NAMES_URL = "https://api.igdb.com/v4/alternative_names"
|
||||
SCREENSHOT_URL = "https://api.igdb.com/v4/screenshots"
|
||||
COVER_URL = "https://api.igdb.com/v4/covers"
|
||||
PLATFORMS_URL = "https://api.igdb.com/v4/platforms"
|
||||
|
||||
IGDB_CLIENT_ID = getattr(settings, "IGDB_CLIENT_ID")
|
||||
IGDB_CLIENT_SECRET = getattr(settings, "IGDB_CLIENT_SECRET")
|
||||
@ -35,6 +36,20 @@ def get_igdb_token() -> str:
|
||||
return results.get("access_token")
|
||||
|
||||
|
||||
def lookup_platform_names(platform_ids: list, headers: dict) -> list:
|
||||
"""Resolve IGDB platform IDs to platform names"""
|
||||
if not platform_ids:
|
||||
return []
|
||||
ids_str = ",".join(str(pid) for pid in platform_ids)
|
||||
body = f"fields name; where id = ({ids_str});"
|
||||
resp = requests.post(PLATFORMS_URL, data=body, headers=headers)
|
||||
if resp.status_code != 200:
|
||||
logger.warn(f"Failed to resolve platform IDs {platform_ids}")
|
||||
return []
|
||||
results = json.loads(resp.content)
|
||||
return [p["name"] for p in results if "name" in p]
|
||||
|
||||
|
||||
def lookup_game_id_from_gdb(name: str) -> str:
|
||||
|
||||
headers = {
|
||||
@ -62,9 +77,10 @@ def lookup_game_id_from_gdb(name: str) -> str:
|
||||
"details": results.get("details"),
|
||||
},
|
||||
)
|
||||
# Sort our result by IDs so we always get the lowest ID, which is likely to be the least esoteric game
|
||||
results = sorted(results, key=lambda k: k.get("game", 250000))
|
||||
return results[0].get("game", "")
|
||||
# Sort results by release date (oldest first) to prefer the original game
|
||||
results = [r for r in results if r.get("game")]
|
||||
results = sorted(results, key=lambda k: k.get("published_at") or 9999999999)
|
||||
return results[0].get("game", "") if results else ""
|
||||
|
||||
|
||||
def lookup_game_from_igdb(name_or_igdb_id: str) -> Dict:
|
||||
@ -118,6 +134,16 @@ def lookup_game_from_igdb(name_or_igdb_id: str) -> Dict:
|
||||
for genre in game.get("genres"):
|
||||
genres.append(genre["name"])
|
||||
|
||||
platforms = []
|
||||
if "release_dates" in game.keys():
|
||||
platform_ids = set()
|
||||
for rd in game["release_dates"]:
|
||||
pid = rd.get("platform")
|
||||
if pid is not None:
|
||||
platform_ids.add(pid)
|
||||
if platform_ids:
|
||||
platforms = lookup_platform_names(list(platform_ids), headers)
|
||||
|
||||
game_dict = {
|
||||
"igdb_id": game.get("id"),
|
||||
"title": game.get("name"),
|
||||
@ -129,6 +155,7 @@ def lookup_game_from_igdb(name_or_igdb_id: str) -> Dict:
|
||||
"release_date": release_date,
|
||||
"summary": game.get("summary"),
|
||||
"genres": genres,
|
||||
"platforms": platforms,
|
||||
}
|
||||
|
||||
return game_dict
|
||||
|
||||
0
vrobbler/apps/videogames/management/__init__.py
Normal file
0
vrobbler/apps/videogames/management/__init__.py
Normal file
@ -0,0 +1,529 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MISSING_ALL = [
|
||||
"cover",
|
||||
"screenshot",
|
||||
"summary",
|
||||
"rating",
|
||||
"release_date",
|
||||
"release_year",
|
||||
"igdb_id",
|
||||
"hltb_id",
|
||||
]
|
||||
|
||||
MISSING_GROUPS = {
|
||||
"cover": lambda g: not bool(g.cover),
|
||||
"screenshot": lambda g: not bool(g.screenshot),
|
||||
"summary": lambda g: not g.summary,
|
||||
"rating": lambda g: g.rating is None,
|
||||
"release_date": lambda g: g.release_date is None,
|
||||
"release_year": lambda g: g.release_year is None,
|
||||
"igdb_id": lambda g: g.igdb_id is None,
|
||||
"hltb_id": lambda g: g.hltb_id is None,
|
||||
}
|
||||
|
||||
|
||||
def _game_matches(game, flags):
|
||||
if not flags:
|
||||
return False
|
||||
for flag in flags:
|
||||
fn = MISSING_GROUPS.get(flag)
|
||||
if fn and fn(game):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Backfill missing metadata on video games from IGDB and HowLongToBeat"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Commit changes to the database",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=100,
|
||||
help="Number of games to process per batch (default: 100)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sleep",
|
||||
type=float,
|
||||
default=0.5,
|
||||
help="Seconds to sleep between API calls (default: 0.5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Re-fetch metadata even if data already exists",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--game-id",
|
||||
type=int,
|
||||
help="Only process a specific game by ID",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fix-broken-images",
|
||||
action="store_true",
|
||||
help="Check and refetch broken/deleted game images (cover, screenshot, hltb_cover)",
|
||||
)
|
||||
for flag in MISSING_ALL:
|
||||
parser.add_argument(
|
||||
f"--missing-{flag}",
|
||||
dest="missing_flags",
|
||||
action="append_const",
|
||||
const=flag,
|
||||
help=f"Process games missing {flag}",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
dest="all_missing",
|
||||
help="Process games missing any metadata field",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from videogames.models import VideoGame
|
||||
|
||||
commit = options["commit"]
|
||||
batch_size = options["batch_size"]
|
||||
sleep_secs = options["sleep"]
|
||||
force = options["force"]
|
||||
game_id = options["game_id"]
|
||||
fix_broken_images = options.get("fix_broken_images", False)
|
||||
flags = options.get("missing_flags") or []
|
||||
all_missing = options["all_missing"]
|
||||
|
||||
if all_missing:
|
||||
flags = MISSING_ALL
|
||||
|
||||
if not flags and not game_id and not force and not fix_broken_images:
|
||||
self.stdout.write(
|
||||
"No filters specified. Use --all, --missing-*, --game-id, --force, or --fix-broken-images."
|
||||
)
|
||||
return
|
||||
|
||||
if game_id:
|
||||
qs = VideoGame.objects.filter(id=game_id)
|
||||
else:
|
||||
qs = VideoGame.objects.all()
|
||||
|
||||
if flags:
|
||||
qs = [g for g in qs.iterator() if _game_matches(g, flags)]
|
||||
else:
|
||||
qs = list(qs)
|
||||
|
||||
total = len(qs)
|
||||
self.stdout.write(f"Found {total} games to process")
|
||||
|
||||
if not commit:
|
||||
self.stdout.write(
|
||||
"Dry run — no API calls will be made. Use --commit to run lookups."
|
||||
)
|
||||
return
|
||||
|
||||
title_mismatches = []
|
||||
enriched = 0
|
||||
skipped = 0
|
||||
stats = {
|
||||
"cover_fixed": 0,
|
||||
"screenshot_fixed": 0,
|
||||
"summary_fixed": 0,
|
||||
"rating_fixed": 0,
|
||||
"release_date_fixed": 0,
|
||||
"release_year_fixed": 0,
|
||||
"igdb_id_found": 0,
|
||||
"hltb_id_found": 0,
|
||||
"images_fixed": 0,
|
||||
}
|
||||
|
||||
enriched_any = bool(flags or game_id or force)
|
||||
|
||||
if enriched_any:
|
||||
for batch_num, offset in enumerate(range(0, len(qs), batch_size)):
|
||||
batch = qs[offset : offset + batch_size]
|
||||
for game in batch:
|
||||
result = self._enrich_game(game, sleep_secs, force)
|
||||
self._check_retroarch_name(game, title_mismatches)
|
||||
if result:
|
||||
enriched += 1
|
||||
for key in stats:
|
||||
if result.get(key):
|
||||
stats[key] += 1
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
self.stdout.write(
|
||||
f" Batch {batch_num + 1}: {offset + len(batch)}/{total} — "
|
||||
f"enriched: {enriched}, skipped: {skipped}"
|
||||
)
|
||||
|
||||
if fix_broken_images:
|
||||
broken_stats = self._fix_broken_images(qs, sleep_secs)
|
||||
stats["images_fixed"] = broken_stats["images_fixed"]
|
||||
|
||||
self.stdout.write(
|
||||
f"\nResults (commit={commit}):\n"
|
||||
f" Games enriched: {enriched}\n"
|
||||
f" Games skipped: {skipped}\n"
|
||||
f" Covers fixed: {stats['cover_fixed']}\n"
|
||||
f" Screenshots fixed: {stats['screenshot_fixed']}\n"
|
||||
f" Summaries fixed: {stats['summary_fixed']}\n"
|
||||
f" Ratings fixed: {stats['rating_fixed']}\n"
|
||||
f" Release dates fixed: {stats['release_date_fixed']}\n"
|
||||
f" Release years fixed: {stats['release_year_fixed']}\n"
|
||||
f" IGDB IDs found: {stats['igdb_id_found']}\n"
|
||||
f" HLtB IDs found: {stats['hltb_id_found']}"
|
||||
)
|
||||
if fix_broken_images:
|
||||
self.stdout.write(f" Broken images fixed: {stats['images_fixed']}")
|
||||
|
||||
if title_mismatches:
|
||||
self.stdout.write("\nTitle vs retroarch_name mismatches (not auto-fixed):")
|
||||
for retroarch_name, title, game_id in title_mismatches:
|
||||
self.stdout.write(
|
||||
f" Game #{game_id}: retroarch_name={retroarch_name!r} vs title={title!r}"
|
||||
)
|
||||
|
||||
def _clean_retroarch_name(self, name):
|
||||
if not name:
|
||||
return ""
|
||||
name = name.strip()
|
||||
if "(" in name:
|
||||
name = name.split("(")[0].strip()
|
||||
return name
|
||||
|
||||
def _check_retroarch_name(self, game, mismatches):
|
||||
if not game.retroarch_name:
|
||||
return
|
||||
cleaned = self._clean_retroarch_name(game.retroarch_name)
|
||||
if cleaned.lower() != game.title.lower():
|
||||
mismatches.append((game.retroarch_name, game.title, game.id))
|
||||
if "retroarch-mismatch" not in game.tags.names():
|
||||
game.tags.add("retroarch-mismatch")
|
||||
self.stdout.write(
|
||||
f" [TAG] {game} — tagged as retroarch-mismatch"
|
||||
)
|
||||
|
||||
def _enrich_game(self, game, sleep_secs, force):
|
||||
from videogames.igdb import lookup_game_id_from_gdb, lookup_game_from_igdb
|
||||
from videogames.howlongtobeat import lookup_game_from_hltb
|
||||
|
||||
search_name = self._clean_retroarch_name(game.retroarch_name) or game.title
|
||||
|
||||
changed = {}
|
||||
|
||||
if not game.hltb_id:
|
||||
hltb_data = None
|
||||
if search_name:
|
||||
hltb_data = lookup_game_from_hltb(search_name, search_by_title=True)
|
||||
time.sleep(sleep_secs)
|
||||
if not hltb_data and game.title and game.title != search_name:
|
||||
hltb_data = lookup_game_from_hltb(game.title, search_by_title=True)
|
||||
time.sleep(sleep_secs)
|
||||
if hltb_data:
|
||||
result = self._apply_hltb_data(game, hltb_data, force)
|
||||
if result:
|
||||
changed.update(result)
|
||||
|
||||
igdb_data = None
|
||||
if not game.igdb_id and search_name:
|
||||
igdb_id = lookup_game_id_from_gdb(search_name)
|
||||
time.sleep(sleep_secs)
|
||||
if igdb_id:
|
||||
igdb_data = lookup_game_from_igdb(str(igdb_id))
|
||||
time.sleep(sleep_secs)
|
||||
elif game.igdb_id:
|
||||
igdb_data = lookup_game_from_igdb(str(game.igdb_id))
|
||||
time.sleep(sleep_secs)
|
||||
|
||||
if igdb_data:
|
||||
igdb_title = igdb_data.get("title", "")
|
||||
igdb_title_clean = self._clean_retroarch_name(igdb_title)
|
||||
if igdb_title_clean.lower() == search_name.lower():
|
||||
if game.igdb_id is None and igdb_data.get("igdb_id"):
|
||||
game.igdb_id = int(igdb_data["igdb_id"])
|
||||
game.save(update_fields=["igdb_id"])
|
||||
changed["igdb_id_found"] = True
|
||||
self.stdout.write(f" [IGDB_ID] {game} — found IGDB ID {game.igdb_id}")
|
||||
result = self._apply_igdb_data(game, igdb_data, force)
|
||||
if result:
|
||||
changed.update(result)
|
||||
else:
|
||||
self.stdout.write(
|
||||
f" [IGDB] {game} — title mismatch (IGDB: {igdb_title!r} vs expected: {search_name!r}), re-searching…"
|
||||
)
|
||||
resolved = False
|
||||
for candidate in (search_name, game.title if game.title != search_name else None):
|
||||
if not candidate:
|
||||
continue
|
||||
new_id = lookup_game_id_from_gdb(candidate)
|
||||
time.sleep(sleep_secs)
|
||||
if not new_id:
|
||||
continue
|
||||
new_data = lookup_game_from_igdb(str(new_id))
|
||||
time.sleep(sleep_secs)
|
||||
if not new_data:
|
||||
continue
|
||||
new_title = new_data.get("title", "")
|
||||
new_title_clean = self._clean_retroarch_name(new_title)
|
||||
if new_title_clean.lower() == candidate.lower():
|
||||
game.igdb_id = int(new_id)
|
||||
if new_title and new_title != game.title:
|
||||
game.title = new_title
|
||||
changed["title_updated"] = True
|
||||
self.stdout.write(f" [TITLE] {game} — updated title to {new_title!r} from IGDB")
|
||||
game.save(update_fields=["igdb_id"] + (["title"] if changed.get("title_updated") else []))
|
||||
changed["igdb_id_found"] = True
|
||||
self.stdout.write(f" [IGDB_ID] {game} — re-found IGDB ID {game.igdb_id}")
|
||||
if "igdb-mismatch" in game.tags.names():
|
||||
game.tags.remove("igdb-mismatch")
|
||||
self.stdout.write(f" [TAG] {game} — removed igdb-mismatch tag")
|
||||
result = self._apply_igdb_data(game, new_data, force)
|
||||
if result:
|
||||
changed.update(result)
|
||||
resolved = True
|
||||
break
|
||||
|
||||
if not resolved and "igdb-mismatch" not in game.tags.names():
|
||||
game.tags.add("igdb-mismatch")
|
||||
self.stdout.write(f" [TAG] {game} — tagged igdb-mismatch")
|
||||
|
||||
# If retroarch-mismatch tag exists but no longer applies, remove it
|
||||
if "retroarch-mismatch" in game.tags.names():
|
||||
cleaned = self._clean_retroarch_name(game.retroarch_name or "")
|
||||
if cleaned.lower() == game.title.lower():
|
||||
game.tags.remove("retroarch-mismatch")
|
||||
self.stdout.write(f" [TAG] {game} — removed retroarch-mismatch tag (title now matches)")
|
||||
|
||||
return changed if changed else None
|
||||
|
||||
def _apply_igdb_data(self, game, data, force):
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
import requests
|
||||
|
||||
changed = {
|
||||
"cover_fixed": False,
|
||||
"screenshot_fixed": False,
|
||||
"summary_fixed": False,
|
||||
"rating_fixed": False,
|
||||
"release_date_fixed": False,
|
||||
}
|
||||
|
||||
update_fields = []
|
||||
|
||||
if data.get("alternative_name") and not game.alternative_name:
|
||||
game.alternative_name = data["alternative_name"]
|
||||
update_fields.append("alternative_name")
|
||||
|
||||
if data.get("summary") and (not game.summary or force):
|
||||
game.summary = data["summary"]
|
||||
update_fields.append("summary")
|
||||
changed["summary_fixed"] = True
|
||||
|
||||
if data.get("rating") is not None and (game.rating is None or force):
|
||||
game.rating = data["rating"]
|
||||
update_fields.append("rating")
|
||||
changed["rating_fixed"] = True
|
||||
|
||||
if data.get("rating_count") is not None and (game.rating_count is None or force):
|
||||
game.rating_count = data["rating_count"]
|
||||
update_fields.append("rating_count")
|
||||
|
||||
if data.get("release_date") and (game.release_date is None or force):
|
||||
game.release_date = data["release_date"]
|
||||
update_fields.append("release_date")
|
||||
changed["release_date_fixed"] = True
|
||||
|
||||
if update_fields:
|
||||
game.save(update_fields=update_fields)
|
||||
self.stdout.write(f" [IGDB] {game} — {', '.join(update_fields)}")
|
||||
|
||||
cover_url = data.get("cover_url")
|
||||
if cover_url:
|
||||
r = requests.get(cover_url)
|
||||
if r.status_code == 200:
|
||||
fname = f"{game.title}_{game.uuid}.jpg"
|
||||
game.cover.save(fname, ContentFile(r.content), save=True)
|
||||
changed["cover_fixed"] = True
|
||||
self.stdout.write(f" [COVER] {game} — cover saved from IGDB")
|
||||
|
||||
screenshot_url = data.get("screenshot_url")
|
||||
if screenshot_url:
|
||||
r = requests.get(screenshot_url)
|
||||
if r.status_code == 200:
|
||||
fname = f"{game.title}_{game.uuid}.jpg"
|
||||
game.screenshot.save(fname, ContentFile(r.content), save=True)
|
||||
changed["screenshot_fixed"] = True
|
||||
self.stdout.write(f" [SCREENSHOT] {game} — screenshot saved from IGDB")
|
||||
|
||||
genres = data.get("genres", [])
|
||||
if genres:
|
||||
existing = set(game.genre.names())
|
||||
new_genres = [g for g in genres if g not in existing]
|
||||
if new_genres:
|
||||
game.genre.add(*new_genres)
|
||||
self.stdout.write(f" [GENRES] {game} — added {len(new_genres)} genres")
|
||||
|
||||
platforms = data.get("platforms", [])
|
||||
if platforms:
|
||||
existing = set(game.platforms.values_list("name", flat=True))
|
||||
new_platforms = [p for p in platforms if p not in existing]
|
||||
if new_platforms:
|
||||
from videogames.models import VideoGamePlatform
|
||||
|
||||
for name in new_platforms:
|
||||
p, _ = VideoGamePlatform.objects.get_or_create(name=name)
|
||||
game.platforms.add(p)
|
||||
self.stdout.write(f" [PLATFORMS] {game} — added {len(new_platforms)} platforms")
|
||||
|
||||
if "igdb-enriched" not in game.tags.names():
|
||||
game.tags.add("igdb-enriched")
|
||||
self.stdout.write(f" [TAG] {game} — tagged igdb-enriched")
|
||||
|
||||
return changed if any(changed.values()) else None
|
||||
|
||||
def _apply_hltb_data(self, game, data, force):
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
import requests
|
||||
|
||||
changed = {
|
||||
"hltb_id_found": False,
|
||||
"release_year_fixed": False,
|
||||
}
|
||||
|
||||
update_fields = []
|
||||
|
||||
hltb_title = data.get("title", "")
|
||||
if hltb_title and hltb_title != game.title:
|
||||
game.title = hltb_title
|
||||
update_fields.append("title")
|
||||
self.stdout.write(f" [TITLE] {game} — updated title to {hltb_title!r}")
|
||||
|
||||
if data.get("hltb_id") and (game.hltb_id is None or force):
|
||||
game.hltb_id = data["hltb_id"]
|
||||
update_fields.append("hltb_id")
|
||||
changed["hltb_id_found"] = True
|
||||
self.stdout.write(f" [HLTB_ID] {game} — found HLtB ID {data['hltb_id']}")
|
||||
|
||||
if data.get("release_year") and (game.release_year is None or force):
|
||||
game.release_year = data["release_year"]
|
||||
update_fields.append("release_year")
|
||||
changed["release_year_fixed"] = True
|
||||
|
||||
if data.get("main_story_time") and (game.main_story_time is None or force):
|
||||
game.main_story_time = data["main_story_time"]
|
||||
update_fields.append("main_story_time")
|
||||
|
||||
if data.get("main_extra_time") and (game.main_extra_time is None or force):
|
||||
game.main_extra_time = data["main_extra_time"]
|
||||
update_fields.append("main_extra_time")
|
||||
|
||||
if data.get("completionist_time") and (game.completionist_time is None or force):
|
||||
game.completionist_time = data["completionist_time"]
|
||||
update_fields.append("completionist_time")
|
||||
|
||||
if data.get("hltb_score") is not None and (game.hltb_score is None or force):
|
||||
game.hltb_score = data["hltb_score"]
|
||||
update_fields.append("hltb_score")
|
||||
|
||||
if update_fields:
|
||||
game.save(update_fields=update_fields)
|
||||
self.stdout.write(f" [HLTB] {game} — {', '.join(update_fields)}")
|
||||
|
||||
cover_url = data.get("cover_url")
|
||||
if cover_url:
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
r = requests.get(cover_url, headers=headers)
|
||||
if r.status_code == 200:
|
||||
fname = f"{game.title}_cover_{game.uuid}.jpg"
|
||||
game.hltb_cover.save(fname, ContentFile(r.content), save=True)
|
||||
self.stdout.write(f" [HLTB_COVER] {game} — cover saved from HLtB")
|
||||
|
||||
platforms = data.get("platforms", [])
|
||||
if platforms:
|
||||
existing = set(game.platforms.values_list("name", flat=True))
|
||||
new_platforms = [p for p in platforms if p not in existing]
|
||||
if new_platforms:
|
||||
from videogames.models import VideoGamePlatform
|
||||
|
||||
for name in new_platforms:
|
||||
p, _ = VideoGamePlatform.objects.get_or_create(name=name)
|
||||
game.platforms.add(p)
|
||||
self.stdout.write(f" [PLATFORMS] {game} — added {len(new_platforms)} platforms")
|
||||
|
||||
if "hltb-enriched" not in game.tags.names():
|
||||
game.tags.add("hltb-enriched")
|
||||
self.stdout.write(f" [TAG] {game} — tagged hltb-enriched")
|
||||
|
||||
return changed if any(changed.values()) else None
|
||||
|
||||
def _fix_broken_images(self, games, sleep_secs):
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
import requests
|
||||
|
||||
from videogames.igdb import lookup_game_from_igdb
|
||||
from videogames.howlongtobeat import lookup_game_from_hltb
|
||||
|
||||
stats = {"cover_fixed": 0, "screenshot_fixed": 0, "images_fixed": 0}
|
||||
|
||||
for game in games:
|
||||
for field_name, source in [
|
||||
("cover", "igdb"),
|
||||
("screenshot", "igdb"),
|
||||
("hltb_cover", "hltb"),
|
||||
]:
|
||||
field = getattr(game, field_name)
|
||||
if not field.name:
|
||||
continue
|
||||
if field.storage.exists(field.name):
|
||||
continue
|
||||
|
||||
self.stdout.write(
|
||||
f" [IMAGE] {game} — {field_name} is broken (file missing), refetching…"
|
||||
)
|
||||
|
||||
if source == "igdb" and game.igdb_id:
|
||||
data = lookup_game_from_igdb(str(game.igdb_id))
|
||||
time.sleep(sleep_secs)
|
||||
if not data:
|
||||
continue
|
||||
url = data.get("cover_url" if field_name == "cover" else "screenshot_url")
|
||||
if not url:
|
||||
continue
|
||||
r = requests.get(url)
|
||||
if r.status_code != 200:
|
||||
continue
|
||||
fname = f"{game.title}_{game.uuid}.jpg"
|
||||
getattr(game, field_name).save(fname, ContentFile(r.content), save=True)
|
||||
stats["images_fixed"] += 1
|
||||
self.stdout.write(f" [IMAGE] {game} — {field_name} refetched from IGDB")
|
||||
|
||||
elif source == "hltb" and game.hltb_id:
|
||||
data = lookup_game_from_hltb(str(game.hltb_id))
|
||||
time.sleep(sleep_secs)
|
||||
if not data:
|
||||
continue
|
||||
url = data.get("cover_url")
|
||||
if not url:
|
||||
continue
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
r = requests.get(url, headers=headers)
|
||||
if r.status_code != 200:
|
||||
continue
|
||||
fname = f"{game.title}_cover_{game.uuid}.jpg"
|
||||
game.hltb_cover.save(fname, ContentFile(r.content), save=True)
|
||||
stats["images_fixed"] += 1
|
||||
self.stdout.write(f" [IMAGE] {game} — hltb_cover refetched from HLtB")
|
||||
|
||||
return stats
|
||||
@ -215,12 +215,16 @@ class VideoGame(LongPlayScrobblableMixin):
|
||||
def fix_metadata(self, force_update: bool = False):
|
||||
from videogames.utils import (
|
||||
get_or_create_videogame,
|
||||
load_game_data_from_hltb,
|
||||
load_game_data_from_igdb,
|
||||
)
|
||||
|
||||
if self.hltb_id and force_update:
|
||||
get_or_create_videogame(str(self.hltb_id), force_update)
|
||||
|
||||
if not self.hltb_id:
|
||||
load_game_data_from_hltb(self.id)
|
||||
|
||||
if not self.igdb_id:
|
||||
# This almost never works without intervention
|
||||
self.igdb_id = lookup_game_id_from_gdb(self.title)
|
||||
|
||||
@ -153,6 +153,13 @@ def import_retroarch_lrtl_files(playlog_path: str, user_id: int) -> List[dict]:
|
||||
continue
|
||||
|
||||
logger.info(f"Queued scrobble for game {found_game.id}")
|
||||
|
||||
log_data = {"emulated": True}
|
||||
if last_scrobble and last_scrobble.log:
|
||||
prev = last_scrobble.log
|
||||
if prev.get("emulator"):
|
||||
log_data["emulator"] = prev["emulator"]
|
||||
|
||||
new_scrobbles.append(
|
||||
Scrobble(
|
||||
video_game_id=found_game.id,
|
||||
@ -168,6 +175,7 @@ def import_retroarch_lrtl_files(playlog_path: str, user_id: int) -> List[dict]:
|
||||
user_id=user_id,
|
||||
source="Retroarch",
|
||||
media_type=Scrobble.MediaType.VIDEO_GAME,
|
||||
log=log_data,
|
||||
)
|
||||
)
|
||||
created_scrobbles = Scrobble.objects.bulk_create(new_scrobbles)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -7,8 +7,6 @@ from videogames.howlongtobeat import lookup_game_from_hltb
|
||||
from videogames.igdb import lookup_game_from_igdb
|
||||
from videogames.models import VideoGame, VideoGamePlatform
|
||||
|
||||
from vrobbler.apps.videogames.exceptions import GameNotFound
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -16,22 +14,33 @@ def get_or_create_videogame(
|
||||
name_or_id: str,
|
||||
force_update: bool = False,
|
||||
) -> Optional[VideoGame]:
|
||||
"""Look up game by name or ID from HowLongToBeat"""
|
||||
"""Look up game by name or ID from HowLongToBeat, then enrich with IGDB"""
|
||||
|
||||
game_dict = lookup_game_from_hltb(name_or_id)
|
||||
hltb_data = lookup_game_from_hltb(name_or_id)
|
||||
|
||||
if not game_dict:
|
||||
game_dict = lookup_game_from_igdb(name_or_id)
|
||||
if hltb_data:
|
||||
game = _create_update_from_dict(hltb_data, force_update)
|
||||
else:
|
||||
igdb_data = lookup_game_from_igdb(name_or_id)
|
||||
if igdb_data:
|
||||
game = _create_update_from_dict(igdb_data, force_update)
|
||||
else:
|
||||
return None
|
||||
|
||||
if not game_dict:
|
||||
return
|
||||
if game:
|
||||
game.fix_metadata()
|
||||
return game
|
||||
|
||||
|
||||
def _create_update_from_dict(
|
||||
game_dict: dict, force_update: bool = False
|
||||
) -> Optional[VideoGame]:
|
||||
|
||||
# Create missing platforms and prep for loading after create
|
||||
platform_ids = []
|
||||
if "platforms" in game_dict.keys():
|
||||
platforms = game_dict.get("platforms", [])
|
||||
if platforms:
|
||||
for platform in game_dict.get("platforms", []):
|
||||
for platform in platforms:
|
||||
p, _created = VideoGamePlatform.objects.get_or_create(name=platform)
|
||||
platform_ids.append(p.id)
|
||||
game_dict.pop("platforms")
|
||||
@ -48,7 +57,7 @@ def get_or_create_videogame(
|
||||
|
||||
title = game_dict.get("title")
|
||||
if not title:
|
||||
raise GameNotFound(name_or_id)
|
||||
return None
|
||||
|
||||
hltb_id = game_dict.get("hltb_id")
|
||||
igdb_id = game_dict.get("igdb_id")
|
||||
@ -69,21 +78,19 @@ def get_or_create_videogame(
|
||||
VideoGame.objects.filter(pk=game.id).update(**game_dict)
|
||||
game.refresh_from_db()
|
||||
|
||||
# Associate plaforms
|
||||
if platform_ids:
|
||||
game.platforms.add(*platform_ids)
|
||||
|
||||
if genres:
|
||||
game.genre.add(*genres)
|
||||
|
||||
if not game.screenshot and screenshot_url:
|
||||
if screenshot_url:
|
||||
r = requests.get(screenshot_url)
|
||||
if r.status_code == 200:
|
||||
fname = f"{game.title}_{game.uuid}.jpg"
|
||||
game.screenshot.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
# Go get cover image if the URL is present
|
||||
if cover_url and not game.hltb_cover:
|
||||
if cover_url:
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
r = requests.get(cover_url, headers=headers)
|
||||
logger.debug(r.status_code)
|
||||
@ -91,12 +98,89 @@ def get_or_create_videogame(
|
||||
fname = f"{game.title}_cover_{game.uuid}.jpg"
|
||||
game.hltb_cover.save(fname, ContentFile(r.content), save=True)
|
||||
logger.debug("Loaded cover image from HLtB")
|
||||
game.fix_metadata()
|
||||
|
||||
tag = "hltb-enriched" if hltb_id else "igdb-enriched"
|
||||
if tag not in game.tags.names():
|
||||
game.tags.add(tag)
|
||||
logger.info(f"Game {game} tagged {tag}")
|
||||
|
||||
return game
|
||||
|
||||
|
||||
def load_game_data_from_igdb(game_id: int, igdb_id: str = "") -> Optional[VideoGame]:
|
||||
def load_game_data_from_hltb(
|
||||
game_id: int, expected_title: str = ""
|
||||
) -> Optional[VideoGame]:
|
||||
"""Look up HLtB data for an existing game and apply it"""
|
||||
game = VideoGame.objects.filter(id=game_id).first()
|
||||
if not game:
|
||||
logger.warn(f"Video game with ID {game_id} not found")
|
||||
return
|
||||
|
||||
logger.info(f"Looking up HLtB data for {game}")
|
||||
hltb_data = lookup_game_from_hltb(game.title, search_by_title=True)
|
||||
if not hltb_data:
|
||||
logger.warn(f"No HLtB data found for {game}")
|
||||
return
|
||||
|
||||
update_fields = []
|
||||
|
||||
hltb_title = hltb_data.get("title", "")
|
||||
if hltb_title and hltb_title != game.title:
|
||||
game.title = hltb_title
|
||||
update_fields.append("title")
|
||||
logger.info(f"Game {game.id} title updated to {hltb_title!r}")
|
||||
|
||||
if hltb_data.get("hltb_id") and (game.hltb_id is None):
|
||||
game.hltb_id = hltb_data["hltb_id"]
|
||||
update_fields.append("hltb_id")
|
||||
|
||||
if hltb_data.get("release_year") and (game.release_year is None):
|
||||
game.release_year = hltb_data["release_year"]
|
||||
update_fields.append("release_year")
|
||||
|
||||
if hltb_data.get("main_story_time") and (game.main_story_time is None):
|
||||
game.main_story_time = hltb_data["main_story_time"]
|
||||
update_fields.append("main_story_time")
|
||||
|
||||
if hltb_data.get("main_extra_time") and (game.main_extra_time is None):
|
||||
game.main_extra_time = hltb_data["main_extra_time"]
|
||||
update_fields.append("main_extra_time")
|
||||
|
||||
if hltb_data.get("completionist_time") and (game.completionist_time is None):
|
||||
game.completionist_time = hltb_data["completionist_time"]
|
||||
update_fields.append("completionist_time")
|
||||
|
||||
if hltb_data.get("hltb_score") is not None and (game.hltb_score is None):
|
||||
game.hltb_score = hltb_data["hltb_score"]
|
||||
update_fields.append("hltb_score")
|
||||
|
||||
if update_fields:
|
||||
game.save(update_fields=update_fields)
|
||||
|
||||
platforms = hltb_data.get("platforms", [])
|
||||
if platforms:
|
||||
for name in platforms:
|
||||
p, _ = VideoGamePlatform.objects.get_or_create(name=name)
|
||||
game.platforms.add(p)
|
||||
|
||||
cover_url = hltb_data.get("cover_url")
|
||||
if cover_url:
|
||||
headers = {"User-Agent": "Vrobbler 0.11.12"}
|
||||
r = requests.get(cover_url, headers=headers)
|
||||
if r.status_code == 200:
|
||||
fname = f"{game.title}_cover_{game.uuid}.jpg"
|
||||
game.hltb_cover.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
if "hltb-enriched" not in game.tags.names():
|
||||
game.tags.add("hltb-enriched")
|
||||
logger.info(f"Game {game} tagged hltb-enriched")
|
||||
|
||||
return game
|
||||
|
||||
|
||||
def load_game_data_from_igdb(
|
||||
game_id: int, igdb_id: str = "", expected_title: str = ""
|
||||
) -> Optional[VideoGame]:
|
||||
"""Look up game, if it doesn't exist, lookup data from igdb"""
|
||||
game = VideoGame.objects.filter(id=game_id).first()
|
||||
if not game:
|
||||
@ -116,25 +200,68 @@ def load_game_data_from_igdb(game_id: int, igdb_id: str = "") -> Optional[VideoG
|
||||
logger.warn(f"No game data found on IGDB for ID {igdb_id}")
|
||||
return
|
||||
|
||||
igdb_title = game_dict.get("title", "")
|
||||
igdb_title_clean = igdb_title.split(" (")[0].strip() if " (" in igdb_title else igdb_title
|
||||
expected = expected_title or game.title
|
||||
if igdb_title_clean.lower() != expected.lower():
|
||||
logger.info(
|
||||
f"IGDB title {igdb_title!r} doesn't match expected {expected!r} for {game} — re-searching…"
|
||||
)
|
||||
from videogames.igdb import lookup_game_id_from_gdb
|
||||
|
||||
new_id = lookup_game_id_from_gdb(expected)
|
||||
if new_id:
|
||||
new_data = lookup_game_from_igdb(str(new_id))
|
||||
if new_data:
|
||||
new_title = new_data.get("title", "")
|
||||
new_title_clean = new_title.split(" (")[0].strip() if " (" in new_title else new_title
|
||||
if new_title_clean.lower() == expected.lower():
|
||||
game_dict = new_data
|
||||
igdb_id = int(new_id)
|
||||
if game.igdb_id != igdb_id:
|
||||
game.igdb_id = igdb_id
|
||||
game.save(update_fields=["igdb_id"])
|
||||
logger.info(f"Game {game} IGDB ID updated to {igdb_id}")
|
||||
|
||||
igdb_title = game_dict.get("title", "")
|
||||
igdb_title_clean = igdb_title.split(" (")[0].strip() if " (" in igdb_title else igdb_title
|
||||
if igdb_title_clean.lower() != expected.lower():
|
||||
if "igdb-mismatch" not in game.tags.names():
|
||||
game.tags.add("igdb-mismatch")
|
||||
logger.info(
|
||||
f"Game {game} tagged igdb-mismatch (IGDB: {igdb_title!r} vs expected: {expected!r})"
|
||||
)
|
||||
return
|
||||
|
||||
screenshot_url = game_dict.pop("screenshot_url")
|
||||
cover_url = game_dict.pop("cover_url")
|
||||
genres = game_dict.pop("genres")
|
||||
platforms = game_dict.pop("platforms", [])
|
||||
|
||||
VideoGame.objects.filter(pk=game.id).update(**game_dict)
|
||||
game.refresh_from_db()
|
||||
|
||||
game.genre.add(*genres)
|
||||
|
||||
if not game.screenshot and screenshot_url:
|
||||
if platforms:
|
||||
for name in platforms:
|
||||
p, _ = VideoGamePlatform.objects.get_or_create(name=name)
|
||||
game.platforms.add(p)
|
||||
|
||||
if screenshot_url:
|
||||
r = requests.get(screenshot_url)
|
||||
if r.status_code == 200:
|
||||
fname = f"{game.title}_{game.uuid}.jpg"
|
||||
game.screenshot.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
if not game.cover and cover_url:
|
||||
if cover_url:
|
||||
r = requests.get(cover_url)
|
||||
if r.status_code == 200:
|
||||
fname = f"{game.title}_{game.uuid}.jpg"
|
||||
game.cover.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
if "igdb-enriched" not in game.tags.names():
|
||||
game.tags.add("igdb-enriched")
|
||||
logger.info(f"Game {game} tagged igdb-enriched")
|
||||
|
||||
return game
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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 %}
|
||||
|
||||
62
vrobbler/templates/books/paper_detail.html
Normal file
62
vrobbler/templates/books/paper_detail.html
Normal 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 %}
|
||||
13
vrobbler/templates/books/paper_list.html
Normal file
13
vrobbler/templates/books/paper_list.html
Normal 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 %}
|
||||
101
vrobbler/templates/discgolf/discgolfcourse_detail.html
Normal file
101
vrobbler/templates/discgolf/discgolfcourse_detail.html
Normal 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: '© <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 %}
|
||||
13
vrobbler/templates/discgolf/discgolfcourse_list.html
Normal file
13
vrobbler/templates/discgolf/discgolfcourse_list.html
Normal 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 %}
|
||||
@ -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">
|
||||
|
||||
|
||||
21
vrobbler/templates/scrobbles/_now_playing.html
Normal file
21
vrobbler/templates/scrobbles/_now_playing.html
Normal 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 %}
|
||||
146
vrobbler/templates/scrobbles/_scrobble_all_content.html
Normal file
146
vrobbler/templates/scrobbles/_scrobble_all_content.html
Normal 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>
|
||||
@ -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 %}
|
||||
|
||||
@ -73,24 +73,19 @@
|
||||
<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>
|
||||
<div class="btn-group me-2">
|
||||
<a href="{% url 'trends:trends-home' %}" class="btn btn-sm btn-outline-secondary">Trends</a>
|
||||
</div>
|
||||
<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 +100,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>
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
Reference in New Issue
Block a user