Compare commits

...

18 Commits
58.0 ... 58.7

Author SHA1 Message Date
1695f7393e [release] Bump to version 58.7
All checks were successful
ci / test (push) Successful in 2m9s
ci / build-and-deploy (push) Successful in 33s
- Split up chart page between tables and maloja
- Fix CI so we don't double run deploys and builds
2026-06-30 16:30:16 -04:00
4468e68110 [charts] Split maloja charts out from tables 2026-06-30 16:29:56 -04:00
da08eca4ab [ci] Fix split in files 2026-06-30 16:26:07 -04:00
08752e30a4 [release] Bump to version 58.6
All checks were successful
build / test (push) Successful in 2m5s
deploy / test (push) Successful in 2m17s
deploy / build-and-deploy (push) Successful in 34s
- Cleanup commands should check for broken images
2026-06-30 16:04:37 -04:00
619718c045 [charts] Fix chart page missing tables 2026-06-30 16:04:09 -04:00
cb23d5a5be [metadata] Fix cleanup scripts to check for dead images 2026-06-30 16:03:29 -04:00
ec4c190e6c [release] Bump to version 58.5
All checks were successful
build / test (push) Successful in 2m15s
deploy / test (push) Successful in 2m14s
deploy / build-and-deploy (push) Successful in 56s
- The maloja style charts are messed up
2026-06-30 15:02:16 -04:00
58126928c7 [charts] Fix maloja charts acting weird
Some checks failed
build / test (push) Has been cancelled
2026-06-30 15:01:57 -04:00
c0d2881585 [release] Bump to version 58.4
All checks were successful
build / test (push) Successful in 2m13s
deploy / test (push) Successful in 2m10s
deploy / build-and-deploy (push) Successful in 53s
- Allow people all trends or individual trends
- Fix a bug in board game scorelog data
2026-06-25 20:02:34 -04:00
41a68291a4 [trends] Allow disabling one or many or all trends
All checks were successful
build / test (push) Successful in 2m22s
2026-06-25 18:58:23 -04:00
0a411bedf4 [boardgames] Fix bug in logdata
All checks were successful
build / test (push) Successful in 2m23s
2026-06-24 19:09:43 -04:00
f2b67b38dc [release] Bump to version 58.3
All checks were successful
build / test (push) Successful in 2m2s
deploy / test (push) Successful in 2m5s
deploy / build-and-deploy (push) Successful in 34s
- Remove curl-cffi as it doesn't work on FreeBSD
2026-06-23 23:19:10 -04:00
662ebe66b9 [webpages] Remove curl_cffi as it doesn't work on FreeBSD 2026-06-23 23:17:08 -04:00
5e0dffdc7a [release] Bump to version 58.2
Some checks failed
build / test (push) Successful in 2m5s
deploy / test (push) Successful in 2m3s
deploy / build-and-deploy (push) Failing after 25s
- Add more robust webpage scraping
- Time of Day Categories trend
2026-06-23 23:04:48 -04:00
2283a6c640 [webpages] Add more robust scraping 2026-06-23 23:04:30 -04:00
327ba94c63 [trends] Add new time of day trend
All checks were successful
build / test (push) Successful in 2m4s
2026-06-23 22:21:18 -04:00
ee59cde882 [release] Bump to version 58.1
All checks were successful
build / test (push) Successful in 2m7s
deploy / test (push) Successful in 2m8s
deploy / build-and-deploy (push) Successful in 1m5s
- Add auto genre tagging for papers
2026-06-23 16:19:06 -04:00
c7b4656679 [papers] Add genre tagging 2026-06-23 16:18:50 -04:00
33 changed files with 1441 additions and 395 deletions

View File

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

View File

@ -1,8 +1,14 @@
name: deploy
name: ci
on:
push:
branches: ["**"]
tags: ["*"]
pull_request:
concurrency:
group: ${{ gitea.workflow }}
cancel-in-progress: false
jobs:
test:
@ -68,6 +74,7 @@ jobs:
build-and-deploy:
needs: [test]
if: startsWith(gitea.ref, 'refs/tags/')
runs-on: ubuntu-latest
steps:

View File

@ -88,7 +88,8 @@ fetching and simple saving.
*** Metadata sources
**** Scraper
* Backlog [0/22] :vrobbler:project:personal:
* Backlog [0/23] :vrobbler:project:personal:
** TODO [#C] After transition to linux add curl_cffi as webpage scrapper again :webpages:metadata:
** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :bug:music:scrobbles:
:PROPERTIES:
:ID: 702462cf-d54b-48c6-8a7c-78b8de751deb
@ -604,6 +605,67 @@ 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.7 [2/2]
** DONE [#B] Split up chart page between tables and maloja :charts:templates:
:PROPERTIES:
:ID: 103ab084-2016-cfa4-c677-3c5fdc54cce0
:END:
** DONE [#A] Fix CI so we don't double run deploys and builds :ci:
:PROPERTIES:
:ID: 1a93e7cb-b883-aae5-2bd5-fcdd6e16f8ab
:END:
* Version 58.6 [1/1]
** DONE [#B] Cleanup commands should check for broken images :metadata:cleanup:
:PROPERTIES:
:ID: bacce321-73c7-ae1f-bfa7-c3ee517b5441
:END:
* Version 58.5 [1/1]
** DONE [#A] The maloja style charts are messed up :templates:charts:
:PROPERTIES:
:ID: 987397a2-7e74-4eb1-87cc-4c8bbe1c7b23
:END:
* Version 58.4 [2/2]
** DONE [#B] Allow people all trends or individual trends :trends:profiles:
:PROPERTIES:
:ID: 1d081152-abd1-73c2-a625-903565a10c6c
:END:
** DONE [#A] Fix a bug in board game scorelog data :boardgames:logdata:
:PROPERTIES:
:ID: 014bab30-13bf-fae7-e678-4666a8d38ae4
:END:
* Version 58.3 [1/1]
** DONE [#A] Remove curl-cffi as it doesn't work on FreeBSD :webpages:deps:
:PROPERTIES:
:ID: 6bc1b0dd-e449-3d32-a176-46451e793e5d
:END:
* Version 58.2 [2/2]
** DONE [#B] Add more robust webpage scraping :webpages:metadata:
:PROPERTIES:
:ID: 84d9bfa5-75c0-0718-764e-379f7456602a
:END:
** DONE [#B] Time of Day Categories trend :trends:
:PROPERTIES:
:ID: 6598074f-2290-46db-967b-29f45d30be29
:END:
*** Description
Added a "Time of Day Categories" trend that groups scrobbles for Books, Trails,
Birding Locations, and Board Games into Early Bird (5-10:59am), Day Jay (11am-6:59pm),
and Night Owl (7pm-4:59am) buckets. Shows both overall and per-media-type breakdowns.
* Version 58.1 [1/1]
** DONE [#B] Add auto genre tagging for papers :books:papers:metadata:
:PROPERTIES:
:ID: e6b5c3a5-7fc6-b530-96c2-b5962a716db6
:END:
* Version 58.0 [1/1]
** DONE [#B] Add scrobbling of Papers via webpages with doi.org links in them :feature:papers:
:PROPERTIES:

355
poetry.lock generated
View File

@ -967,6 +967,23 @@ prompt-toolkit = ">=3.0.36"
[package.extras]
testing = ["pytest (>=7.2.1)", "pytest-cov (>=4.0.0)", "tox (>=4.4.3)"]
[[package]]
name = "cloudscraper"
version = "1.2.71"
description = "A Python module to bypass Cloudflare's anti-bot page."
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "cloudscraper-1.2.71-py2.py3-none-any.whl", hash = "sha256:76f50ca529ed2279e220837befdec892626f9511708e200d48d5bb76ded679b0"},
{file = "cloudscraper-1.2.71.tar.gz", hash = "sha256:429c6e8aa6916d5bad5c8a5eac50f3ea53c9ac22616f6cb21b18dcc71517d0d3"},
]
[package.dependencies]
pyparsing = ">=2.4.7"
requests = ">=2.9.2"
requests-toolbelt = ">=0.9.1"
[[package]]
name = "colorama"
version = "0.4.6"
@ -2430,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"
@ -3247,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"
@ -5256,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"
@ -5450,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"
@ -6197,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"
@ -6476,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 = "02f8b551a54c53fa2a51622af17ef844e40aa8505c395ed02a7b87eb05647cc5"
content-hash = "beac677c269bb8618ca802e5f92f7558391d8d26f1b2150f3c8a6d3417848cb1"

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "vrobbler"
version = "58.0"
version = "58.7"
description = ""
authors = ["Colin Powell <colin@unbl.ink>"]
@ -11,6 +11,7 @@ django-extensions = "^3.1.5"
python-dateutil = "^2.8.2"
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"
@ -66,6 +67,7 @@ lxml = ">=5.5.0"
vaderSentiment = "^3.3.2"
sqids = "^0.5.2"
python-amazon-paapi = "^6.3.0"
yake = "^0.7.3"
[tool.poetry.group.test]
optional = true

View File

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

View File

@ -18,8 +18,17 @@ MISSING_ALL = [
"publish_year",
]
def _cover_missing_or_broken(book) -> bool:
if not bool(book.cover):
return True
try:
return not book.cover.storage.exists(book.cover.name)
except Exception:
return True
MISSING_GROUPS = {
"cover": lambda b: not bool(b.cover),
"cover": _cover_missing_or_broken,
"summary": lambda b: not b.summary,
"isbn": lambda b: not b.isbn_13 and not b.isbn_10,
"pages": lambda b: b.pages is None,

View File

@ -3,11 +3,40 @@ 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:
@ -17,6 +46,28 @@ def _strip_jats(text: str) -> str:
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)"}
@ -46,7 +97,11 @@ def lookup_paper_from_crossref(doi: str) -> dict:
abstract = msg.get("abstract", "")
if abstract:
paper_dict["abstract"] = _strip_jats(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", []):

View File

@ -3,6 +3,7 @@ from charts.views import (
BirdsChartView,
ChartDetailView,
ChartRecordView,
MalojaChartsView,
SpotifyTracksView,
)
from django.urls import path
@ -11,6 +12,7 @@ app_name = "charts"
urlpatterns = [
path("charts/", ChartRecordView.as_view(), name="charts-home"),
path("charts/maloja/", MalojaChartsView.as_view(), name="maloja-charts"),
path("charts/spotify/", SpotifyTracksView.as_view(), name="spotify-tracks"),
path("charts/bandcamp/", BandcampTracksView.as_view(), name="bandcamp-tracks"),
path("charts/birds/", BirdsChartView.as_view(), name="birds-chart"),

View File

@ -114,175 +114,13 @@ class ChartRecordView(TemplateView):
context["current_week"] = current_week
context["current_day"] = current_day
context["chart_keys"] = {
"today": "Today",
"week": "This Week",
"month": "This Month",
"year": "This Year",
"all": "All Time",
}
context["maloja_charts"] = {
"artist": {
"today": list(
self.get_charts_for_period(
user,
"artist",
year=current_year,
month=current_month,
day=current_day,
)
),
"week": list(
self.get_charts_for_period(
user,
"artist",
year=current_year,
week=current_week,
)
),
"month": list(
self.get_charts_for_period(
user,
"artist",
year=current_year,
month=current_month,
)
),
"year": list(
self.get_charts_for_period(user, "artist", year=current_year)
),
"all": list(self.get_charts_for_period(user, "artist")),
},
"album": {
"today": list(
self.get_charts_for_period(
user,
"album",
year=current_year,
month=current_month,
day=current_day,
)
),
"week": list(
self.get_charts_for_period(
user, "album", year=current_year, week=current_week
)
),
"month": list(
self.get_charts_for_period(
user,
"album",
year=current_year,
month=current_month,
)
),
"year": list(
self.get_charts_for_period(user, "album", year=current_year)
),
"all": list(self.get_charts_for_period(user, "album")),
},
"tv_series": {
"today": list(
self.get_charts_for_period(
user,
"tv_series",
year=current_year,
month=current_month,
day=current_day,
)
),
"week": list(
self.get_charts_for_period(
user,
"tv_series",
year=current_year,
week=current_week,
)
),
"month": list(
self.get_charts_for_period(
user,
"tv_series",
year=current_year,
month=current_month,
)
),
"year": list(
self.get_charts_for_period(user, "tv_series", year=current_year)
),
"all": list(self.get_charts_for_period(user, "tv_series")),
},
}
if not date_param:
context["period"] = "current"
context["year"] = current_year
context["month"] = current_month
context["month_name"] = calendar.month_name[current_month]
context["week"] = current_week
context["day"] = current_day
context["charts"] = {
"artist": list(
self.get_charts_for_period(
user, "artist", year=current_year, limit=20
)
),
"album": list(
self.get_charts_for_period(
user, "album", year=current_year, limit=20
)
),
"track": list(
self.get_charts_for_period(
user, "track", year=current_year, limit=20
)
),
"tv_series": list(
self.get_charts_for_period(
user, "tv_series", year=current_year, limit=20
)
),
"video": list(
self.get_charts_for_period(
user, "video", year=current_year, limit=20
)
),
"board_game": list(
self.get_charts_for_period(
user, "board_game", year=current_year, limit=20
)
),
"book": list(
self.get_charts_for_period(
user, "book", year=current_year, limit=20
)
),
"food": list(
self.get_charts_for_period(
user, "food", year=current_year, limit=20
)
),
"podcast": list(
self.get_charts_for_period(
user, "podcast", year=current_year, limit=20
)
),
"trail": list(
self.get_charts_for_period(
user, "trail", year=current_year, limit=20
)
),
}
else:
# Resolve date parameters
if date_param:
parts = date_param.split("-")
year = int(parts[0])
week = None
month = None
day = None
if len(parts) >= 2 and parts[1].startswith("W"):
week = int(parts[1].lstrip("W"))
elif len(parts) >= 2 and parts[1]:
@ -290,20 +128,17 @@ class ChartRecordView(TemplateView):
month = int(parts[1])
except ValueError:
pass
if len(parts) >= 3:
if parts[2].startswith("W"):
week = int(parts[2].lstrip("W"))
elif not parts[2].startswith("W"):
day = int(parts[2])
context["period"] = "historical"
context["year"] = year
context["month"] = month
context["month_name"] = calendar.month_name[month] if month else None
context["week"] = week
context["day"] = day
period_str = str(year)
if month:
period_str = f"{calendar.month_name[month]} {period_str}"
@ -312,109 +147,82 @@ class ChartRecordView(TemplateView):
if day:
period_str = f"{calendar.month_name[month]} {day}, {year}"
context["period_str"] = period_str
else:
year = current_year
month = current_month
week = current_week
day = current_day
context["period"] = "current"
context["year"] = current_year
context["month"] = current_month
context["month_name"] = calendar.month_name[current_month]
context["week"] = current_week
context["day"] = current_day
context["charts"] = {
"artist": list(
self.get_charts_for_period(
user,
"artist",
year=year,
month=month,
week=week,
day=day,
)
),
"album": list(
self.get_charts_for_period(
user,
"album",
year=year,
month=month,
week=week,
day=day,
)
),
"track": list(
self.get_charts_for_period(
user,
"track",
year=year,
month=month,
week=week,
day=day,
)
),
"tv_series": list(
self.get_charts_for_period(
user,
"tv_series",
year=year,
month=month,
week=week,
day=day,
)
),
"video": list(
self.get_charts_for_period(
user,
"video",
year=year,
month=month,
week=week,
day=day,
)
),
"board_game": list(
self.get_charts_for_period(
user,
"board_game",
year=year,
month=month,
week=week,
day=day,
)
),
"book": list(
self.get_charts_for_period(
user,
"book",
year=year,
month=month,
week=week,
day=day,
)
),
"food": list(
self.get_charts_for_period(
user,
"food",
year=year,
month=month,
week=week,
day=day,
)
),
"podcast": list(
self.get_charts_for_period(
user,
"podcast",
year=year,
month=month,
week=week,
day=day,
)
),
"trail": list(
self.get_charts_for_period(
user,
"trail",
year=year,
month=month,
week=week,
day=day,
)
),
}
# List-group tables default to week-level when no date param (matches active tab)
if not date_param:
list_year = current_year
list_month = None
list_week = current_week
list_day = None
else:
list_year = year
list_month = month
list_week = week
list_day = day
context["charts"] = {
"artist": list(
self.get_charts_for_period(
user, "artist", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
)
),
"album": list(
self.get_charts_for_period(
user, "album", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
)
),
"track": list(
self.get_charts_for_period(
user, "track", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
)
),
"tv_series": list(
self.get_charts_for_period(
user, "tv_series", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
)
),
"video": list(
self.get_charts_for_period(
user, "video", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
)
),
"board_game": list(
self.get_charts_for_period(
user, "board_game", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
)
),
"book": list(
self.get_charts_for_period(
user, "book", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
)
),
"food": list(
self.get_charts_for_period(
user, "food", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
)
),
"podcast": list(
self.get_charts_for_period(
user, "podcast", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
)
),
"trail": list(
self.get_charts_for_period(
user, "trail", year=list_year, month=list_month, week=list_week, day=list_day, limit=20
)
),
}
bird_data = self.get_bird_chart_data(
user,
@ -628,6 +436,53 @@ class ChartRecordView(TemplateView):
}
class MalojaChartsView(ChartRecordView):
"""Three maloja-themed image grid widgets (artists, albums, TV series)
with Today/Week/Month/Year/All tabs. Each tab computes its own period
from the current date — no query param needed."""
template_name = "charts/maloja_charts.html"
def get_context_data(self, **kwargs):
context = super(ChartRecordView, self).get_context_data(**kwargs)
user = self.request.user
now = timezone.now()
if user.is_authenticated:
now = now_user_timezone(user.profile)
today = now.date()
context["chart_keys"] = {
"today": "Today",
"week": "This Week",
"month": "This Month",
"year": "This Year",
"all": "All Time",
}
tab_params = {
"today": {"year": today.year, "month": today.month, "day": today.day},
"week": {"year": today.year, "week": today.isocalendar()[1]},
"month": {"year": today.year, "month": today.month},
"year": {"year": today.year},
}
maloja_charts = {}
for media_type in ("artist", "album", "tv_series"):
tabs = {}
for key in ("today", "week", "month", "year"):
tabs[key] = list(
self.get_charts_for_period(user, media_type, **tab_params[key])
)
tabs["all"] = list(
self.get_charts_for_period(user, media_type)
)
maloja_charts[media_type] = tabs
context["maloja_charts"] = maloja_charts
return context
MEDIA_TYPE_LABELS = {
"artist": ("🎤", "Top Artists"),
"album": ("💿", "Top Albums"),

View File

@ -0,0 +1,202 @@
import logging
from django.core.management.base import BaseCommand
from django.db import models, transaction
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Enrich artist and album metadata (covers, thumbnails) from MusicBrainz and TheAudioDB"
def add_arguments(self, parser):
parser.add_argument(
"--force",
action="store_true",
help="Overwrite existing cover image and metadata",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be done without making changes",
)
parser.add_argument(
"--artists",
action="store_true",
help="Only process artists",
)
parser.add_argument(
"--albums",
action="store_true",
help="Only process albums",
)
parser.add_argument(
"--needs-metadata",
action="store_true",
help="Only process items missing metadata or with broken images",
)
def _has_broken_image(self, obj, field_name: str) -> bool:
field = getattr(obj, field_name, None)
if not field or not field.name:
return False
try:
return not field.storage.exists(field.name)
except Exception:
return True
def handle(self, *args, **options):
from music.models import Album, Artist
force = options["force"]
dry_run = options["dry_run"]
only_artists = options["artists"]
only_albums = options["albums"]
needs_metadata = options["needs_metadata"]
if not only_artists and not only_albums:
only_artists = only_albums = True
updated_total = 0
errors_total = 0
if only_artists:
updated_total += self._process_artists(force, dry_run, needs_metadata)
errors_total = 0 # reset per section
if only_albums:
updated_total += self._process_albums(force, dry_run, needs_metadata)
self.stdout.write(
self.style.SUCCESS(f"\nDone! {updated_total} items processed")
)
def _get_artists(self, needs_metadata: bool):
from music.models import Artist
qs = Artist.objects.all()
if needs_metadata:
qs = qs.filter(
models.Q(theaudiodb_id__isnull=True)
| models.Q(theaudiodb_id="")
| models.Q(thumbnail__isnull=True)
| models.Q(thumbnail="")
)
broken = []
if needs_metadata:
broken_qs = Artist.objects.exclude(
models.Q(thumbnail__isnull=True) | models.Q(thumbnail=""),
)
for artist in broken_qs.iterator():
if self._has_broken_image(artist, "thumbnail"):
broken.append(artist)
return list(qs) + broken
def _get_albums(self, needs_metadata: bool):
from music.models import Album
qs = Album.objects.all()
if needs_metadata:
qs = qs.filter(
models.Q(cover_image__isnull=True)
| models.Q(cover_image="")
| models.Q(cover_image="default-image-replace-me")
)
broken = []
if needs_metadata:
broken_qs = Album.objects.exclude(
models.Q(cover_image__isnull=True) | models.Q(cover_image=""),
)
for album in broken_qs.iterator():
if self._has_broken_image(album, "cover_image"):
broken.append(album)
return list(qs) + broken
def _process_artists(self, force, dry_run, needs_metadata):
from music.models import Artist
artists = self._get_artists(needs_metadata) if needs_metadata else list(Artist.objects.all())
total = len(artists)
self.stdout.write(f"Processing {total} artists")
if dry_run:
for artist in artists:
has_tadb = bool(artist.theaudiodb_id)
has_thumb = bool(artist.thumbnail)
thumb_broken = self._has_broken_image(artist, "thumbnail")
status = f"theaudiodb_id={'' if has_tadb else ''}"
if thumb_broken:
status += ", thumbnail=BROKEN"
elif has_thumb:
status += ", thumbnail=✓"
else:
status += ", thumbnail=✗"
self.stdout.write(f" [DRY RUN] Would fix {artist.name} ({status})")
return 0
updated = 0
errors = 0
for artist in artists:
try:
with transaction.atomic():
artist.fix_metadata(
force_update=force or self._has_broken_image(artist, "thumbnail")
)
updated += 1
self.stdout.write(f" [ARTIST {updated}/{total}] {artist.name}")
except Exception as e:
errors += 1
self.stdout.write(
self.style.ERROR(f" Error updating artist {artist.name}: {e}")
)
self.stdout.write(
self.style.SUCCESS(f"\nArtists done! {updated} updated, {errors} errors")
)
return updated
def _process_albums(self, force, dry_run, needs_metadata):
from music.models import Album
albums = self._get_albums(needs_metadata) if needs_metadata else list(Album.objects.all())
total = len(albums)
self.stdout.write(f"Processing {total} albums")
if dry_run:
for album in albums:
has_cover = bool(album.cover_image)
cover_broken = self._has_broken_image(album, "cover_image")
if cover_broken:
status = "cover=BROKEN"
elif has_cover:
status = "cover=✓"
else:
status = "cover=✗"
self.stdout.write(f" [DRY RUN] Would fix {album.name} ({status})")
return 0
updated = 0
errors = 0
for album in albums:
try:
with transaction.atomic():
if self._has_broken_image(album, "cover_image") or force:
album.fetch_artwork(force=True)
else:
album.fix_metadata()
updated += 1
self.stdout.write(f" [ALBUM {updated}/{total}] {album.name}")
except Exception as e:
errors += 1
self.stdout.write(
self.style.ERROR(f" Error updating album {album.name}: {e}")
)
self.stdout.write(
self.style.SUCCESS(f"\nAlbums done! {updated} updated, {errors} errors")
)
return updated

View File

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

View File

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

View File

@ -106,6 +106,14 @@ class UserProfile(TimeStampedModel):
default=WeighUnit.METRIC,
)
trends_disabled = models.BooleanField(default=False)
disabled_trends = models.JSONField(
default=list,
blank=True,
help_text="List of trend slugs the user has disabled",
)
def __str__(self):
return f"User profile for {self.user}"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,8 +17,8 @@ MISSING_ALL = [
]
MISSING_GROUPS = {
"cover": lambda g: not bool(g.cover),
"screenshot": lambda g: not bool(g.screenshot),
"cover": lambda g: _image_missing_or_broken(g, "cover"),
"screenshot": lambda g: _image_missing_or_broken(g, "screenshot"),
"summary": lambda g: not g.summary,
"rating": lambda g: g.rating is None,
"release_date": lambda g: g.release_date is None,
@ -28,6 +28,16 @@ MISSING_GROUPS = {
}
def _image_missing_or_broken(game, field_name) -> bool:
field = getattr(game, field_name)
if not bool(field):
return True
try:
return not field.storage.exists(field.name)
except Exception:
return True
def _game_matches(game, flags):
if not flags:
return False
@ -103,6 +113,7 @@ class Command(BaseCommand):
if all_missing:
flags = MISSING_ALL
fix_broken_images = True
if not flags and not game_id and not force and not fix_broken_images:
self.stdout.write(

View File

@ -30,6 +30,19 @@ class Command(BaseCommand):
action="store_true",
help="Only process channels with a twitch_id",
)
parser.add_argument(
"--needs-metadata",
action="store_true",
help="Only process channels missing youtube_id, twitch_id, cover image, or with broken cover image",
)
def _has_broken_image(self, channel) -> bool:
if not channel.cover_image or not channel.cover_image.name:
return False
try:
return not channel.cover_image.storage.exists(channel.cover_image.name)
except Exception:
return True
def handle(self, *args, **options):
from videos.models import Channel
@ -38,6 +51,7 @@ class Command(BaseCommand):
dry_run = options["dry_run"]
youtube_only = options["youtube_only"]
twitch_only = options["twitch_only"]
needs_metadata = options["needs_metadata"]
qs = Channel.objects.all()
@ -45,29 +59,61 @@ class Command(BaseCommand):
qs = qs.exclude(youtube_id__isnull=True).exclude(youtube_id="")
elif twitch_only:
qs = qs.exclude(twitch_id__isnull=True).exclude(twitch_id="")
elif needs_metadata:
no_id = models.Q(youtube_id__isnull=True) & models.Q(twitch_id__isnull=True)
no_id |= models.Q(youtube_id="") & models.Q(twitch_id="")
no_id |= models.Q(youtube_id__isnull=True) & models.Q(twitch_id="")
no_id |= models.Q(youtube_id="") & models.Q(twitch_id__isnull=True)
qs = qs.filter(
no_id
| models.Q(cover_image__isnull=True)
| models.Q(cover_image="")
)
else:
qs = qs.filter(
models.Q(youtube_id__isnull=False) | models.Q(twitch_id__isnull=False)
).exclude(youtube_id="", twitch_id="")
total = qs.count()
self.stdout.write(f"Processing {total} channels")
self.stdout.write(f"Processing {total} channels from DB filter")
broken_channels = []
if needs_metadata:
broken_qs = Channel.objects.filter(
cover_image__isnull=False,
).exclude(
cover_image="",
)
if youtube_only:
broken_qs = broken_qs.exclude(youtube_id__isnull=True).exclude(youtube_id="")
elif twitch_only:
broken_qs = broken_qs.exclude(twitch_id__isnull=True).exclude(twitch_id="")
for channel in broken_qs.iterator():
if self._has_broken_image(channel):
broken_channels.append(channel)
all_channels = list(qs) + broken_channels
total = len(all_channels)
self.stdout.write(f"Total channels to process: {total}")
if dry_run:
for channel in qs.iterator():
for channel in all_channels:
source = "youtube" if channel.youtube_id else "twitch"
identifier = channel.youtube_id or channel.twitch_id
status = f"({source}: {identifier})"
if self._has_broken_image(channel):
status += " [image BROKEN]"
self.stdout.write(
f" [DRY RUN] Would fix {channel.name} ({source}: {identifier})"
f" [DRY RUN] Would fix {channel.name} {status}"
)
return
updated = 0
errors = 0
for channel in qs.iterator():
for channel in all_channels:
try:
with transaction.atomic():
channel.fix_metadata(force=force)
channel.fix_metadata(force=force or self._has_broken_image(channel))
updated += 1
source = "youtube" if channel.youtube_id else "twitch"
self.stdout.write(f" [{updated}/{total}] {channel.name} ({source})")

View File

@ -1,4 +1,5 @@
import logging
import os
from django.core.management.base import BaseCommand
from django.db import models, transaction
@ -28,9 +29,18 @@ class Command(BaseCommand):
parser.add_argument(
"--needs-metadata",
action="store_true",
help="Only process series missing imdb_id or cover image",
help="Only process series missing imdb_id or with broken cover image",
)
def _has_broken_image(self, series) -> bool:
"""Check if a series has a cover_image set but the file is missing."""
if not series.cover_image:
return False
try:
return not os.path.exists(series.cover_image.path)
except Exception:
return True
def handle(self, *args, **options):
from videos.models import Series
@ -51,25 +61,50 @@ class Command(BaseCommand):
)
total = qs.count()
self.stdout.write(f"Processing {total} series")
self.stdout.write(f"Processing {total} series from DB filter")
# Also find series with broken cover images
broken_image_series = []
if needs_metadata:
broken_qs = Series.objects.filter(
cover_image__isnull=False,
).exclude(
models.Q(imdb_id__isnull=True)
| models.Q(imdb_id="")
| models.Q(cover_image__isnull=True)
| models.Q(cover_image=""),
)
if imdb_id:
broken_qs = broken_qs.filter(imdb_id=imdb_id)
for series in broken_qs.iterator():
if self._has_broken_image(series):
broken_image_series.append(series)
all_series = list(qs) + broken_image_series
total = len(all_series)
self.stdout.write(f"Total series to process: {total}")
if dry_run:
for series in qs.iterator():
for series in all_series:
has_imdb = bool(series.imdb_id)
has_image = bool(series.cover_image)
self.stdout.write(
f" [DRY RUN] Would fix {series.name}"
f" (imdb_id={'' if has_imdb else ''}"
f", image={'' if has_image else ''})"
)
image_broken = self._has_broken_image(series)
status = f"imdb_id={'' if has_imdb else ''}"
if image_broken:
status += ", image=BROKEN"
elif has_image:
status += ", image=✓"
else:
status += ", image=✗"
self.stdout.write(f" [DRY RUN] Would fix {series.name} ({status})")
return
updated = 0
errors = 0
for series in qs.iterator():
for series in all_series:
try:
with transaction.atomic():
series.fix_metadata(force_update=force)
series.fix_metadata(force_update=force or self._has_broken_image(series))
updated += 1
self.stdout.write(f" [{updated}/{total}] {series.name}")
except Exception as e:

View File

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

View File

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

View File

@ -0,0 +1,45 @@
{% extends "base_list.html" %}
{% load static %}
{% block title %}Maloja Widgets{% endblock %}
{% block head_extra %}
<style>
.container { margin-bottom: 100px; }
h2 { padding-top: 20px; }
.nav-tabs { cursor: pointer; }
.image-wrapper { contain: content; }
.image-wrapper :hover { background: rgba(0,0,0,0.3); }
.caption {
position: fixed; top: 5px; left: 5px;
padding: 3px; font-size: 90%;
color: white; background: rgba(0,0,0,0.4);
}
.caption-medium {
position: fixed; top: 5px; left: 5px;
padding: 3px; font-size: 75%;
color: white; background: rgba(0,0,0,0.4);
}
.caption-small {
position: fixed; top: 5px; left: 5px;
padding: 3px; font-size: 60%;
color: white; background: rgba(0,0,0,0.4);
}
</style>
{% endblock %}
{% block lists %}
{% block grid_view_button %}{% endblock %}
<div class="row mb-3">
<div class="col-12">
<a href="{% url 'charts:charts-home' %}" class="btn btn-sm btn-outline-secondary">&larr; Full Charts</a>
<a href="{% url 'charts:spotify-tracks' %}" class="btn btn-sm btn-outline-secondary">🎵 Spotify Tracks</a>
<a href="{% url 'charts:bandcamp-tracks' %}" class="btn btn-sm btn-outline-secondary">🎵 Bandcamp Tracks</a>
</div>
</div>
{% include "scrobbles/_top_charts.html" %}
{% endblock %}

View File

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

View File

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

View File

@ -76,9 +76,11 @@
<div class="btn-group me-2">
<a href="{% url 'scrobbles:scrobble-list' %}" class="btn btn-sm btn-outline-secondary">Live</a>
</div>
{% if not user.profile.trends_disabled %}
<div class="btn-group me-2">
<a href="{% url 'trends:trends-home' %}" class="btn btn-sm btn-outline-secondary">Trends</a>
</div>
{% endif %}
<div class="btn-group me-2">
{% if user.profile.lastfm_username and not user.profile.lastfm_auto_import %}
<form action="{% url 'scrobbles:lastfm-import' %}" method="get">