Compare commits
174 Commits
f160f5a7b8
...
49.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 15d27f6d94 | |||
| c8292d1c06 | |||
| 68f821fce1 | |||
| ed2ed59f65 | |||
| 17a7bb52fa | |||
| bbac142b40 | |||
| 5f55ec557f | |||
| 7f3076608f | |||
| 568772a0e6 | |||
| 91c3376256 | |||
| 58639c6fc1 | |||
| 228441ddc5 | |||
| 6341075f07 | |||
| a135b9f5f2 | |||
| 9088412d1e | |||
| c7339fbe31 | |||
| 4ce3dc03c5 | |||
| 5a4ef678a8 | |||
| 5ca22efeaa | |||
| 912ea8bfac | |||
| b541e1084d | |||
| c9b9da4abc | |||
| 8236f43026 | |||
| ea1b43d1b8 | |||
| 4bf22c96e9 | |||
| dec7a79509 | |||
| 371e1d654c | |||
| bef7e683c5 | |||
| ec219ef3ea | |||
| dcc7229e90 | |||
| 73665ef19e | |||
| 2536e330af | |||
| 99c056adeb | |||
| 7a504e45de | |||
| 7618d0ba30 | |||
| ce4dc40033 | |||
| b0b22b79dc | |||
| 6471413681 | |||
| 50b10689fc | |||
| 85bddb6cba | |||
| c285b0d3b3 | |||
| 671fe8d86f | |||
| 89817110de | |||
| ee01e3d8df | |||
| a70343d6f3 | |||
| 3e72042c24 | |||
| 087c7775ae | |||
| 3f71065ad6 | |||
| 801672124f | |||
| 811e9c1ce9 | |||
| 415b32bdc7 | |||
| 22319c807a | |||
| f9ba6fec14 | |||
| 5f55163147 | |||
| a6ef34623e | |||
| 7cb48d20f6 | |||
| 445103a878 | |||
| 579da8c44e | |||
| daabd2f37f | |||
| 039c58cf89 | |||
| 410c033f12 | |||
| ce302e4d45 | |||
| 19589c9463 | |||
| 3d9506b14e | |||
| 23b87278b2 | |||
| 0b8e027c30 | |||
| 1bd9f0d942 | |||
| fa7890cb21 | |||
| 957c32e3a7 | |||
| 8d069df9d1 | |||
| 96d1d7ac6b | |||
| 009b2ba243 | |||
| 4f051ae250 | |||
| 7e9fbb1bf6 | |||
| ec1a54f623 | |||
| b502667ca6 | |||
| 263874288a | |||
| bca90c97ae | |||
| 12f49a6cee | |||
| 034c7cb413 | |||
| e737870733 | |||
| 95757650f6 | |||
| 1928acf8a6 | |||
| 22956c7c7f | |||
| 17aed1191d | |||
| 0b1fb8667c | |||
| 13dd5b67d0 | |||
| 20c7874466 | |||
| 62d8ffd794 | |||
| bea2b2d187 | |||
| 034cb99c77 | |||
| 37187f33dd | |||
| d7a23c3832 | |||
| 6d45571e75 | |||
| 88fd0ed7f8 | |||
| 2100cedc1a | |||
| 2b17a92c6c | |||
| 72fd1ab90e | |||
| 301440909b | |||
| 389641002d | |||
| 43d514cf5b | |||
| 25baeca2b0 | |||
| b4e15c73c1 | |||
| 90f3d38687 | |||
| 8afb227267 | |||
| 425abebc9a | |||
| afb61f6622 | |||
| 65815fc198 | |||
| 75479d91a3 | |||
| 99789e5477 | |||
| fd95f1e686 | |||
| e133c4149b | |||
| 7e75828012 | |||
| 664148e702 | |||
| 768819b664 | |||
| 760d453165 | |||
| 2fac5815b1 | |||
| f4b30ade70 | |||
| 1cbb84c29f | |||
| b622b151d4 | |||
| 4e1c3ffbf0 | |||
| 8a419c7bbc | |||
| 0639033aa9 | |||
| 6927729284 | |||
| 766f9db17c | |||
| 25cbd88071 | |||
| 972568bebc | |||
| 939c89d368 | |||
| 83e7061b92 | |||
| a171517f92 | |||
| a4030e89ec | |||
| dce31ed840 | |||
| 645e81299b | |||
| 72fa41977e | |||
| 56745b33f4 | |||
| dd2f44e72f | |||
| 41890d14d9 | |||
| c5f1ee2d64 | |||
| 26176ccd73 | |||
| 9b7fa0d4f8 | |||
| aeb460d677 | |||
| 4e059683b0 | |||
| d3146433f2 | |||
| 7b487f8494 | |||
| f7675b8a02 | |||
| d036dbe4fd | |||
| 86c495f22f | |||
| b9324d6443 | |||
| c11858810c | |||
| 9bafe45951 | |||
| 4d8e925f8c | |||
| e7fff25543 | |||
| 62c200ab05 | |||
| 7c33095d87 | |||
| cb50de13c0 | |||
| 08152de086 | |||
| 0d6b2c4afc | |||
| 9d3f7f434f | |||
| 2b88f89794 | |||
| 3014a30616 | |||
| c7850878fe | |||
| db5b673cd5 | |||
| 217e2443e2 | |||
| af8b1d4f8a | |||
| d69bc6c235 | |||
| cc0f7db453 | |||
| 11bde2a306 | |||
| cd4f6ff71d | |||
| bf7e5677e6 | |||
| 2b04a17d77 | |||
| 97f35f62b8 | |||
| dfd51c1343 | |||
| e47328e572 | |||
| e85d6ec779 |
68
.gitea/workflows/build.yml
Normal file
68
.gitea/workflows/build.yml
Normal file
@ -0,0 +1,68 @@
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VROBBLER_DATABASE_URL: sqlite:///test.db
|
||||
VROBBLER_USDA_API_KEY: ${{ vars.VROBBLER_USDA_API_KEY }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Cache pip/poetry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/pip
|
||||
~/.cache/pypoetry
|
||||
key: ${{ runner.os }}-py311-${{ hashFiles('**/poetry.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-py311-
|
||||
|
||||
- name: Install Poetry
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install poetry
|
||||
|
||||
- name: Install deps
|
||||
run: |
|
||||
cp vrobbler.conf.test vrobbler.conf
|
||||
poetry install --with test
|
||||
|
||||
- name: Pytest with coverage
|
||||
run: |
|
||||
poetry run pytest -n 5 --cov-report term:skip-covered --cov=vrobbler tests
|
||||
|
||||
- name: Notify success (ntfy)
|
||||
if: success()
|
||||
run: |
|
||||
curl -fsS \
|
||||
-H "Title: vrobbler CI success" \
|
||||
-H "Priority: low" \
|
||||
-H "Tags: success,vrobbler" \
|
||||
-H "Actions: view, Changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, Build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
|
||||
-d "✅ Build succeeded: ${{ gitea.repository }} @ ${{ gitea.sha }}" \
|
||||
https://ntfy.unbl.ink/drone
|
||||
|
||||
- name: Notify failure (ntfy)
|
||||
if: failure()
|
||||
run: |
|
||||
curl -fsS \
|
||||
-H "Title: vrobbler CI failure" \
|
||||
-H "Priority: high" \
|
||||
-H "Tags: failure,vrobbler" \
|
||||
-H "Actions: view, Changes, ${{ gitea.server_url }}/${{ gitea.repository }}/commit/${{ gitea.sha }}; view, Build, ${{ gitea.server_url }}/${{ gitea.repository }}/actions/runs/${{ gitea.run_id }}" \
|
||||
-d "❌ Build failed: ${{ gitea.repository }} @ ${{ gitea.sha }}" \
|
||||
https://ntfy.unbl.ink/drone
|
||||
@ -1,10 +1,8 @@
|
||||
name: build & deploy
|
||||
name: deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
tags: ["*"]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@ -22,7 +20,6 @@ jobs:
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
# Cache pip + Poetry caches (rough equivalent to your mounted pip_cache)
|
||||
- name: Cache pip/poetry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@ -47,7 +44,6 @@ jobs:
|
||||
run: |
|
||||
poetry run pytest -n 5 --cov-report term:skip-covered --cov=vrobbler tests
|
||||
|
||||
# Notifications (success/failure) for the test job
|
||||
- name: Notify success (ntfy)
|
||||
if: success()
|
||||
run: |
|
||||
@ -71,8 +67,6 @@ jobs:
|
||||
https://ntfy.unbl.ink/drone
|
||||
|
||||
build-and-deploy:
|
||||
# Only deploy on tags (equivalent to Drone when: ref: refs/tags/*)
|
||||
# if: startsWith(gitea.ref, 'refs/tags/')
|
||||
needs: [test]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@ -97,12 +91,19 @@ jobs:
|
||||
|
||||
- name: Build package with commit info
|
||||
run: |
|
||||
# Write commit to _commit.py before build
|
||||
echo "commit = '$(echo ${{ gitea.sha }} | cut -c1-8)'" > vrobbler/_commit.py
|
||||
poetry build
|
||||
# Restore original _commit.py
|
||||
git checkout vrobbler/_commit.py
|
||||
|
||||
- name: Clean old wheels from server
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: vrobbler.service
|
||||
username: root
|
||||
key: ${{ secrets.JAIL_KEY }}
|
||||
script: |
|
||||
rm -f /var/lib/vrobbler/dist/*.whl
|
||||
|
||||
- name: Copy wheel to server and deploy
|
||||
uses: appleboy/scp-action@v1.0.0
|
||||
with:
|
||||
@ -120,13 +121,16 @@ jobs:
|
||||
key: ${{ secrets.JAIL_KEY }}
|
||||
command_timeout: 2m
|
||||
script: |
|
||||
set -e
|
||||
mkdir -p /var/lib/vrobbler
|
||||
echo "${{ gitea.sha }}" | cut -c1-8 > /var/lib/vrobbler/commit.txt
|
||||
pip uninstall -y vrobbler
|
||||
pip install /var/lib/vrobbler/dist/*.whl
|
||||
rm -f /var/lib/vrobbler/dist/*.whl
|
||||
python3 -c "import vrobbler; print(f'vrobbler {vrobbler.__version__} installed OK')"
|
||||
vrobbler migrate
|
||||
vrobbler collectstatic --noinput
|
||||
immortalctl restart celery && immortalctl restart vrobbler
|
||||
immortalctl restart vrobbler-celery && immortalctl restart vrobbler-celerybeat && immortalctl restart vrobbler
|
||||
|
||||
- name: Notify deploy success (ntfy)
|
||||
if: success()
|
||||
@ -14,3 +14,11 @@ ro class method should call the utility function.
|
||||
|
||||
Be sure to check pyproject.toml for project defaults. Specifically for black and
|
||||
isort expectations.
|
||||
|
||||
All tasks live in the PROJECT.org file and include an org ID that is a uuid to make them unique.
|
||||
|
||||
In local development, environment variables for various sensitive values live in a .envrc file
|
||||
|
||||
The .envrc file can be loaded into a shell environment to allow access to most third party services
|
||||
|
||||
Care should be taken when using .envrc that we do not spam services we use in production with requests
|
||||
|
||||
1041
PROJECT.org
1041
PROJECT.org
File diff suppressed because it is too large
Load Diff
20
README.md
20
README.md
@ -21,3 +21,23 @@ VROBBLER_KEEP_DETAILED_SCROBBLE_LOGS=True
|
||||
VROBBLER_DATABASE_URL="postgres://vrobbler:<pass>@db.service:5432/vrobbler"
|
||||
VROBBLER_REDIS_URL="redis://:<pass>@cache.service:6379/0"
|
||||
```
|
||||
|
||||
## Database Backup
|
||||
|
||||
A backup command is available via `./manage.py backup_database` (also runs on a cron schedule via Celery). It dumps the database with `pg_dump`, compresses with gzip, and optionally copies the backup to a remote host via SCP.
|
||||
|
||||
Configure these additional settings as needed:
|
||||
|
||||
```
|
||||
VROBBLER_DB_BACKUP_SSH_KEY="/path/to/ssh/private/key"
|
||||
VROBBLER_DB_BACKUP_SSH_DEST="user@backup.example.com:/remote/path/"
|
||||
VROBBLER_DB_BACKUP_NTFY_URL="https://ntfy.sh/your-topic"
|
||||
```
|
||||
|
||||
- `VROBBLER_DB_BACKUP_SSH_KEY` — Path to the SSH private key used for remote copy.
|
||||
- `VROBBLER_DB_BACKUP_SSH_DEST` — SCP destination (user@host:path). If set, the backup is copied to the remote host and old backups are pruned.
|
||||
- `VROBBLER_DB_BACKUP_LOCAL_DIR` — Local directory for backup storage. Defaults to `/var/backups/`. Backups are stored in a `vrobbler/` subdirectory.
|
||||
- `VROBBLER_DB_BACKUP_NTFY_URL` — ntfy.sh URL for success notifications. Defaults to `https://ntfy.unbl.ink/backups`.
|
||||
|
||||
Retention is hardcoded: keeps daily backups for 7 days, plus one per month for 12 months.
|
||||
```
|
||||
|
||||
BIN
data/sample-trail.fit
Normal file
BIN
data/sample-trail.fit
Normal file
Binary file not shown.
3360
data/sample_trail.gpx
Normal file
3360
data/sample_trail.gpx
Normal file
File diff suppressed because one or more lines are too long
2
data/scale-example.csv
Normal file
2
data/scale-example.csv
Normal file
@ -0,0 +1,2 @@
|
||||
DATE,TIME,BICEPS,BMI,BMR,BODY_FAT,BONE,CALIPER,CALIPER_1,CALIPER_2,CALIPER_3,CALORIES,CHEST,COMMENT,HEART_RATE,HIPS,LBM,MUSCLE,NECK,TDEE,THIGH,VISCERAL_FAT,WAIST,WATER,WEIGHT,WHR,WHTR
|
||||
2026-05-20,11:56:58.076,,31.09,1706.74,29.084072,3.438837,,,,,,,,,,,33.07067,,2645.46,,,,54.445187,192.68378,,
|
||||
|
9
justfile
9
justfile
@ -14,3 +14,12 @@ celery:
|
||||
|
||||
celery-beat:
|
||||
poetry run celery -A vrobbler beat -l info
|
||||
|
||||
push:
|
||||
git push && git push gitea
|
||||
git push --tags && git push --tags gitea
|
||||
|
||||
release kind="minor":
|
||||
poetry run python scripts/release.py {{kind}}
|
||||
just push
|
||||
|
||||
|
||||
325
poetry.lock
generated
325
poetry.lock
generated
@ -1796,6 +1796,17 @@ files = [
|
||||
packaging = ">=20"
|
||||
platformdirs = ">=4.3.6"
|
||||
|
||||
[[package]]
|
||||
name = "fitparse"
|
||||
version = "1.2.0"
|
||||
description = "Python library to parse ANT/Garmin .FIT files"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "fitparse-1.2.0.tar.gz", hash = "sha256:2d691022452dea6dabad13cc6e017ca467fe8a3a895cd3ac67a50a7bb716b4a9"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flake8"
|
||||
version = "7.3.0"
|
||||
@ -1953,6 +1964,18 @@ files = [
|
||||
{file = "frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpxpy"
|
||||
version = "1.6.2"
|
||||
description = "GPX file parser and GPS track manipulation library"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "gpxpy-1.6.2-py3-none-any.whl", hash = "sha256:289bc2d80f116c988d0a1e763fda22838f83005573ece2bbc6521817b26fb40a"},
|
||||
{file = "gpxpy-1.6.2.tar.gz", hash = "sha256:a72c484b97ec42b80834353b029cc8ee1b79f0ffca1179b2210bb3baf26c01ae"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gunicorn"
|
||||
version = "20.1.0"
|
||||
@ -2486,144 +2509,146 @@ zookeeper = ["kazoo (>=2.8.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "lxml"
|
||||
version = "5.4.0"
|
||||
version = "6.1.1"
|
||||
description = "Powerful and Pythonic XML processing library combining libxml2/libxslt with the ElementTree API."
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "lxml-5.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e7bc6df34d42322c5289e37e9971d6ed114e3776b45fa879f734bded9d1fea9c"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6854f8bd8a1536f8a1d9a3655e6354faa6406621cf857dc27b681b69860645c7"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:696ea9e87442467819ac22394ca36cb3d01848dad1be6fac3fb612d3bd5a12cf"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ef80aeac414f33c24b3815ecd560cee272786c3adfa5f31316d8b349bfade28"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b9c2754cef6963f3408ab381ea55f47dabc6f78f4b8ebb0f0b25cf1ac1f7609"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a62cc23d754bb449d63ff35334acc9f5c02e6dae830d78dab4dd12b78a524f4"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f82125bc7203c5ae8633a7d5d20bcfdff0ba33e436e4ab0abc026a53a8960b7"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:b67319b4aef1a6c56576ff544b67a2a6fbd7eaee485b241cabf53115e8908b8f"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-manylinux_2_28_ppc64le.whl", hash = "sha256:a8ef956fce64c8551221f395ba21d0724fed6b9b6242ca4f2f7beb4ce2f41997"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-manylinux_2_28_s390x.whl", hash = "sha256:0a01ce7d8479dce84fc03324e3b0c9c90b1ece9a9bb6a1b6c9025e7e4520e78c"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:91505d3ddebf268bb1588eb0f63821f738d20e1e7f05d3c647a5ca900288760b"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a3bcdde35d82ff385f4ede021df801b5c4a5bcdfb61ea87caabcebfc4945dc1b"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:aea7c06667b987787c7d1f5e1dfcd70419b711cdb47d6b4bb4ad4b76777a0563"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a7fb111eef4d05909b82152721a59c1b14d0f365e2be4c742a473c5d7372f4f5"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:43d549b876ce64aa18b2328faff70f5877f8c6dede415f80a2f799d31644d776"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-win32.whl", hash = "sha256:75133890e40d229d6c5837b0312abbe5bac1c342452cf0e12523477cd3aa21e7"},
|
||||
{file = "lxml-5.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:de5b4e1088523e2b6f730d0509a9a813355b7f5659d70eb4f319c76beea2e250"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:98a3912194c079ef37e716ed228ae0dcb960992100461b704aea4e93af6b0bb9"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0ea0252b51d296a75f6118ed0d8696888e7403408ad42345d7dfd0d1e93309a7"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b92b69441d1bd39f4940f9eadfa417a25862242ca2c396b406f9272ef09cdcaa"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20e16c08254b9b6466526bc1828d9370ee6c0d60a4b64836bc3ac2917d1e16df"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7605c1c32c3d6e8c990dd28a0970a3cbbf1429d5b92279e37fda05fb0c92190e"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ecf4c4b83f1ab3d5a7ace10bafcb6f11df6156857a3c418244cef41ca9fa3e44"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cef4feae82709eed352cd7e97ae062ef6ae9c7b5dbe3663f104cd2c0e8d94ba"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:df53330a3bff250f10472ce96a9af28628ff1f4efc51ccba351a8820bca2a8ba"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-manylinux_2_28_ppc64le.whl", hash = "sha256:aefe1a7cb852fa61150fcb21a8c8fcea7b58c4cb11fbe59c97a0a4b31cae3c8c"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-manylinux_2_28_s390x.whl", hash = "sha256:ef5a7178fcc73b7d8c07229e89f8eb45b2908a9238eb90dcfc46571ccf0383b8"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:d2ed1b3cb9ff1c10e6e8b00941bb2e5bb568b307bfc6b17dffbbe8be5eecba86"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:72ac9762a9f8ce74c9eed4a4e74306f2f18613a6b71fa065495a67ac227b3056"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f5cb182f6396706dc6cc1896dd02b1c889d644c081b0cdec38747573db88a7d7"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:3a3178b4873df8ef9457a4875703488eb1622632a9cee6d76464b60e90adbfcd"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e094ec83694b59d263802ed03a8384594fcce477ce484b0cbcd0008a211ca751"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-win32.whl", hash = "sha256:4329422de653cdb2b72afa39b0aa04252fca9071550044904b2e7036d9d97fe4"},
|
||||
{file = "lxml-5.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd3be6481ef54b8cfd0e1e953323b7aa9d9789b94842d0e5b142ef4bb7999539"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b5aff6f3e818e6bdbbb38e5967520f174b18f539c2b9de867b1e7fde6f8d95a4"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:942a5d73f739ad7c452bf739a62a0f83e2578afd6b8e5406308731f4ce78b16d"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:460508a4b07364d6abf53acaa0a90b6d370fafde5693ef37602566613a9b0779"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529024ab3a505fed78fe3cc5ddc079464e709f6c892733e3f5842007cec8ac6e"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ca56ebc2c474e8f3d5761debfd9283b8b18c76c4fc0967b74aeafba1f5647f9"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a81e1196f0a5b4167a8dafe3a66aa67c4addac1b22dc47947abd5d5c7a3f24b5"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00b8686694423ddae324cf614e1b9659c2edb754de617703c3d29ff568448df5"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c5681160758d3f6ac5b4fea370495c48aac0989d6a0f01bb9a72ad8ef5ab75c4"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:2dc191e60425ad70e75a68c9fd90ab284df64d9cd410ba8d2b641c0c45bc006e"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:67f779374c6b9753ae0a0195a892a1c234ce8416e4448fe1e9f34746482070a7"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:79d5bfa9c1b455336f52343130b2067164040604e41f6dc4d8313867ed540079"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3d3c30ba1c9b48c68489dc1829a6eede9873f52edca1dda900066542528d6b20"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1af80c6316ae68aded77e91cd9d80648f7dd40406cef73df841aa3c36f6907c8"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4d885698f5019abe0de3d352caf9466d5de2baded00a06ef3f1216c1a58ae78f"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aea53d51859b6c64e7c51d522c03cc2c48b9b5d6172126854cc7f01aa11f52bc"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-win32.whl", hash = "sha256:d90b729fd2732df28130c064aac9bb8aff14ba20baa4aee7bd0795ff1187545f"},
|
||||
{file = "lxml-5.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1dc4ca99e89c335a7ed47d38964abcb36c5910790f9bd106f2a8fa2ee0b909d2"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:773e27b62920199c6197130632c18fb7ead3257fce1ffb7d286912e56ddb79e0"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ce9c671845de9699904b1e9df95acfe8dfc183f2310f163cdaa91a3535af95de"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9454b8d8200ec99a224df8854786262b1bd6461f4280064c807303c642c05e76"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cccd007d5c95279e529c146d095f1d39ac05139de26c098166c4beb9374b0f4d"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fce1294a0497edb034cb416ad3e77ecc89b313cff7adbee5334e4dc0d11f422"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:24974f774f3a78ac12b95e3a20ef0931795ff04dbb16db81a90c37f589819551"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:497cab4d8254c2a90bf988f162ace2ddbfdd806fce3bda3f581b9d24c852e03c"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e794f698ae4c5084414efea0f5cc9f4ac562ec02d66e1484ff822ef97c2cadff"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2c62891b1ea3094bb12097822b3d44b93fc6c325f2043c4d2736a8ff09e65f60"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:142accb3e4d1edae4b392bd165a9abdee8a3c432a2cca193df995bc3886249c8"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1a42b3a19346e5601d1b8296ff6ef3d76038058f311902edd574461e9c036982"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4291d3c409a17febf817259cb37bc62cb7eb398bcc95c1356947e2871911ae61"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4f5322cf38fe0e21c2d73901abf68e6329dc02a4994e483adbcf92b568a09a54"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0be91891bdb06ebe65122aa6bf3fc94489960cf7e03033c6f83a90863b23c58b"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:15a665ad90054a3d4f397bc40f73948d48e36e4c09f9bcffc7d90c87410e478a"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-win32.whl", hash = "sha256:d5663bc1b471c79f5c833cffbc9b87d7bf13f87e055a5c86c363ccd2348d7e82"},
|
||||
{file = "lxml-5.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:bcb7a1096b4b6b24ce1ac24d4942ad98f983cd3810f9711bcd0293f43a9d8b9f"},
|
||||
{file = "lxml-5.4.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:7be701c24e7f843e6788353c055d806e8bd8466b52907bafe5d13ec6a6dbaecd"},
|
||||
{file = "lxml-5.4.0-cp36-cp36m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb54f7c6bafaa808f27166569b1511fc42701a7713858dddc08afdde9746849e"},
|
||||
{file = "lxml-5.4.0-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97dac543661e84a284502e0cf8a67b5c711b0ad5fb661d1bd505c02f8cf716d7"},
|
||||
{file = "lxml-5.4.0-cp36-cp36m-manylinux_2_28_x86_64.whl", hash = "sha256:c70e93fba207106cb16bf852e421c37bbded92acd5964390aad07cb50d60f5cf"},
|
||||
{file = "lxml-5.4.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:9c886b481aefdf818ad44846145f6eaf373a20d200b5ce1a5c8e1bc2d8745410"},
|
||||
{file = "lxml-5.4.0-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:fa0e294046de09acd6146be0ed6727d1f42ded4ce3ea1e9a19c11b6774eea27c"},
|
||||
{file = "lxml-5.4.0-cp36-cp36m-win32.whl", hash = "sha256:61c7bbf432f09ee44b1ccaa24896d21075e533cd01477966a5ff5a71d88b2f56"},
|
||||
{file = "lxml-5.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:7ce1a171ec325192c6a636b64c94418e71a1964f56d002cc28122fceff0b6121"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:795f61bcaf8770e1b37eec24edf9771b307df3af74d1d6f27d812e15a9ff3872"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29f451a4b614a7b5b6c2e043d7b64a15bd8304d7e767055e8ab68387a8cacf4e"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:891f7f991a68d20c75cb13c5c9142b2a3f9eb161f1f12a9489c82172d1f133c0"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4aa412a82e460571fad592d0f93ce9935a20090029ba08eca05c614f99b0cc92"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:ac7ba71f9561cd7d7b55e1ea5511543c0282e2b6450f122672a2694621d63b7e"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:c5d32f5284012deaccd37da1e2cd42f081feaa76981f0eaa474351b68df813c5"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:ce31158630a6ac85bddd6b830cffd46085ff90498b397bd0a259f59d27a12188"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:31e63621e073e04697c1b2d23fcb89991790eef370ec37ce4d5d469f40924ed6"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-win32.whl", hash = "sha256:be2ba4c3c5b7900246a8f866580700ef0d538f2ca32535e991027bdaba944063"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:09846782b1ef650b321484ad429217f5154da4d6e786636c38e434fa32e94e49"},
|
||||
{file = "lxml-5.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:eaf24066ad0b30917186420d51e2e3edf4b0e2ea68d8cd885b14dc8afdcf6556"},
|
||||
{file = "lxml-5.4.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b31a3a77501d86d8ade128abb01082724c0dfd9524f542f2f07d693c9f1175f"},
|
||||
{file = "lxml-5.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e108352e203c7afd0eb91d782582f00a0b16a948d204d4dec8565024fafeea5"},
|
||||
{file = "lxml-5.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a11a96c3b3f7551c8a8109aa65e8594e551d5a84c76bf950da33d0fb6dfafab7"},
|
||||
{file = "lxml-5.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:ca755eebf0d9e62d6cb013f1261e510317a41bf4650f22963474a663fdfe02aa"},
|
||||
{file = "lxml-5.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:4cd915c0fb1bed47b5e6d6edd424ac25856252f09120e3e8ba5154b6b921860e"},
|
||||
{file = "lxml-5.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:226046e386556a45ebc787871d6d2467b32c37ce76c2680f5c608e25823ffc84"},
|
||||
{file = "lxml-5.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:b108134b9667bcd71236c5a02aad5ddd073e372fb5d48ea74853e009fe38acb6"},
|
||||
{file = "lxml-5.4.0-cp38-cp38-win32.whl", hash = "sha256:1320091caa89805df7dcb9e908add28166113dcd062590668514dbd510798c88"},
|
||||
{file = "lxml-5.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:073eb6dcdf1f587d9b88c8c93528b57eccda40209cf9be549d469b942b41d70b"},
|
||||
{file = "lxml-5.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bda3ea44c39eb74e2488297bb39d47186ed01342f0022c8ff407c250ac3f498e"},
|
||||
{file = "lxml-5.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9ceaf423b50ecfc23ca00b7f50b64baba85fb3fb91c53e2c9d00bc86150c7e40"},
|
||||
{file = "lxml-5.4.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:664cdc733bc87449fe781dbb1f309090966c11cc0c0cd7b84af956a02a8a4729"},
|
||||
{file = "lxml-5.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67ed8a40665b84d161bae3181aa2763beea3747f748bca5874b4af4d75998f87"},
|
||||
{file = "lxml-5.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b4a3bd174cc9cdaa1afbc4620c049038b441d6ba07629d89a83b408e54c35cd"},
|
||||
{file = "lxml-5.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:b0989737a3ba6cf2a16efb857fb0dfa20bc5c542737fddb6d893fde48be45433"},
|
||||
{file = "lxml-5.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:dc0af80267edc68adf85f2a5d9be1cdf062f973db6790c1d065e45025fa26140"},
|
||||
{file = "lxml-5.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:639978bccb04c42677db43c79bdaa23785dc7f9b83bfd87570da8207872f1ce5"},
|
||||
{file = "lxml-5.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5a99d86351f9c15e4a901fc56404b485b1462039db59288b203f8c629260a142"},
|
||||
{file = "lxml-5.4.0-cp39-cp39-win32.whl", hash = "sha256:3e6d5557989cdc3ebb5302bbdc42b439733a841891762ded9514e74f60319ad6"},
|
||||
{file = "lxml-5.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:a8c9b7f16b63e65bbba889acb436a1034a82d34fa09752d754f88d708eca80e1"},
|
||||
{file = "lxml-5.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1b717b00a71b901b4667226bba282dd462c42ccf618ade12f9ba3674e1fabc55"},
|
||||
{file = "lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27a9ded0f0b52098ff89dd4c418325b987feed2ea5cc86e8860b0f844285d740"},
|
||||
{file = "lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b7ce10634113651d6f383aa712a194179dcd496bd8c41e191cec2099fa09de5"},
|
||||
{file = "lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:53370c26500d22b45182f98847243efb518d268374a9570409d2e2276232fd37"},
|
||||
{file = "lxml-5.4.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c6364038c519dffdbe07e3cf42e6a7f8b90c275d4d1617a69bb59734c1a2d571"},
|
||||
{file = "lxml-5.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b12cb6527599808ada9eb2cd6e0e7d3d8f13fe7bbb01c6311255a15ded4c7ab4"},
|
||||
{file = "lxml-5.4.0-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5f11a1526ebd0dee85e7b1e39e39a0cc0d9d03fb527f56d8457f6df48a10dc0c"},
|
||||
{file = "lxml-5.4.0-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48b4afaf38bf79109bb060d9016fad014a9a48fb244e11b94f74ae366a64d252"},
|
||||
{file = "lxml-5.4.0-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:de6f6bb8a7840c7bf216fb83eec4e2f79f7325eca8858167b68708b929ab2172"},
|
||||
{file = "lxml-5.4.0-pp37-pypy37_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5cca36a194a4eb4e2ed6be36923d3cffd03dcdf477515dea687185506583d4c9"},
|
||||
{file = "lxml-5.4.0-pp37-pypy37_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b7c86884ad23d61b025989d99bfdd92a7351de956e01c61307cb87035960bcb1"},
|
||||
{file = "lxml-5.4.0-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:53d9469ab5460402c19553b56c3648746774ecd0681b1b27ea74d5d8a3ef5590"},
|
||||
{file = "lxml-5.4.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:56dbdbab0551532bb26c19c914848d7251d73edb507c3079d6805fa8bba5b706"},
|
||||
{file = "lxml-5.4.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14479c2ad1cb08b62bb941ba8e0e05938524ee3c3114644df905d2331c76cd57"},
|
||||
{file = "lxml-5.4.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:32697d2ea994e0db19c1df9e40275ffe84973e4232b5c274f47e7c1ec9763cdd"},
|
||||
{file = "lxml-5.4.0-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:24f6df5f24fc3385f622c0c9d63fe34604893bc1a5bdbb2dbf5870f85f9a404a"},
|
||||
{file = "lxml-5.4.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:151d6c40bc9db11e960619d2bf2ec5829f0aaffb10b41dcf6ad2ce0f3c0b2325"},
|
||||
{file = "lxml-5.4.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:4025bf2884ac4370a3243c5aa8d66d3cb9e15d3ddd0af2d796eccc5f0244390e"},
|
||||
{file = "lxml-5.4.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9459e6892f59ecea2e2584ee1058f5d8f629446eab52ba2305ae13a32a059530"},
|
||||
{file = "lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47fb24cc0f052f0576ea382872b3fc7e1f7e3028e53299ea751839418ade92a6"},
|
||||
{file = "lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50441c9de951a153c698b9b99992e806b71c1f36d14b154592580ff4a9d0d877"},
|
||||
{file = "lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ab339536aa798b1e17750733663d272038bf28069761d5be57cb4a9b0137b4f8"},
|
||||
{file = "lxml-5.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:9776af1aad5a4b4a1317242ee2bea51da54b2a7b7b48674be736d463c999f37d"},
|
||||
{file = "lxml-5.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:63e7968ff83da2eb6fdda967483a7a023aa497d85ad8f05c3ad9b1f2e8c84987"},
|
||||
{file = "lxml-5.4.0.tar.gz", hash = "sha256:d12832e1dbea4be280b22fd0ea7c9b87f0d8fc51ba06e92dc62d52f804f78ebd"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:09dd5b7075dc2f7709654a46543ba1ea3c2e217b2ed8fbd413a8a945a0f40f60"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f6ac4ef4d82dff54670227a69c67782ae0b811b5cf6b17954f1e8f7502fc0d1d"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:556e94a63c9b04716f8e4de2abb65775061f846e89331b6c5be79183a24f98ea"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6bf403fbb3b3e348a561a5f4f0b9961835657981c802a1df03653eef8a9074"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dde6131244bba38a17c745836ba190bc753fd73c9291666287fd0a3fa3dcf30"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98fc784c2c1440667aeedf8465bdfe10208acf0ead656a2c68627299f546b315"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:add8cf6ddf9a65116119a28ece0f7886e30af27ba724a7594305f1d1b58a92a1"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cf9d57306d848218f3601fee7601fab1a327c942d56e2e97610583cb4dd74206"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88136950da4d13c318bde414ce10219931937851327f44328f2df4d2c4614067"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cecdd5dfdc87b1fd87dbf81d4b037a544f47f4c744200a67013771682d67686a"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:cd312b9692e831d2ffcad61eab31d91d4b4655a962e61de8fb410472cbcd37aa"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:5b7328b46d49fc9477d91ae8f6d55340347d827b7734ba3ea33faae0efef1383"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37a58976370f36d9329d118ad0b953c5aeb9119ac9c6a4e258942a225d0573a1"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-win32.whl", hash = "sha256:cea3f4c1af79af13cdb2da0c028111d8f8522d4f22a000c82385535f24e5cf3a"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:3abf332af33a74288675d936fe861fd4344da0dd6622193fbc4f2bfbb35536b5"},
|
||||
{file = "lxml-6.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:8dadbe5b217ff35b6a8d16610dd710219b59b76d13f0e3f0d9f36786206e4485"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:53b7d2b7a10b1c35c0a5e21e9224accf60c1bbfba523990732e521b2b73adef2"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3f333630ab480244a1bff72043e511a91eb22e7595dead8653ee5612dd8f3d"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a4bbea04c97f6d78a48e3fbc1cb9116d2780b1b39e03a23f6eb9b603fd61f510"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:db1d75f6617a49c1c01bc7023713e0ff59ab32c9579ae62a7674c0e34f3b0b0a"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a12689be69a28ddaa0ab99a5a1137da2afd5f8f16df7b5680b66f616d3eda1d"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b73c339ae29b90fd2d06e58ebd555a751bde9cd6bbd36cc0281b9a2c94e9d8"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:752d3bbfe874715ccd0aec7f88d7fc623c0f1fd7aa7b3238a084e017bad2a009"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:6b1761fbf9ec984e2e9d9c589ef5f5fd684b7c19f92aadd567a26c5224958db6"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d680fbcb768404c601ecb43519ecd8461f6954cb11c06a78962f666832ccfca8"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:162af1091cd785f2f27e62d3547ae9bc58ec5c86dd314d67021fd02463708d83"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e9308ff8241c532df3f3e570f9a5aeed6c853f888512ba4b75638d7c11c95ef6"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5f6994074ebae6ffb04447268e37dc16edc304f9859cf91acb86e0af6c1b395c"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80c2dfadb855da477cf73373ad29a333535dedb9b12bad02c9814c8e2b43bf08"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-win32.whl", hash = "sha256:30a89d3ac8faec007453fb541f3f46807eeec88edd5826f6e3fe001752a2c621"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:abbefa31eee84842140f67acef1c828e28bba8bbf0c3bc6e5492a9af88152c28"},
|
||||
{file = "lxml-6.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:dcb292aa7fe485ceff7af4f92e46c5af397daec5dff64871a528f0fc47a3cc5b"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:104c09bda8d2a562824c0e319d0768ce26a779b7601e0931d33b09b53c392ef7"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:25c6997a9a534e016695a0ba06b2f07945de682731ff01065b6d5a4474179da1"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c921ba5c51e4e9f63b8b00267d06566e1f63407408a0496da2d1d0bfc819c7fc"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:54a7f95e4de5fb94e2f9f4b9055c6ba33bf3d628fd77a1d647c5923caa2cdcdc"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f2ec43df44b1f76249ee0a615334f9b5b060e1c8bd90e706dad2d14d02f383"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:70ef8a7e102a1508f8121aae5b0867abd663f72c14f0a9c937e6554cb4587b7b"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ebe6af670449830d6d9b752c256a983291c766a1365ba5d5460048f9e33a7818"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:27acc820660aaffa4f7c087f29120e12980f7779d56d8492d263170111284740"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:1db753c9115ec7100d073b744d17e25e88a8f90f5c39b2f5dd878149af59671f"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4f469aebd783bb741c2ecb2a681008fd26bfe5c16a9a72ed5467f834e810df2"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:766b010012d59470072c1816b5b6c69f1d243e5db36ea5968e94accf430a4635"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b8d812c6011c08b8111a15e54dd990b8923692d80adf35488bee34026c35accf"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:fe0306bd29505a9177aac19f1877174b0e7422c222a59f70b2cd41633448c3dc"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5ba186ad207446c65d3bb3d3e0412b032b1d9f595e59861e2354798c5703d955"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aa366a1e55b8ebfe8ca8ddc3cfe75c8ebade181aeb0f661d0cb05986b647f72a"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-win32.whl", hash = "sha256:126c93f7f56f0eda92f6d8c619edc463a4f23d9252f1c9d0405a76f25fa9f11a"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:26e6eda8d38c1fcab1090dd196ee87cbd13788e531937610e2589085de074e77"},
|
||||
{file = "lxml-6.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:6540377fbd53fe1b629172288c464fb18db11ce1fa7dc15891da10aa9dcc3e7f"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:68a9198d0fc122d14bb76837de9aa80cf84caed990b5b237f532ed87d3706736"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7d47866cb32fb503450b6edc9df355d10dc49836af2e89901bd6ac6b0896d9d9"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb7c9811bfaa8b1ed5ed319f5d370dfbcaa59d52ea64be2a5a85e18195930354"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:762ff394d5bd56da0cf034a23dcce4e13923f15321a2adfa2ac00201dc6d3fca"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a088f287f7d8275a33c07f2cac6c50b9319309a0200a39e7e75d80c707723099"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e902da4b04e6b52e5893900d4b8ab46068f75f3561f01bf1080957f9fd932ed6"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1d4962d4c66bf830a7e59ed6cfc17d148149898a3aefa8ec6e59763e6e3ed085"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:581d4c8ae690a6609e64862dd6b7c2489635c2d13907fc2b20f2bc200ff1d21e"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:876e1ff5930ed8bf295ec5ef9a8155e9b6b1876bbf1deed8b3a8069311875a8f"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9eb9b5a968f6e0f6d640092a567e14529ff8cea2e29d00da6f78a79fa49f013c"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aa49e06d94aba782c6a02eecb7e507969e7e7a41b267f1b359bb35585f295d5b"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:70cdfd80589d59e43e18005dd7244e8895e93db8ab6a620b7e23df5445a4e3d2"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:aad9aa39483ed8ec44d6d2e59e5b98a0d80676ef0d92f44bfc374836111f62f5"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d49514be2f28d895c38cf9d2b72d7b9a07d00314519f456c0b50b53cfcf4c785"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:47402e62c52ff5988c1e8c6c63177f5708bccf48e366dea4e3dcf1e645e04947"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-win32.whl", hash = "sha256:3483644525531e1d5762b0c44a8e18b6efba321b6dcf8a8952de10b037618bca"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:a10bd2fd62e8ce916ececb342f348f190724a098c1faa056fdfb2a22ad5e8660"},
|
||||
{file = "lxml-6.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:424aa57aca0897eb922aef34395bd1289b3b6f04e6bae20ea123c0c7e333cffc"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:19b7ab10b210b0b3ad7985d9ac4eb66ab09a90b20fe6e2f7ba55d01a234345d0"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c08e5c694306507275f2290073350c4f32e383db15213b2c69e7ff39c1193840"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:74a9717fd0d82effef5c2854f0d917231d5324b5a3eb7275c43ac9fa32f97a14"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efe0374196335f93b53269acd811b944f2e6bdc88e8894f214bd636455484909"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac931cdc9442c1763b8a8f6cd62c0c938737eafc5be75eff88df55fc73bc0d00"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:aee395f5d0927f947758b4ec119fd5fc8ec71f07a1c5c52077b30b04c0fa6955"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9395002973c827b3ed67db77e6ec09f092919a587022174554096a269378fb13"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:73bc2086f141224ebddb7fc5c6a36ca58b31b94b561e1dfe8e073e3270fad1e7"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:3779def59032b81e44a5f70096ef6bf2082f8d901937dca354474ba09782e245"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:86c89b9d55ebf820ad7c90bc533410f0d098054f293351f10603c0c46ff598f5"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19607c6bbff2a44cf3fe8250abccd20942d3462473e0a721d01d379ed017e462"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c6ed5141a5c7507cf3ee76bd363b0d6f801e3321adc35b5d825a23115faa5465"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:62aeb7e85b5d60320b9d77eef2e773994e2c0ce10121b277e0a19804e1654a5a"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b1b963fd8f5caa68e99dfae060d54de1fe9cba899b8718b44a00cdca53c3e590"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63876be28efefa04a1df615b46770e82042cce445cfdce55160522f57b231ccb"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-win32.whl", hash = "sha256:7f7a92e8583f06b1fd49d01158143b8461cfcd135dcb10ec807270a3051bd603"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:b2d444f2e66624d68e9c6b211e28a76e22fff5fcabcfff4deac18b529b7d4137"},
|
||||
{file = "lxml-6.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3fd9728a2735fda14f4e8235830c86b539e9661e849665bf926d3f867943b4bf"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:787b2496d0dbe8cd180984e8d29e3a6f76e7ea34db781cb3bd55e4ba1ef8b4ee"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2c8daa471358dc2d6fcf02165e80ec68f77871a286df95bc5cc3816153b0fd2c"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:acd7d70b64c0aae0c7922cca83d288a16f5f6da523637697872253415269baef"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4f0dd2f01f9f8a89f565d000e03abcf0a13d692a346c8d22f628d49af098777a"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b7e8a14c8634bf6f7a568634cb395305a6d964aeb5b7ee32248094bed3a7e2c"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:86281fbdd6a8162756f8d603f37e3435bfa38043adb79c6dc6a2dfee065e7525"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5d7152ec39ca7c402d8fb9bad86140a15b9503bd0c54484e3f1bbe3dd37ceca"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:88d8cb75b9d82858497a5393e3c63cfbf03035225e4b35a49ed7ccb151e4dc0e"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f64ec5397ea6a41fc1b4af0380d79b44a755b5531dcaccd9940fb260dca93038"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d34bbf07dbc7ca5970671b1512e928991fb5e9d95365636c9b2d8b4f53af405e"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:17e0e18d4ad8adbd0399291bc44845b69d9dd68439a3cdebdf35ff902ec05072"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:3ab541146f1f6968c462d6c2ac495148e8cdba2f8347700b2141b6ec5a75bf52"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2a0217714657e023ef4293500f65aa20fce6164c8fd6b08fa5bd4a859fb14b9b"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:05a82eb6e1530a64f26225b55cbd178113bd0b5af1c2b625f25e5296742c26d2"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9e36f163528fc50cbef305f02a5fd66d404edf7049cdaff211dbc2cba5a7013e"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-win32.whl", hash = "sha256:649dda677cf3bd6ac9ae14007ba0c824ded8ce5808b53fc7431d9140399118c1"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:793033d6c5cdf33a573f910d9bea14ef8f5771820411d118da8e1182edb53d5e"},
|
||||
{file = "lxml-6.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:58bb955caba94e467d2a96da17660d2d704e0675894cba21ab8a775b8621fd1c"},
|
||||
{file = "lxml-6.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6689e828a94eee4f139408c337bb198e014724bb8a8c26d3cfac49d119ed69a6"},
|
||||
{file = "lxml-6.1.1-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bdebcc8a75d38c7598dfb2c9ed852d7a9eb4a10d6e2d0764b919b802bf32ac88"},
|
||||
{file = "lxml-6.1.1-cp38-cp38-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8be8ad51249698103d24b0571df35a10990fbe93dd043b6c024172189485f5e3"},
|
||||
{file = "lxml-6.1.1-cp38-cp38-manylinux_2_28_i686.whl", hash = "sha256:76447f65250ed2501ead1a1552f5ce8edff159a86f308348e6a9c4acb5e1f1b4"},
|
||||
{file = "lxml-6.1.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ffecec8eb889b58ba9be5b95fb1cc78e22ea8eedea38e8736a1568fe1979250e"},
|
||||
{file = "lxml-6.1.1-cp38-cp38-win32.whl", hash = "sha256:c674693f055fa2495de12292cb45e9944199d8eaef5a2dec45175c7c61cb73e3"},
|
||||
{file = "lxml-6.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:55b03549819867ea141c0202242c4816c82e52ec36e7e648db9d8da5a3dc3ed6"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c9f79d5325907f13e1be0b3e4dacc1049d1dffc4aeee3c995284bea5fe0fab7d"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:83b6b30eb131da7a75b601f28c5d6971e6ed3e887919bf6b6a1ad3c2df289080"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:441dd227fa0690eb9fc81edabc63cdcefc212bba99b906dcf6e32cc1a9d3e533"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e07c65f443c887bbcf31cc1771d932ecc192a5273943589b3c7572b749f1ffb2"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5bec7d03d78d853597d6107854c2310ce3f761fd218fe9fe91d5101fcf6c2efe"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f76acfb5f68ba982635a53fd985a8044be98a35b43232c2a1ee235ffab3e1dd"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-manylinux_2_28_i686.whl", hash = "sha256:8d43ca737b20e106e4aebc42b2f3ae19f00ba63d7eb731698ee083d72d15646f"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:32ab449a5486f6c758e849bb86710d0e45edc24a04e250c01555f8f5653958f8"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53c909b62a0532183542fed00c5a7218258c56292d409bc789886fe1cb04c438"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:640f97d43d867bcb9c75b3af013b64850756b746cb6bce8ace83b70da3abba9d"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:469e3618338bd7ab5beb412d2439825479fcf0dab99e394ca563dbc4eaf6c834"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:aae97dfdb60715c164419ac2532a76d013c3918a665eb6cb7288098b5f349aaf"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c9a4b821dc7055bf9e05ff5719e18ec501f75c0f0bbfabd573b277559780833d"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-win32.whl", hash = "sha256:639f6c857d91d9be29bd7502348d6736dab168b54b5158cd899abf11684dc186"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:34c2d737beabfe35baada43941ed519251e9a12e779031496bcd5d539fcfd730"},
|
||||
{file = "lxml-6.1.1-cp39-cp39-win_arm64.whl", hash = "sha256:07a4a68e286ee7a1ed7dfb8af83e615757c0ccfe9f18c6b4ea6771388d9ba8c9"},
|
||||
{file = "lxml-6.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:31033dc34636ea6b7d5cc11b1ddbda78a14de858ba9d3e1ed4b69a3085bc521e"},
|
||||
{file = "lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3893c14c4b6ac5b2d54ba8cf03e99fe5104e592de491f19bd6b82756c09f8004"},
|
||||
{file = "lxml-6.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c07da4cebf6889f03ebac8d238f62318e29f495de0aa18a51ea14e61ae907e2e"},
|
||||
{file = "lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f6f0ce10945fab9c4c06ce14e22af9059d1a87493a9af4501a5b0b9187e21cf2"},
|
||||
{file = "lxml-6.1.1-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f8844cd288697c6425c9beba919302241e3278871dc6519515e72b04e987abcf"},
|
||||
{file = "lxml-6.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:ed21202aec73cda4d55d1ce57b389aadb90ffb044e6cd1080b8347efe1b1ec84"},
|
||||
{file = "lxml-6.1.1.tar.gz", hash = "sha256:ba96ae44888e0185281e937633a743ea90d5a196c6000f82565ebb0580012d40"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@ -2634,7 +2659,6 @@ cssselect = ["cssselect (>=0.7)"]
|
||||
html-clean = ["lxml_html_clean"]
|
||||
html5 = ["html5lib"]
|
||||
htmlsoup = ["BeautifulSoup4"]
|
||||
source = ["Cython (>=3.0.11,<3.1.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "lxml-html-clean"
|
||||
@ -4942,6 +4966,18 @@ files = [
|
||||
{file = "soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqids"
|
||||
version = "0.5.2"
|
||||
description = "Generate YouTube-like ids from numbers."
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "sqids-0.5.2-py3-none-any.whl", hash = "sha256:0089ba823e21fd44290c7225f02fb0b5140c36e41959c04d86d3f6f2513799be"},
|
||||
{file = "sqids-0.5.2.tar.gz", hash = "sha256:5ac08f0c5c9b6814bc2e7c79ee5931e0849d25d95c50e415771b022a44f58af9"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlparse"
|
||||
version = "0.5.5"
|
||||
@ -5415,6 +5451,21 @@ brotli = ["brotli (==1.0.9) ; os_name != \"nt\" and python_version < \"3\" and p
|
||||
secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress ; python_version == \"2.7\"", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"]
|
||||
socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "vadersentiment"
|
||||
version = "3.3.2"
|
||||
description = "VADER Sentiment Analysis. VADER (Valence Aware Dictionary and sEntiment Reasoner) is a lexicon and rule-based sentiment analysis tool that is specifically attuned to sentiments expressed in social media, and works well on texts from other domains."
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "vaderSentiment-3.3.2-py2.py3-none-any.whl", hash = "sha256:3bf1d243b98b1afad575b9f22bc2cb1e212b94ff89ca74f8a23a588d024ea311"},
|
||||
{file = "vaderSentiment-3.3.2.tar.gz", hash = "sha256:5d7c06e027fc8b99238edb0d53d970cf97066ef97654009890b83703849632f9"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
requests = "*"
|
||||
|
||||
[[package]]
|
||||
name = "vine"
|
||||
version = "5.1.0"
|
||||
@ -5980,5 +6031,5 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = ">=3.11,<3.14"
|
||||
content-hash = "40ffa724f63298ca37279d7f72dc541f372d02244481c225fa748940da8ab468"
|
||||
python-versions = ">=3.11,<3.15"
|
||||
content-hash = "cc5b3b44071d6b0ab4f05189580232cc129b4ed694ab3f0673c3d838c3af0f8a"
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
[tool.poetry]
|
||||
name = "vrobbler"
|
||||
version = "0.16.1"
|
||||
version = "49.1"
|
||||
description = ""
|
||||
authors = ["Colin Powell <colin@unbl.ink>"]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.11,<3.14"
|
||||
python = ">=3.11,<3.15"
|
||||
Django = "^4.0.3"
|
||||
django-extensions = "^3.1.5"
|
||||
python-dateutil = "^2.8.2"
|
||||
@ -59,6 +59,11 @@ feedparser = "^6.0.12"
|
||||
titlecase = "^2.4.1"
|
||||
bgg-api = "^1.1.13"
|
||||
recipe-scrapers = "^15.11.0"
|
||||
gpxpy = "^1.6.2"
|
||||
fitparse = "^1.2.0"
|
||||
lxml = ">=5.5.0"
|
||||
vaderSentiment = "^3.3.2"
|
||||
sqids = "^0.5.2"
|
||||
|
||||
[tool.poetry.group.test]
|
||||
optional = true
|
||||
@ -104,6 +109,8 @@ exclude_dirs = ["*/tests/*", "*/migrations/*"]
|
||||
[tool.poetry.scripts]
|
||||
vrobbler = "vrobbler.cli:main"
|
||||
|
||||
[tool.poetry_bumpversion.file."vrobbler/__init__.py"]
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
217
scripts/release.py
Executable file
217
scripts/release.py
Executable file
@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Cut a new release: collect DONE items from Backlog into a new Version section.
|
||||
|
||||
Usage:
|
||||
poetry run python scripts/release.py major
|
||||
poetry run python scripts/release.py minor
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_FILE = Path("PROJECT.org")
|
||||
PYPROJECT_FILE = Path("pyproject.toml")
|
||||
|
||||
BACKLOG_RE = re.compile(r"^\* Backlog\s+\[(\d+)/(\d+)\](.*)$")
|
||||
VERSION_RE = re.compile(r"^\* Version\s+(\d+\.\d+)\s+\[\d+/\d+\]")
|
||||
DONE_HEADER_RE = re.compile(r"^(\*\* DONE\s+)(.*)$")
|
||||
ITEM_HEADER_RE = re.compile(r"^\*\* ")
|
||||
|
||||
|
||||
def parse_done_line(line):
|
||||
"""Extract a clean title from a ** DONE line, stripping priority and tags."""
|
||||
rest = line[8:].strip() # remove "** DONE "
|
||||
# strip priority marker like [#A]
|
||||
rest = re.sub(r"^\[#[A-C]\]\s+", "", rest, count=1)
|
||||
# strip org-mode tags at end (space-colon-tags)
|
||||
rest = re.sub(r"\s+:\S.*:\s*$", "", rest)
|
||||
return rest
|
||||
|
||||
|
||||
def bump_version(current_major, current_minor, kind):
|
||||
if kind == "major":
|
||||
return current_major + 1, 0
|
||||
elif kind == "minor":
|
||||
return current_major, current_minor + 1
|
||||
else:
|
||||
raise ValueError(f"Unknown bump kind: {kind}")
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in ("major", "minor"):
|
||||
print(f"Usage: {sys.argv[0]} <major|minor>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
kind = sys.argv[1]
|
||||
|
||||
lines = PROJECT_FILE.read_text().splitlines(keepends=True)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 1. Identify top-level sections
|
||||
# ---------------------------------------------------------------
|
||||
section_starts = []
|
||||
for i, line in enumerate(lines):
|
||||
if line.startswith("* ") and not line.startswith("** "):
|
||||
section_starts.append(i)
|
||||
section_starts.append(len(lines))
|
||||
|
||||
backlog_idx = None
|
||||
version_idx = None
|
||||
|
||||
for idx, start in enumerate(section_starts[:-1]):
|
||||
header = lines[start].strip()
|
||||
if header.startswith("* Backlog"):
|
||||
backlog_idx = idx
|
||||
if header.startswith("* Version"):
|
||||
version_idx = idx # last occurrence wins
|
||||
|
||||
if backlog_idx is None:
|
||||
print("ERROR: no Backlog section found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if version_idx is None:
|
||||
print("ERROR: no Version section found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
backlog_start = section_starts[backlog_idx]
|
||||
backlog_end = section_starts[backlog_idx + 1]
|
||||
|
||||
# Find the newest Version section (first after Backlog) that matches
|
||||
# our expected format (e.g. "37.0" not "0.11.4").
|
||||
version_start = None
|
||||
for idx in range(backlog_idx + 1, version_idx + 1):
|
||||
header = lines[section_starts[idx]].strip()
|
||||
if VERSION_RE.match(header):
|
||||
version_start = section_starts[idx]
|
||||
break
|
||||
|
||||
if version_start is None:
|
||||
print("ERROR: no parseable Version header found", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
version_header = lines[version_start].strip()
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 2. Parse current version from the newest * Version header
|
||||
# ---------------------------------------------------------------
|
||||
vm = VERSION_RE.match(version_header)
|
||||
current_version = vm.group(1)
|
||||
major_str, minor_str = current_version.split(".")
|
||||
current_major = int(major_str)
|
||||
current_minor = int(minor_str)
|
||||
new_major, new_minor = bump_version(current_major, current_minor, kind)
|
||||
new_version = f"{new_major}.{new_minor}"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 3. Collect ** DONE items from the Backlog section
|
||||
# ---------------------------------------------------------------
|
||||
backlog_lines = lines[backlog_start:backlog_end]
|
||||
|
||||
# Split Backlog into items at each ** line (skip the section header)
|
||||
items = [] # list of (start_idx, end_idx, is_done)
|
||||
item_start = None
|
||||
for i in range(1, len(backlog_lines)):
|
||||
if ITEM_HEADER_RE.match(backlog_lines[i]):
|
||||
if item_start is not None:
|
||||
items.append((item_start, i, backlog_lines[item_start].startswith("** DONE")))
|
||||
item_start = i
|
||||
if item_start is not None:
|
||||
items.append((item_start, len(backlog_lines), backlog_lines[item_start].startswith("** DONE")))
|
||||
|
||||
done_items = [(s, e) for s, e, is_done in items if is_done]
|
||||
kept_items = [(s, e) for s, e, is_done in items if not is_done]
|
||||
|
||||
if not done_items:
|
||||
print("No DONE items found in Backlog — nothing to release.")
|
||||
sys.exit(1)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 4. Build the new Version section text
|
||||
# ---------------------------------------------------------------
|
||||
version_section_lines = [f"* Version {new_version} [{len(done_items)}/{len(done_items)}]\n"]
|
||||
for s, e in done_items:
|
||||
version_section_lines.extend(backlog_lines[s:e])
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 5. Build updated Backlog section
|
||||
# ---------------------------------------------------------------
|
||||
backlog_header_line = backlog_lines[0]
|
||||
bm = BACKLOG_RE.match(backlog_header_line.strip())
|
||||
if not bm:
|
||||
print(f"ERROR: could not parse backlog header: {backlog_header_line!r}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
done_count = int(bm.group(1))
|
||||
total_count = int(bm.group(2))
|
||||
tags = bm.group(3)
|
||||
|
||||
new_done = done_count - len(done_items)
|
||||
new_total = total_count - len(done_items)
|
||||
new_backlog_header = f"* Backlog [{new_done}/{new_total}]{tags}\n"
|
||||
|
||||
backlog_body = []
|
||||
for s, e in kept_items:
|
||||
backlog_body.extend(backlog_lines[s:e])
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 6. Assemble the new file
|
||||
# ---------------------------------------------------------------
|
||||
before_backlog = lines[:backlog_start]
|
||||
after_backlog = lines[backlog_end:version_start]
|
||||
|
||||
# Everything from the first Version section onwards
|
||||
from_version = lines[version_start:]
|
||||
|
||||
output = (
|
||||
before_backlog
|
||||
+ [new_backlog_header]
|
||||
+ backlog_body
|
||||
+ version_section_lines
|
||||
+ ["\n"]
|
||||
+ after_backlog
|
||||
+ from_version
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 7. Update pyproject.toml
|
||||
# ---------------------------------------------------------------
|
||||
pyproject = PYPROJECT_FILE.read_text()
|
||||
pyproject = re.sub(
|
||||
r'^version = "[\d.]+"',
|
||||
f'version = "{new_version}"',
|
||||
pyproject,
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 8. Write files
|
||||
# ---------------------------------------------------------------
|
||||
PROJECT_FILE.write_text("".join(output))
|
||||
PYPROJECT_FILE.write_text(pyproject)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 9. Build commit body from done item titles
|
||||
# ---------------------------------------------------------------
|
||||
commit_lines = []
|
||||
for s, e in done_items:
|
||||
title = parse_done_line(backlog_lines[s])
|
||||
if title:
|
||||
commit_lines.append(f"- {title}")
|
||||
|
||||
commit_body = "\n".join(commit_lines)
|
||||
commit_message = f"[release] Bump to version {new_version}\n\n{commit_body}"
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# 10. Git commit + tag
|
||||
# ---------------------------------------------------------------
|
||||
subprocess.run(["git", "add", str(PROJECT_FILE), str(PYPROJECT_FILE)], check=True)
|
||||
subprocess.run(["git", "commit", "-m", commit_message], check=True)
|
||||
subprocess.run(["git", "tag", new_version], check=True)
|
||||
|
||||
print(f"\nReleased v{new_version} — tag {new_version} created.")
|
||||
print(f"Moved {len(done_items)} DONE item(s) from Backlog to Version section.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@ -1,3 +1,6 @@
|
||||
import tempfile
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from birds.importer import (
|
||||
import_birding_csv,
|
||||
@ -111,6 +114,13 @@ class TestImportBirdingCSV:
|
||||
goose = next(b for b in birds if b["quantity"] == 6)
|
||||
assert goose is not None
|
||||
|
||||
def test_import_sets_stop_timestamp(self, user, birding_csv_file):
|
||||
import_birding_csv(birding_csv_file, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="Birding CSV Import").first()
|
||||
assert scrobble.stop_timestamp is not None
|
||||
expected = scrobble.timestamp + timedelta(minutes=9)
|
||||
assert scrobble.stop_timestamp == expected
|
||||
|
||||
|
||||
class TestBirdingCSVImportModel:
|
||||
def test_create_import_model(self, db, user):
|
||||
@ -119,6 +129,23 @@ class TestBirdingCSVImportModel:
|
||||
assert imp.import_type == "Birding CSV"
|
||||
assert "Birding" in str(imp)
|
||||
|
||||
def test_record_error(self, db, user):
|
||||
imp = BirdingCSVImport.objects.create(user=user)
|
||||
assert imp.error_log is None
|
||||
imp.record_error("test error")
|
||||
imp.refresh_from_db()
|
||||
assert imp.error_log is not None
|
||||
assert "test error" in imp.error_log
|
||||
|
||||
def test_record_error_appends(self, db, user):
|
||||
imp = BirdingCSVImport.objects.create(user=user)
|
||||
imp.record_error("first error")
|
||||
imp.record_error("second error")
|
||||
imp.refresh_from_db()
|
||||
assert imp.error_log.count("\n") == 1
|
||||
assert "first error" in imp.error_log
|
||||
assert "second error" in imp.error_log
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_process_via_model(self, user, birding_csv_file):
|
||||
imp = BirdingCSVImport.objects.create(user=user)
|
||||
@ -128,3 +155,35 @@ class TestBirdingCSVImportModel:
|
||||
imp.refresh_from_db()
|
||||
assert imp.process_count == 1
|
||||
assert imp.processed_finished is not None
|
||||
|
||||
def test_record_error_on_bad_csv(self, user, db):
|
||||
content = """Species,Count,Location,Observation Type,Observation Date,Start Time,Duration,Distance,Area,Party Size,Complete Checklist,# of species,Details
|
||||
Canada Goose,6,Test Park,Stationary,"Bad Date",4:15 PM,9 minute(s),,,4,true,4 species,
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".csv", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
f.write(content)
|
||||
file_path = f.name
|
||||
|
||||
errors = []
|
||||
scrobbles = import_birding_csv(file_path, user.id, record_error=errors.append)
|
||||
assert len(scrobbles) == 0
|
||||
assert len(errors) == 1
|
||||
assert "Could not parse date/time" in errors[0]
|
||||
|
||||
def test_record_error_on_bad_location(self, user, db):
|
||||
content = """Species,Count,Location,Observation Type,Observation Date,Start Time,Duration,Distance,Area,Party Size,Complete Checklist,# of species,Details
|
||||
Canada Goose,6,,Stationary,"May 10, 2026",4:15 PM,9 minute(s),,,4,true,4 species,
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".csv", delete=False, encoding="utf-8"
|
||||
) as f:
|
||||
f.write(content)
|
||||
file_path = f.name
|
||||
|
||||
errors = []
|
||||
scrobbles = import_birding_csv(file_path, user.id, record_error=errors.append)
|
||||
assert len(scrobbles) == 0
|
||||
assert len(errors) == 1
|
||||
assert "Skipping rows with no location" in errors[0]
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from birds.models import BirdingCSVImport
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
from scrobbles.models import EBirdCSVImport
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@ -35,8 +35,8 @@ class TestBirdingCSVImportViews:
|
||||
user = User.objects.create(email="birder@example.com")
|
||||
client = Client()
|
||||
client.force_login(user)
|
||||
imp = BirdingCSVImport.objects.create(user=user)
|
||||
imp = EBirdCSVImport.objects.create(user=user)
|
||||
response = client.get(
|
||||
reverse("birds:csv_import_detail", kwargs={"slug": imp.uuid})
|
||||
reverse("scrobbles:ebird-csv-import-detail", kwargs={"slug": imp.uuid})
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
@ -8,7 +8,8 @@ from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from music.models import Album, Artist, Track
|
||||
from podcasts.models import PodcastEpisode
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.models import Scrobble, ShareViewLog
|
||||
from scrobbles.sqids import encode_scrobble_share
|
||||
from tasks.models import Task
|
||||
|
||||
|
||||
@ -143,9 +144,10 @@ def test_mopidy_track_webhook_creates_track_and_scrobble(
|
||||
album = Album.objects.create(name="Sublime", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Same in the End",
|
||||
artist=artist,
|
||||
artist_fk=artist,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.artists.add(artist)
|
||||
track.albums.add(album)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
@ -181,9 +183,10 @@ def test_jellyfin_track_webhook_creates_track_and_scrobble(
|
||||
album = Album.objects.create(name="Emotion", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Emotion",
|
||||
artist=artist,
|
||||
artist_fk=artist,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.artists.add(artist)
|
||||
track.albums.add(album)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
@ -225,9 +228,10 @@ def test_mopidy_track_webhook_stores_raw_data(
|
||||
album = Album.objects.create(name="Sublime", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Same in the End",
|
||||
artist=artist,
|
||||
artist_fk=artist,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.artists.add(artist)
|
||||
track.albums.add(album)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
@ -265,10 +269,11 @@ def test_mopidy_track_webhook_stores_album_id(
|
||||
album = Album.objects.create(name="Sublime", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Same in the End",
|
||||
artist=artist,
|
||||
artist_fk=artist,
|
||||
album=album,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.artists.add(artist)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
mock_track_fc.return_value = track
|
||||
@ -303,9 +308,10 @@ def test_jellyfin_track_webhook_stores_raw_data(
|
||||
album = Album.objects.create(name="Emotion", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Emotion",
|
||||
artist=artist,
|
||||
artist_fk=artist,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.artists.add(artist)
|
||||
track.albums.add(album)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
@ -347,11 +353,11 @@ def test_jellyfin_track_webhook_stores_album_id(
|
||||
album = Album.objects.create(name="Emotion", album_artist=artist)
|
||||
track = Track.objects.create(
|
||||
title="Emotion",
|
||||
artist=artist,
|
||||
artist_fk=artist,
|
||||
album=album,
|
||||
base_run_time_seconds=60,
|
||||
)
|
||||
track.albums.add(album)
|
||||
track.artists.add(artist)
|
||||
|
||||
mock_artist_fc.return_value = artist
|
||||
mock_track_fc.return_value = track
|
||||
@ -507,6 +513,7 @@ def test_scrobble_detail_view_with_notes_as_flat_list(client):
|
||||
task=task,
|
||||
media_type="Task",
|
||||
user=user,
|
||||
visibility="public",
|
||||
log={
|
||||
"notes": ["First note", "Second note"],
|
||||
"description": "Test description",
|
||||
@ -529,6 +536,7 @@ def test_scrobble_detail_view_with_notes_as_dict_timestamps(client):
|
||||
task=task,
|
||||
media_type="Task",
|
||||
user=user,
|
||||
visibility="public",
|
||||
log={
|
||||
"notes": [
|
||||
{"2024-01-01 10:00:00": "Note at first timestamp"},
|
||||
@ -557,6 +565,7 @@ def test_scrobble_detail_view_with_notes_and_labels(client):
|
||||
task=task,
|
||||
media_type="Task",
|
||||
user=user,
|
||||
visibility="public",
|
||||
log={
|
||||
"notes": [
|
||||
{"2024-01-01 10:00:00": "Note with label"},
|
||||
@ -602,7 +611,8 @@ def test_scrobble_detail_view_post_updates_log(client):
|
||||
|
||||
scrobble.refresh_from_db()
|
||||
assert scrobble.log["description"] == "Updated description"
|
||||
assert scrobble.log["notes"] == ["Updated note"]
|
||||
assert isinstance(scrobble.log["notes"], dict)
|
||||
assert list(scrobble.log["notes"].values()) == ["Updated note"]
|
||||
|
||||
|
||||
@pytest.mark.skip("Need to refactor")
|
||||
@ -733,3 +743,293 @@ def test_gps_webhook_creates_location(client, valid_auth_token):
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert "scrobble_id" in response.data
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_share_view_shared_visibility(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="shareuser", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task, media_type="Task", user=user, visibility="shared",
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
url = reverse(
|
||||
"scrobbles:shared-detail",
|
||||
kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)},
|
||||
)
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_share_view_public_visibility(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="shareuser2", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task, media_type="Task", user=user, visibility="public",
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
url = reverse(
|
||||
"scrobbles:shared-detail",
|
||||
kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)},
|
||||
)
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_share_view_private_visibility_returns_404(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="shareuser3", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task, media_type="Task", user=user, visibility="private",
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
url = reverse(
|
||||
"scrobbles:shared-detail",
|
||||
kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)},
|
||||
)
|
||||
response = client.get(url)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_share_view_invalid_sqid_returns_404(client):
|
||||
url = reverse("scrobbles:shared-detail", kwargs={"sqid": "InvalidSqid123"})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_share_view_expired_token_returns_404(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="shareuser4", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task, media_type="Task", user=user, visibility="shared",
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
old_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
|
||||
scrobble.regenerate_share_token()
|
||||
url = reverse("scrobbles:shared-detail", kwargs={"sqid": old_sqid})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_share_view_increments_count_and_logs_view(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="shareuser5", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task, media_type="Task", user=user, visibility="shared",
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
assert scrobble.share_view_count == 0
|
||||
|
||||
url = reverse(
|
||||
"scrobbles:shared-detail",
|
||||
kwargs={"sqid": encode_scrobble_share(scrobble.id, scrobble.share_token_version)},
|
||||
)
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
|
||||
scrobble.refresh_from_db()
|
||||
assert scrobble.share_view_count == 1
|
||||
assert ShareViewLog.objects.filter(scrobble=scrobble).count() == 1
|
||||
|
||||
log_entry = ShareViewLog.objects.filter(scrobble=scrobble).first()
|
||||
assert log_entry.ip_address == "127.0.0.1"
|
||||
assert log_entry.user_agent == ""
|
||||
assert log_entry.referrer == ""
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_explore_view_shows_only_public_scrobbles(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="exploreuser", password="testpass"
|
||||
)
|
||||
public_task = Task.objects.create(title="Public Task Title")
|
||||
shared_task = Task.objects.create(title="Shared Task Title")
|
||||
private_task = Task.objects.create(title="Private Task Title")
|
||||
ts = timezone.now()
|
||||
public_scrobble = Scrobble.objects.create(
|
||||
task=public_task, media_type="Task", user=user, visibility="public",
|
||||
timestamp=ts,
|
||||
)
|
||||
Scrobble.objects.create(
|
||||
task=shared_task, media_type="Task", user=user, visibility="shared",
|
||||
timestamp=ts,
|
||||
)
|
||||
Scrobble.objects.create(
|
||||
task=private_task, media_type="Task", user=user, visibility="private",
|
||||
timestamp=ts,
|
||||
)
|
||||
|
||||
url = reverse("scrobbles:explore")
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
|
||||
content = response.content.decode()
|
||||
assert "Public Task Title" in content
|
||||
assert "Shared Task Title" not in content
|
||||
assert "Private Task Title" not in content
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_change_visibility_owner_can_change(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="visuser", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task, media_type="Task", user=user, visibility="private",
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
|
||||
client.force_login(user)
|
||||
url = reverse("scrobbles:change-visibility", kwargs={"uuid": scrobble.uuid})
|
||||
response = client.post(url, {"visibility": "shared"})
|
||||
assert response.status_code == 302
|
||||
|
||||
scrobble.refresh_from_db()
|
||||
assert scrobble.visibility == "shared"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_change_visibility_non_owner_gets_404(client):
|
||||
owner = get_user_model().objects.create_user(
|
||||
username="owner", password="testpass"
|
||||
)
|
||||
other = get_user_model().objects.create_user(
|
||||
username="other", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task, media_type="Task", user=owner, visibility="private",
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
|
||||
client.force_login(other)
|
||||
url = reverse("scrobbles:change-visibility", kwargs={"uuid": scrobble.uuid})
|
||||
response = client.post(url, {"visibility": "shared"})
|
||||
assert response.status_code == 404
|
||||
|
||||
scrobble.refresh_from_db()
|
||||
assert scrobble.visibility == "private"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_change_visibility_anonymous_redirects_to_login(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="anontest", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task, media_type="Task", user=user, visibility="private",
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
url = reverse("scrobbles:change-visibility", kwargs={"uuid": scrobble.uuid})
|
||||
response = client.post(url, {"visibility": "shared"})
|
||||
assert response.status_code == 302
|
||||
assert "/login/" in response.url
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_regenerate_share_token_invalidates_old_sqid(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="regentest", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task, media_type="Task", user=user, visibility="shared",
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
old_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
|
||||
|
||||
client.force_login(user)
|
||||
url = reverse("scrobbles:regenerate-share-token", kwargs={"uuid": scrobble.uuid})
|
||||
response = client.post(url)
|
||||
assert response.status_code == 302
|
||||
|
||||
scrobble.refresh_from_db()
|
||||
assert scrobble.share_token_version == 1
|
||||
|
||||
old_url = reverse("scrobbles:shared-detail", kwargs={"sqid": old_sqid})
|
||||
old_response = client.get(old_url)
|
||||
assert old_response.status_code == 404
|
||||
|
||||
new_sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
|
||||
new_url = reverse("scrobbles:shared-detail", kwargs={"sqid": new_sqid})
|
||||
new_response = client.get(new_url)
|
||||
assert new_response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_share_analytics_owner_can_view(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="analyticsuser", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task, media_type="Task", user=user, visibility="shared",
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
|
||||
client.force_login(user)
|
||||
url = reverse("scrobbles:share-analytics", kwargs={"uuid": scrobble.uuid})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_share_analytics_non_owner_gets_404(client):
|
||||
owner = get_user_model().objects.create_user(
|
||||
username="analyticsowner", password="testpass"
|
||||
)
|
||||
other = get_user_model().objects.create_user(
|
||||
username="analyticsother", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task, media_type="Task", user=owner, visibility="shared",
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
|
||||
client.force_login(other)
|
||||
url = reverse("scrobbles:share-analytics", kwargs={"uuid": scrobble.uuid})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_share_analytics_shows_view_logs(client):
|
||||
user = get_user_model().objects.create_user(
|
||||
username="analyticsviews", password="testpass"
|
||||
)
|
||||
task = Task.objects.create(title="Test Task")
|
||||
scrobble = Scrobble.objects.create(
|
||||
task=task, media_type="Task", user=user, visibility="shared",
|
||||
timestamp=timezone.now(),
|
||||
)
|
||||
|
||||
sqid = encode_scrobble_share(scrobble.id, scrobble.share_token_version)
|
||||
share_url = reverse("scrobbles:shared-detail", kwargs={"sqid": sqid})
|
||||
client.get(share_url)
|
||||
client.get(share_url)
|
||||
|
||||
client.force_login(user)
|
||||
url = reverse("scrobbles:share-analytics", kwargs={"uuid": scrobble.uuid})
|
||||
response = client.get(url)
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "127.0.0.1" in content
|
||||
assert ShareViewLog.objects.filter(scrobble=scrobble).count() == 2
|
||||
|
||||
0
tests/trails_tests/__init__.py
Normal file
0
tests/trails_tests/__init__.py
Normal file
281
tests/trails_tests/test_gpx_importer.py
Normal file
281
tests/trails_tests/test_gpx_importer.py
Normal file
@ -0,0 +1,281 @@
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.files import File
|
||||
|
||||
from locations.models import GeoLocation
|
||||
from scrobbles.importers.trail_gpx import (
|
||||
compute_trail_stats,
|
||||
find_route_waypoint,
|
||||
import_trail_gpx,
|
||||
parse_trackpoints,
|
||||
)
|
||||
from scrobbles.models import Scrobble, TrailGPXImport
|
||||
from trails.models import Trail, TrailLogData
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
SAMPLE_GPX = os.path.join(
|
||||
os.path.dirname(__file__), "..", "..", "data", "sample_trail.gpx"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(db):
|
||||
return User.objects.create(email="trailblazer@example.com")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_gpx_path():
|
||||
return SAMPLE_GPX
|
||||
|
||||
|
||||
class TestParseTrackpoints:
|
||||
def test_parses_gpx(self, sample_gpx_path):
|
||||
result = parse_trackpoints(sample_gpx_path)
|
||||
points = result["points"]
|
||||
assert len(points) == 837
|
||||
assert result["name"] == "Morning Run ⛅"
|
||||
assert result["description"] == "Run"
|
||||
lat, lon, ele, t = points[0]
|
||||
assert round(lat, 6) == 34.190598
|
||||
assert round(lon, 6) == -118.844015
|
||||
assert ele == 305.3
|
||||
assert t is not None
|
||||
|
||||
def test_first_and_last_times(self, sample_gpx_path):
|
||||
result = parse_trackpoints(sample_gpx_path)
|
||||
points = result["points"]
|
||||
first_time = points[0][3]
|
||||
last_time = points[-1][3]
|
||||
duration = (last_time - first_time).total_seconds()
|
||||
assert duration == pytest.approx(3770, abs=5)
|
||||
|
||||
def test_gpx_extra_metadata(self, sample_gpx_path):
|
||||
result = parse_trackpoints(sample_gpx_path)
|
||||
extra = result["extra"]
|
||||
assert extra["avg_heartrate"] == 159
|
||||
assert extra["max_heartrate"] == 183
|
||||
assert extra["avg_speed_kmh"] == pytest.approx(9.82, abs=0.1)
|
||||
assert extra["activity_type"] == "Run"
|
||||
assert extra["moving_time_seconds"] == 3008
|
||||
assert extra["total_elevation_gain_m"] == 246.4
|
||||
|
||||
|
||||
class TestImportTrailGPX:
|
||||
def test_creates_trail(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
assert Trail.objects.filter(title="Morning Run ⛅").exists()
|
||||
|
||||
def test_creates_geolocation(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
assert GeoLocation.objects.filter(lat=34.190598, lon=-118.844015).exists()
|
||||
|
||||
def test_sets_trailhead(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
trail = Trail.objects.filter(title="Morning Run ⛅").first()
|
||||
assert trail.trailhead_location is not None
|
||||
assert round(trail.trailhead_location.lat, 6) == 34.190598
|
||||
|
||||
def test_creates_scrobble(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
assert Scrobble.objects.filter(source="GPX Import").count() == 1
|
||||
|
||||
def test_scrobble_timestamps(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.timestamp.isoformat().startswith("2022-06-05T13:55:09")
|
||||
assert scrobble.stop_timestamp.isoformat().startswith("2022-06-05T14:57:59")
|
||||
assert scrobble.media_type == Scrobble.MediaType.TRAIL
|
||||
|
||||
def test_scrobble_has_trail_fk(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.trail is not None
|
||||
assert scrobble.trail.title == "Morning Run ⛅"
|
||||
|
||||
def test_scrobble_has_gpx_file(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.gpx_file
|
||||
assert scrobble.gpx_file.name.endswith(".gpx")
|
||||
|
||||
def test_lookup_existing_trail_by_trailhead(self, user, sample_gpx_path):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
trail = Trail.objects.create(title="Existing Trail", trailhead_location=geo)
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.trail.id == trail.id
|
||||
|
||||
def test_dedup(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
assert Scrobble.objects.filter(source="GPX Import").count() == 1
|
||||
|
||||
def test_scrobble_log_has_stats(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
log = scrobble.log
|
||||
assert log["distance_km"] == pytest.approx(8.2, abs=0.2)
|
||||
assert log["elevation_gain_m"] == pytest.approx(260, abs=20)
|
||||
assert log["moving_time_seconds"] == pytest.approx(3770, abs=10)
|
||||
assert log["avg_speed_kmh"] == pytest.approx(7.8, abs=0.5)
|
||||
assert log["description"] == "Run"
|
||||
|
||||
def test_scrobble_playback_position(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.playback_position_seconds == pytest.approx(3770, abs=5)
|
||||
|
||||
def test_scrobble_has_timezone(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.timezone is not None
|
||||
assert isinstance(scrobble.timezone, str)
|
||||
|
||||
def test_scrobble_log_extra_metadata(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
log = scrobble.log
|
||||
assert log["avg_heartrate"] == 159
|
||||
assert log["max_heartrate"] == 183
|
||||
assert log["activity_type"] == "Run"
|
||||
|
||||
def test_scrobble_log_no_calories_in_gpx(self, user, sample_gpx_path):
|
||||
import_trail_gpx(sample_gpx_path, user.id)
|
||||
scrobble = Scrobble.objects.filter(source="GPX Import").first()
|
||||
assert scrobble.log.get("calories") is None
|
||||
|
||||
|
||||
class TestComputeTrailStats:
|
||||
def test_computes_distance_and_elevation(self, sample_gpx_path):
|
||||
result = parse_trackpoints(sample_gpx_path)
|
||||
stats = compute_trail_stats(result["points"])
|
||||
assert stats["distance_km"] == pytest.approx(8.2, abs=0.2)
|
||||
assert stats["elevation_gain_m"] == pytest.approx(260, abs=20)
|
||||
assert stats["moving_time_seconds"] == pytest.approx(3770, abs=10)
|
||||
assert stats["avg_speed_kmh"] == pytest.approx(7.8, abs=0.5)
|
||||
|
||||
|
||||
class TestTrailGPXImportModel:
|
||||
def test_create_import_model(self, db, user, sample_gpx_path):
|
||||
imp = TrailGPXImport.objects.create(
|
||||
user=user,
|
||||
original_filename="test_trail.gpx",
|
||||
)
|
||||
assert imp.uuid is not None
|
||||
assert imp.import_type == "Trail GPX"
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_process_via_model(self, user, sample_gpx_path):
|
||||
imp = TrailGPXImport.objects.create(
|
||||
user=user,
|
||||
original_filename="Morning Run.gpx",
|
||||
)
|
||||
with open(sample_gpx_path, "rb") as f:
|
||||
imp.gpx_file.save("Morning Run.gpx", File(f), save=True)
|
||||
imp.process()
|
||||
imp.refresh_from_db()
|
||||
assert imp.process_count == 1
|
||||
assert imp.processed_finished is not None
|
||||
|
||||
|
||||
class TestFindRouteWaypoint:
|
||||
def test_returns_halfway_point(self, sample_gpx_path):
|
||||
result = parse_trackpoints(sample_gpx_path)
|
||||
pt = find_route_waypoint(result["points"])
|
||||
assert pt is not None
|
||||
lat, lon = pt
|
||||
assert lat == pytest.approx(34.177853, abs=0.001)
|
||||
assert lon == pytest.approx(-118.829944, abs=0.001)
|
||||
|
||||
def test_returns_last_point_for_short_track(self):
|
||||
points = [(34.0, -118.0, None, None), (34.001, -118.001, None, None)]
|
||||
pt = find_route_waypoint(points)
|
||||
assert pt == (34.001, -118.001)
|
||||
|
||||
def test_returns_none_for_empty_points(self):
|
||||
assert find_route_waypoint([]) is None
|
||||
|
||||
|
||||
class TestFindByTrailhead:
|
||||
def test_exact_match(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
trail = Trail.objects.create(title="Test Trail", trailhead_location=geo)
|
||||
found = Trail.find_by_trailhead(34.190598, -118.844015)
|
||||
assert found == trail
|
||||
|
||||
def test_within_tolerance(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
trail = Trail.objects.create(title="Nearby Trail", trailhead_location=geo)
|
||||
found = Trail.find_by_trailhead(34.191000, -118.844000, tolerance_m=100)
|
||||
assert found == trail
|
||||
|
||||
def test_beyond_tolerance(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
Trail.objects.create(title="Far Trail", trailhead_location=geo)
|
||||
found = Trail.find_by_trailhead(34.200000, -118.850000, tolerance_m=50)
|
||||
assert found is None
|
||||
|
||||
def test_no_trailhead_returns_none(self, db):
|
||||
Trail.objects.create(title="No Location")
|
||||
found = Trail.find_by_trailhead(34.190598, -118.844015)
|
||||
assert found is None
|
||||
|
||||
def test_same_trailhead_same_route_matches(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
trail = Trail.objects.create(
|
||||
title="Same Route Trail",
|
||||
trailhead_location=geo,
|
||||
route_lat=34.192167,
|
||||
route_lon=-118.843143,
|
||||
)
|
||||
found = Trail.find_by_trailhead(
|
||||
34.190598, -118.844015,
|
||||
route_lat=34.192167, route_lon=-118.843143,
|
||||
tolerance_m=100,
|
||||
)
|
||||
assert found == trail
|
||||
|
||||
def test_same_trailhead_different_route_does_not_match(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
Trail.objects.create(
|
||||
title="Different Route Trail",
|
||||
trailhead_location=geo,
|
||||
route_lat=34.200000,
|
||||
route_lon=-118.850000,
|
||||
)
|
||||
found = Trail.find_by_trailhead(
|
||||
34.190598, -118.844015,
|
||||
route_lat=34.192167, route_lon=-118.843143,
|
||||
tolerance_m=100,
|
||||
)
|
||||
assert found is None
|
||||
|
||||
def test_legacy_trail_without_route_still_matches(self, db):
|
||||
geo = GeoLocation.objects.create(lat=34.190598, lon=-118.844015)
|
||||
trail = Trail.objects.create(
|
||||
title="Legacy Trail",
|
||||
trailhead_location=geo,
|
||||
)
|
||||
found = Trail.find_by_trailhead(
|
||||
34.190598, -118.844015,
|
||||
route_lat=34.192167, route_lon=-118.843143,
|
||||
tolerance_m=100,
|
||||
)
|
||||
assert found == trail
|
||||
|
||||
|
||||
class TestFindOrCreate:
|
||||
def test_find_existing(self, db):
|
||||
Trail.objects.create(title="Existing Trail")
|
||||
trail = Trail.find_or_create("Existing Trail")
|
||||
assert trail.title == "Existing Trail"
|
||||
|
||||
def test_create_new(self, db):
|
||||
trail = Trail.find_or_create("New Trail")
|
||||
assert trail.title == "New Trail"
|
||||
assert Trail.objects.count() == 1
|
||||
@ -2,4 +2,5 @@
|
||||
# Django starts so that shared_task will use this app.
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ("celery_app",)
|
||||
__version__ = "42.0"
|
||||
__all__ = ("celery_app", "__version__")
|
||||
|
||||
@ -16,6 +16,7 @@ class BirdingLocationAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("uuid", "title")
|
||||
ordering = ("-created",)
|
||||
raw_id_fields = ("geo_location",)
|
||||
search_fields = ("title",)
|
||||
inlines = [
|
||||
ScrobbleInline,
|
||||
@ -25,5 +26,5 @@ class BirdingLocationAdmin(admin.ModelAdmin):
|
||||
@admin.register(BirdingCSVImport)
|
||||
class BirdingCSVImportAdmin(admin.ModelAdmin):
|
||||
date_hierarchy = "created"
|
||||
list_display = ("uuid", "process_count", "processed_finished", "processing_started")
|
||||
list_display = ("uuid", "process_count", "processed_finished", "processing_started", "error_log")
|
||||
ordering = ("-created",)
|
||||
|
||||
@ -2,13 +2,15 @@ import csv
|
||||
import logging
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
from datetime import timedelta
|
||||
|
||||
from dateutil import parser
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from birds.models import Bird, BirdSightingEntry, BirdSightingLogData, BirdingLocation
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.notifications import ScrobbleNtfyNotification
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
@ -35,12 +37,13 @@ def parse_coords(location_str):
|
||||
|
||||
def parse_timestamp(date_str, time_str):
|
||||
try:
|
||||
dt = datetime.strptime(f"{date_str} {time_str}", "%B %d, %Y %I:%M %p")
|
||||
return dt.replace(tzinfo=ZoneInfo("UTC"))
|
||||
dt_str = f"{date_str} {time_str}".strip()
|
||||
dt = parser.parse(dt_str)
|
||||
return dt
|
||||
except (ValueError, TypeError):
|
||||
try:
|
||||
dt = datetime.strptime(date_str, "%B %d, %Y")
|
||||
return dt.replace(tzinfo=ZoneInfo("UTC"))
|
||||
dt = parser.parse(date_str)
|
||||
return dt
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(f"Could not parse date/time: {date_str} {time_str}")
|
||||
return None
|
||||
@ -61,7 +64,7 @@ def parse_int(value):
|
||||
return None
|
||||
|
||||
|
||||
def import_birding_csv(file_path, user_id):
|
||||
def import_birding_csv(file_path, user_id, record_error=None):
|
||||
user = User.objects.get(id=user_id)
|
||||
new_scrobbles = []
|
||||
|
||||
@ -80,13 +83,21 @@ def import_birding_csv(file_path, user_id):
|
||||
|
||||
for (location_str, date_str, time_str), sighting_rows in groups.items():
|
||||
if not location_str:
|
||||
logger.warning("Skipping rows with no location")
|
||||
msg = "Skipping rows with no location"
|
||||
logger.warning(msg)
|
||||
if record_error:
|
||||
record_error(msg)
|
||||
continue
|
||||
|
||||
timestamp = parse_timestamp(date_str, time_str)
|
||||
if not timestamp:
|
||||
msg = f"Could not parse date/time: {date_str} {time_str}"
|
||||
if record_error:
|
||||
record_error(msg)
|
||||
continue
|
||||
|
||||
timestamp = user.profile.get_timestamp_with_tz(timestamp)
|
||||
|
||||
location_title = (
|
||||
LOCATION_COORDS_RE.sub("", location_str).strip().rstrip(",").strip()
|
||||
)
|
||||
@ -122,9 +133,10 @@ def import_birding_csv(file_path, user_id):
|
||||
)
|
||||
birds_data.append(entry.asdict)
|
||||
|
||||
duration_minutes = parse_duration(first_row.get("Duration", ""))
|
||||
logdata = BirdSightingLogData(
|
||||
birds=birds_data,
|
||||
duration_minutes=parse_duration(first_row.get("Duration", "")),
|
||||
duration_minutes=duration_minutes,
|
||||
observation_type=first_row.get("Observation Type", "").strip() or None,
|
||||
distance=first_row.get("Distance", "").strip() or None,
|
||||
area=first_row.get("Area", "").strip() or None,
|
||||
@ -134,9 +146,34 @@ def import_birding_csv(file_path, user_id):
|
||||
|
||||
log_dict = logdata.asdict
|
||||
|
||||
weather_loc = location.geo_location
|
||||
if not weather_loc:
|
||||
last_loc = (
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
media_type=Scrobble.MediaType.GEO_LOCATION,
|
||||
geo_location__isnull=False,
|
||||
)
|
||||
.order_by("-timestamp")
|
||||
.first()
|
||||
)
|
||||
if last_loc:
|
||||
weather_loc = last_loc.geo_location
|
||||
if weather_loc:
|
||||
weather = weather_loc.current_weather
|
||||
if weather:
|
||||
log_dict["weather"] = weather["description"]
|
||||
log_dict["temperature"] = weather["temp"]
|
||||
|
||||
stop_timestamp = timestamp + timedelta(minutes=duration_minutes) if duration_minutes else None
|
||||
|
||||
tz = getattr(timestamp.tzinfo, "name", None)
|
||||
|
||||
scrobble = Scrobble(
|
||||
user=user,
|
||||
timestamp=timestamp,
|
||||
timezone=tz,
|
||||
stop_timestamp=stop_timestamp,
|
||||
source="Birding CSV Import",
|
||||
birding_location=location,
|
||||
log=log_dict,
|
||||
@ -156,4 +193,6 @@ def import_birding_csv(file_path, user_id):
|
||||
|
||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||
logger.info(f"Created {len(created)} birding scrobbles")
|
||||
for scrobble in created:
|
||||
ScrobbleNtfyNotification(scrobble).send()
|
||||
return created
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-08 14:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("birds", "0002_birdingcsvimport"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="birdingcsvimport",
|
||||
name="error_log",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -61,9 +61,7 @@ class BirdSightingLogData(BaseLogData, WithPeopleLogData):
|
||||
@cached_property
|
||||
def bird_list(self) -> str:
|
||||
if self.birds:
|
||||
return ", ".join(
|
||||
[BirdSightingEntry(**b).__str__() for b in self.birds]
|
||||
)
|
||||
return ", ".join([BirdSightingEntry(**b).__str__() for b in self.birds])
|
||||
return ""
|
||||
|
||||
def as_html(self) -> str:
|
||||
@ -80,9 +78,7 @@ class BirdSightingLogData(BaseLogData, WithPeopleLogData):
|
||||
)
|
||||
|
||||
if self.area:
|
||||
html_parts.append(
|
||||
f'<div class="birding-area">Area: {self.area}</div>'
|
||||
)
|
||||
html_parts.append(f'<div class="birding-area">Area: {self.area}</div>')
|
||||
|
||||
if self.party_size:
|
||||
html_parts.append(
|
||||
@ -105,9 +101,7 @@ class BirdSightingLogData(BaseLogData, WithPeopleLogData):
|
||||
)
|
||||
|
||||
if self.guide:
|
||||
html_parts.append(
|
||||
f'<div class="birding-guide">Guide: {self.guide}</div>'
|
||||
)
|
||||
html_parts.append(f'<div class="birding-guide">Guide: {self.guide}</div>')
|
||||
|
||||
if self.duration_minutes:
|
||||
html_parts.append(
|
||||
@ -183,9 +177,7 @@ class Bird(TimeStampedModel):
|
||||
|
||||
class BirdingLocation(ScrobblableMixin):
|
||||
description = models.TextField(**BNULL)
|
||||
geo_location = models.ForeignKey(
|
||||
GeoLocation, **BNULL, on_delete=models.DO_NOTHING
|
||||
)
|
||||
geo_location = models.ForeignKey(GeoLocation, **BNULL, on_delete=models.DO_NOTHING)
|
||||
ebird_hotspot_id = models.CharField(max_length=255, **BNULL)
|
||||
|
||||
def get_absolute_url(self):
|
||||
@ -193,7 +185,7 @@ class BirdingLocation(ScrobblableMixin):
|
||||
|
||||
@property
|
||||
def subtitle(self):
|
||||
return ""
|
||||
return self.geo_location
|
||||
|
||||
@property
|
||||
def strings(self) -> ScrobblableConstants:
|
||||
@ -224,6 +216,7 @@ class BirdingCSVImport(TimeStampedModel):
|
||||
processed_finished = models.DateTimeField(**BNULL)
|
||||
process_log = models.TextField(**BNULL)
|
||||
process_count = models.IntegerField(**BNULL)
|
||||
error_log = models.TextField(**BNULL)
|
||||
csv_file = models.FileField(upload_to="birding-csv-uploads/", **BNULL)
|
||||
|
||||
class Meta:
|
||||
@ -269,9 +262,7 @@ class BirdingCSVImport(TimeStampedModel):
|
||||
self.save(update_fields=["process_log", "process_count"])
|
||||
return
|
||||
for count, scrobble in enumerate(scrobbles):
|
||||
scrobble_str = (
|
||||
f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.media_obj}"
|
||||
)
|
||||
scrobble_str = f"{scrobble.id}\t{scrobble.timestamp}\t{scrobble.media_obj}"
|
||||
log_line = f"{scrobble_str}"
|
||||
if count > 0:
|
||||
log_line = "\n" + log_line
|
||||
@ -279,6 +270,14 @@ class BirdingCSVImport(TimeStampedModel):
|
||||
self.process_count = len(scrobbles)
|
||||
self.save(update_fields=["process_log", "process_count"])
|
||||
|
||||
def record_error(self, error_message):
|
||||
log_line = f"{timezone.now().isoformat()}: {error_message}"
|
||||
if self.error_log:
|
||||
self.error_log += "\n" + log_line
|
||||
else:
|
||||
self.error_log = log_line
|
||||
self.save(update_fields=["error_log"])
|
||||
|
||||
def scrobbles(self):
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
@ -297,6 +296,13 @@ class BirdingCSVImport(TimeStampedModel):
|
||||
from birds.importer import import_birding_csv
|
||||
|
||||
self.mark_started()
|
||||
scrobbles = import_birding_csv(self.upload_file_path, self.user_id)
|
||||
self.record_log(scrobbles)
|
||||
self.mark_finished()
|
||||
try:
|
||||
scrobbles = import_birding_csv(
|
||||
self.upload_file_path, self.user_id, record_error=self.record_error
|
||||
)
|
||||
self.record_log(scrobbles)
|
||||
except Exception as e:
|
||||
self.record_error(f"Import failed: {e}")
|
||||
logger.exception(f"Import failed for {self}")
|
||||
finally:
|
||||
self.mark_finished()
|
||||
|
||||
27
vrobbler/apps/birds/static/birds/bird_sightings.js
Normal file
27
vrobbler/apps/birds/static/birds/bird_sightings.js
Normal file
@ -0,0 +1,27 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var widget = document.querySelector(".bird-sightings-widget");
|
||||
if (!widget) return;
|
||||
|
||||
var list = widget.querySelector(".bird-sightings-list");
|
||||
|
||||
widget.addEventListener("click", function (e) {
|
||||
if (e.target.classList.contains("add-sighting-row")) {
|
||||
var rows = list.querySelectorAll(".bird-sighting-row");
|
||||
var template = rows[rows.length - 1];
|
||||
if (!template) return;
|
||||
var clone = template.cloneNode(true);
|
||||
clone.querySelectorAll("select, input").forEach(function (el) {
|
||||
el.value = "";
|
||||
});
|
||||
clone.querySelector('input[name$="_quantity"]').value = "1";
|
||||
list.appendChild(clone);
|
||||
}
|
||||
|
||||
if (e.target.classList.contains("remove-sighting")) {
|
||||
var rows = list.querySelectorAll(".bird-sighting-row");
|
||||
if (rows.length > 1) {
|
||||
e.target.closest(".bird-sighting-row").remove();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,8 +1,9 @@
|
||||
from birds.models import Bird, BirdingCSVImport, BirdingLocation
|
||||
from birds.models import Bird, BirdingLocation
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse_lazy
|
||||
from django.views import generic
|
||||
from scrobbles.models import EBirdCSVImport as BirdingCSVImport
|
||||
from scrobbles.views import (
|
||||
ScrobbleableDetailView,
|
||||
ScrobbleableListView,
|
||||
@ -40,6 +41,9 @@ class BirdingCSVImportCreateView(
|
||||
def form_valid(self, form):
|
||||
self.object = form.save(commit=False)
|
||||
self.object.user = self.request.user
|
||||
self.object.original_filename = (
|
||||
form.cleaned_data["csv_file"].name
|
||||
)
|
||||
self.object.save()
|
||||
self.object.process()
|
||||
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER"))
|
||||
@ -55,5 +59,5 @@ class BirdingCSVImportDetailView(LoginRequiredMixin, generic.DetailView):
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context_data = super().get_context_data(**kwargs)
|
||||
context_data["title"] = "Birding CSV Import"
|
||||
context_data["title"] = "eBird CSV Import"
|
||||
return context_data
|
||||
|
||||
@ -90,6 +90,26 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
||||
"variant",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def override_fields(cls) -> dict:
|
||||
from scrobbles.forms import NotesDictField
|
||||
|
||||
fields = {}
|
||||
for base in cls.mro()[1:]:
|
||||
if hasattr(base, "override_fields"):
|
||||
base_fields = base.override_fields()
|
||||
fields.update(base_fields)
|
||||
custom_fields = {
|
||||
"notes": NotesDictField(required=False),
|
||||
"location_id": forms.ModelChoiceField(
|
||||
queryset=BoardGameLocation.objects.all(),
|
||||
required=False,
|
||||
widget=forms.Select(),
|
||||
),
|
||||
}
|
||||
fields.update(custom_fields)
|
||||
return fields
|
||||
|
||||
@cached_property
|
||||
def location(self):
|
||||
if not self.location_id:
|
||||
@ -133,23 +153,6 @@ class BoardGameLogData(BaseLogData, LongPlayLogData):
|
||||
|
||||
return "".join(html_parts)
|
||||
|
||||
@classmethod
|
||||
def override_fields(cls) -> dict:
|
||||
fields = {}
|
||||
for base in cls.mro()[1:]:
|
||||
if hasattr(base, "override_fields"):
|
||||
base_fields = base.override_fields()
|
||||
fields.update(base_fields)
|
||||
custom_fields = {
|
||||
"location_id": forms.ModelChoiceField(
|
||||
queryset=BoardGameLocation.objects.all(),
|
||||
required=False,
|
||||
widget=forms.Select(),
|
||||
)
|
||||
}
|
||||
fields.update(custom_fields)
|
||||
return fields
|
||||
|
||||
|
||||
class BoardGamePublisher(TimeStampedModel):
|
||||
name = models.CharField(max_length=255)
|
||||
@ -263,8 +266,9 @@ class BoardGame(ScrobblableMixin):
|
||||
"self", **BNULL, on_delete=models.DO_NOTHING
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
@property
|
||||
def subtitle(self) -> str:
|
||||
return self.publisher
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("boardgames:boardgame_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
@ -113,6 +113,8 @@ def import_chess_games_for_all_users():
|
||||
scrobbles_to_create = []
|
||||
for user in User.objects.filter(profile__lichess_username__isnull=False):
|
||||
scrobble_dict = import_chess_games_for_user_id(user.id)
|
||||
if not scrobble_dict:
|
||||
continue
|
||||
scrobbles_to_create.append(Scrobble(**scrobble_dict))
|
||||
|
||||
if scrobbles_to_create:
|
||||
|
||||
@ -173,15 +173,23 @@ class Book(LongPlayScrobblableMixin):
|
||||
genre = TaggableManager(through=ObjectWithGenres, blank=True, verbose_name="Genre")
|
||||
|
||||
def __str__(self) -> str:
|
||||
if self.issue_number and "Issue" not in str(self.title):
|
||||
return f"{self.title} - Issue {self.issue_number}"
|
||||
if self.volume_number and "Volume" not in str(self.title):
|
||||
return f"{self.title} - Volume {self.volume_number}"
|
||||
return f"{self.title}"
|
||||
return f"{self.title} - {self.subtitle}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.pages:
|
||||
self.base_run_time_seconds = int(self.pages) * int(
|
||||
self.AVG_PAGE_READING_SECONDS
|
||||
)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def subtitle(self):
|
||||
return f" by {self.author}"
|
||||
subtitle = self.author
|
||||
if self.issue_number and "Issue" not in str(self.title):
|
||||
subtitle += " - Issue {self.issue_number}"
|
||||
if self.volume_number and "Volume" not in str(self.title):
|
||||
subtitle += " - Volume {self.volume_number}"
|
||||
return subtitle
|
||||
|
||||
@property
|
||||
def strings(self) -> ScrobblableConstants:
|
||||
@ -201,6 +209,10 @@ class Book(LongPlayScrobblableMixin):
|
||||
def get_absolute_url(self):
|
||||
return reverse("books:book_detail", kwargs={"slug": self.uuid})
|
||||
|
||||
@property
|
||||
def resume_start_url(self):
|
||||
return reverse("scrobbles:start", kwargs={"uuid": self.uuid}) + "?resume=1"
|
||||
|
||||
@classmethod
|
||||
def get_from_comicvine(
|
||||
cls, title: str, overwrite: bool = False, force_new: bool = False
|
||||
@ -413,11 +425,6 @@ class Book(LongPlayScrobblableMixin):
|
||||
fname = f"{self.title}_{self.uuid}.jpg"
|
||||
self.cover.save(fname, ContentFile(r.content), save=True)
|
||||
|
||||
if self.pages:
|
||||
self.base_run_time_seconds = int(self.pages) * int(
|
||||
self.AVG_PAGE_READING_SECONDS
|
||||
)
|
||||
|
||||
self.save()
|
||||
|
||||
def fix_authors_metadata(self, openlibrary_author_id):
|
||||
|
||||
@ -110,14 +110,13 @@ class ChartRecord(TimeStampedModel):
|
||||
|
||||
@property
|
||||
def period_str(self) -> str:
|
||||
period = str(self.year)
|
||||
if self.month:
|
||||
period = f"{calendar.month_name[self.month]} {period}"
|
||||
if self.week:
|
||||
period = f"Week {self.week}, {period}"
|
||||
if self.day:
|
||||
period = f"{calendar.month_name[self.month]} {self.day}, {period}"
|
||||
return period
|
||||
return f"{calendar.month_name[self.month]} {self.day}, {self.year}"
|
||||
if self.week:
|
||||
return f"Week {self.week}, {self.year}"
|
||||
if self.month:
|
||||
return f"{calendar.month_name[self.month]} {self.year}"
|
||||
return str(self.year)
|
||||
|
||||
@property
|
||||
def period_type(self) -> str:
|
||||
|
||||
@ -83,6 +83,12 @@
|
||||
{% endfor %}
|
||||
<a href="#" class="btn btn-xs btn-outline-secondary" style="padding:1px 3px;font-size:9px;" onclick="var w=prompt('Week number (1-52):');if(w)this.href='/charts/?date={{ year }}-W'+w.padStart(2,'0');return false;">+</a>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-12">
|
||||
<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>
|
||||
{% if chart_type == "maloja" %}
|
||||
{% include "scrobbles/_top_charts.html" %}
|
||||
{% else %}
|
||||
@ -337,7 +343,7 @@
|
||||
{% for chart in charts.trail %}
|
||||
<tr>
|
||||
<td>{{chart.rank}}</td>
|
||||
<td><a href="{{chart.trail.get_absolute_url}}">{{chart.trail.name}}</a></td>
|
||||
<td><a href="{{chart.trail.get_absolute_url}}">{{chart.trail.title}}</a></td>
|
||||
<td>{{chart.count}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
from charts.views import ChartRecordView, SpotifyTracksView
|
||||
from charts.views import (
|
||||
BandcampTracksView,
|
||||
BirdsChartView,
|
||||
ChartDetailView,
|
||||
ChartRecordView,
|
||||
SpotifyTracksView,
|
||||
)
|
||||
from django.urls import path
|
||||
|
||||
app_name = "charts"
|
||||
@ -6,4 +12,7 @@ app_name = "charts"
|
||||
urlpatterns = [
|
||||
path("charts/", ChartRecordView.as_view(), name="charts-home"),
|
||||
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"),
|
||||
path("charts/<slug:media_type>/", ChartDetailView.as_view(), name="chart-detail"),
|
||||
]
|
||||
|
||||
@ -104,8 +104,8 @@ def build_charts(
|
||||
|
||||
media_config = {
|
||||
"artist": {
|
||||
"filter": Q(track__isnull=False) & Q(track__artist__isnull=False),
|
||||
"values": "track__artist",
|
||||
"filter": Q(track__isnull=False) & Q(track__artists__isnull=False),
|
||||
"values": "track__artists",
|
||||
"annotate": Count("id", distinct=True),
|
||||
},
|
||||
"album": {
|
||||
|
||||
@ -3,6 +3,7 @@ from datetime import timedelta
|
||||
|
||||
from charts.models import ChartRecord
|
||||
from django.db.models import Count, Q
|
||||
from django.http import Http404
|
||||
from django.utils import timezone
|
||||
from django.views.generic import TemplateView
|
||||
from profiles.utils import now_user_timezone
|
||||
@ -220,6 +221,7 @@ class ChartRecordView(TemplateView):
|
||||
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
|
||||
|
||||
@ -300,6 +302,7 @@ class ChartRecordView(TemplateView):
|
||||
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
|
||||
|
||||
@ -415,6 +418,20 @@ class ChartRecordView(TemplateView):
|
||||
),
|
||||
}
|
||||
|
||||
bird_data = self.get_bird_chart_data(
|
||||
user,
|
||||
year=context.get("year"),
|
||||
month=context.get("month"),
|
||||
week=context.get("week"),
|
||||
day=context.get("day"),
|
||||
)
|
||||
context["birds_chart"] = bird_data["top_birds"]
|
||||
context["bird_stats"] = {
|
||||
"total_species": bird_data["total_species"],
|
||||
"total_outings": bird_data["total_outings"],
|
||||
"total_individuals": bird_data["total_individuals"],
|
||||
}
|
||||
|
||||
context["chart_years"] = self.get_available_years(user)
|
||||
context["period_type"] = self.get_period_type()
|
||||
context["prev_period"] = self.get_prev_period_url(user)
|
||||
@ -558,17 +575,176 @@ class ChartRecordView(TemplateView):
|
||||
return f"/charts/?date={year + 1}"
|
||||
return None
|
||||
|
||||
def get_bird_chart_data(
|
||||
self, user, year=None, month=None, week=None, day=None, limit=20
|
||||
):
|
||||
from birds.models import Bird
|
||||
|
||||
filters = {
|
||||
"user": user,
|
||||
"media_type": Scrobble.MediaType.BIRDING_LOCATION,
|
||||
}
|
||||
if year:
|
||||
filters["timestamp__year"] = year
|
||||
if month:
|
||||
filters["timestamp__month"] = month
|
||||
if week:
|
||||
filters["timestamp__week"] = week
|
||||
if day:
|
||||
filters["timestamp__day"] = day
|
||||
|
||||
scrobbles = Scrobble.objects.filter(**filters)
|
||||
|
||||
bird_counts = {}
|
||||
outings = 0
|
||||
for scrobble in scrobbles.iterator():
|
||||
outings += 1
|
||||
birds_data = scrobble.log.get("birds", []) if scrobble.log else []
|
||||
for entry in birds_data:
|
||||
bird_id = entry.get("bird_id")
|
||||
quantity = entry.get("quantity", 1)
|
||||
if bird_id:
|
||||
bird_counts[bird_id] = bird_counts.get(bird_id, 0) + quantity
|
||||
|
||||
sorted_birds = sorted(bird_counts.items(), key=lambda x: x[1], reverse=True)[
|
||||
:limit
|
||||
]
|
||||
|
||||
bird_ids = [bid for bid, _ in sorted_birds]
|
||||
birds = Bird.objects.filter(id__in=bird_ids)
|
||||
bird_map = {b.id: b for b in birds}
|
||||
|
||||
top_birds = [
|
||||
{"bird": bird_map[bid], "count": count}
|
||||
for bid, count in sorted_birds
|
||||
if bid in bird_map
|
||||
]
|
||||
|
||||
return {
|
||||
"top_birds": top_birds,
|
||||
"total_outings": outings,
|
||||
"total_species": len(bird_counts),
|
||||
"total_individuals": sum(bird_counts.values()),
|
||||
}
|
||||
|
||||
|
||||
MEDIA_TYPE_LABELS = {
|
||||
"artist": ("🎤", "Top Artists"),
|
||||
"album": ("💿", "Top Albums"),
|
||||
"track": ("🎵", "Top Tracks"),
|
||||
"tv_series": ("📺", "Top TV Series"),
|
||||
"video": ("🎬", "Top Videos"),
|
||||
"podcast": ("🎙️", "Top Podcasts"),
|
||||
"podcast_episode": ("🎙️", "Top Podcast Episodes"),
|
||||
"board_game": ("🎲", "Top Board Games"),
|
||||
"book": ("📚", "Top Books"),
|
||||
"food": ("🍽️", "Top Foods"),
|
||||
"trail": ("🥾", "Top Trails"),
|
||||
"geo_location": ("📍", "Top Locations"),
|
||||
}
|
||||
|
||||
|
||||
class ChartDetailView(TemplateView):
|
||||
template_name = "charts/chart_detail.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
user = self.request.user
|
||||
media_type = kwargs.get("media_type")
|
||||
|
||||
if media_type not in MEDIA_TYPE_FILTERS:
|
||||
raise Http404
|
||||
|
||||
date_param = self.request.GET.get("date")
|
||||
now = timezone.now()
|
||||
if user.is_authenticated:
|
||||
now = now_user_timezone(user.profile)
|
||||
today = now.date()
|
||||
|
||||
year = today.year
|
||||
month = None
|
||||
week = None
|
||||
day = None
|
||||
|
||||
if date_param:
|
||||
parts = date_param.split("-")
|
||||
year = int(parts[0])
|
||||
if len(parts) >= 2 and parts[1].startswith("W"):
|
||||
week = int(parts[1].lstrip("W"))
|
||||
month = None
|
||||
elif len(parts) >= 2:
|
||||
try:
|
||||
month = int(parts[1])
|
||||
except ValueError:
|
||||
pass
|
||||
if len(parts) >= 3:
|
||||
if parts[2].startswith("W"):
|
||||
week = int(parts[2].lstrip("W"))
|
||||
else:
|
||||
day = int(parts[2])
|
||||
|
||||
params = {"user": user}
|
||||
if year:
|
||||
params["year"] = year
|
||||
if month:
|
||||
params["month"] = month
|
||||
if week:
|
||||
params["week"] = week
|
||||
if day:
|
||||
params["day"] = day
|
||||
|
||||
media_filter = MEDIA_TYPE_FILTERS.get(media_type, Q())
|
||||
qs = ChartRecord.objects.filter(media_filter, **params)
|
||||
|
||||
if day is not None:
|
||||
qs = qs.filter(day__isnull=False)
|
||||
elif week is not None:
|
||||
qs = qs.filter(week__isnull=False, day__isnull=True)
|
||||
elif month is not None:
|
||||
qs = qs.filter(month__isnull=False, week__isnull=True, day__isnull=True)
|
||||
else:
|
||||
qs = qs.filter(month__isnull=True, week__isnull=True, day__isnull=True)
|
||||
|
||||
context["page_charts"] = qs.order_by("rank")
|
||||
context["media_type"] = media_type
|
||||
emoji, label = MEDIA_TYPE_LABELS.get(media_type, ("", media_type))
|
||||
context["media_label"] = label
|
||||
context["media_emoji"] = emoji
|
||||
context["year"] = year
|
||||
context["month"] = month
|
||||
context["week"] = week
|
||||
context["day"] = day
|
||||
|
||||
context["chart_years"] = list(
|
||||
ChartRecord.objects.filter(Q(user=user) & media_filter)
|
||||
.values_list("year", flat=True)
|
||||
.distinct()
|
||||
.order_by("-year")
|
||||
)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class SpotifyTracksView(TemplateView):
|
||||
template_name = "charts/spotify_tracks.html"
|
||||
|
||||
def get_spotify_tracks(self, user, limit=50):
|
||||
non_spotify_mopidy_tracks = (
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
source="Mopidy",
|
||||
track__isnull=False,
|
||||
)
|
||||
.exclude(log__mopidy_source="spotify")
|
||||
.values("track")
|
||||
)
|
||||
track_ids = (
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
track__isnull=False,
|
||||
)
|
||||
.filter(Q(source="Last.fm") | Q(log__mopidy_source="spotify"))
|
||||
.exclude(track__in=non_spotify_mopidy_tracks)
|
||||
.values("track")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")[:limit]
|
||||
@ -594,3 +770,144 @@ class SpotifyTracksView(TemplateView):
|
||||
user = self.request.user
|
||||
context["spotify_tracks"] = self.get_spotify_tracks(user)
|
||||
return context
|
||||
|
||||
|
||||
class BandcampTracksView(TemplateView):
|
||||
template_name = "charts/bandcamp_tracks.html"
|
||||
|
||||
def get_bandcamp_tracks(self, user, limit=50):
|
||||
non_bandcamp_mopidy_tracks = (
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
source="Mopidy",
|
||||
track__isnull=False,
|
||||
)
|
||||
.exclude(log__mopidy_source="bandcamp")
|
||||
.values("track")
|
||||
)
|
||||
track_ids = (
|
||||
Scrobble.objects.filter(
|
||||
user=user,
|
||||
track__isnull=False,
|
||||
log__mopidy_source="bandcamp",
|
||||
)
|
||||
.exclude(track__in=non_bandcamp_mopidy_tracks)
|
||||
.values("track")
|
||||
.annotate(count=Count("id"))
|
||||
.order_by("-count")[:limit]
|
||||
)
|
||||
from music.models import Track
|
||||
|
||||
track_id_list = [item["track"] for item in track_ids]
|
||||
tracks = Track.objects.filter(id__in=track_id_list)
|
||||
track_map = {t.id: t for t in tracks}
|
||||
return [
|
||||
{
|
||||
"track": track_map[tid],
|
||||
"count": next(
|
||||
(item["count"] for item in track_ids if item["track"] == tid), 0
|
||||
),
|
||||
}
|
||||
for tid in track_id_list
|
||||
if tid in track_map
|
||||
]
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
user = self.request.user
|
||||
context["bandcamp_tracks"] = self.get_bandcamp_tracks(user)
|
||||
return context
|
||||
|
||||
|
||||
class BirdsChartView(TemplateView):
|
||||
template_name = "charts/birds_chart.html"
|
||||
|
||||
def get_bird_data(self, user, year=None, month=None, week=None, day=None, limit=50):
|
||||
from birds.models import Bird
|
||||
|
||||
filters = {
|
||||
"user": user,
|
||||
"media_type": Scrobble.MediaType.BIRDING_LOCATION,
|
||||
}
|
||||
if year:
|
||||
filters["timestamp__year"] = year
|
||||
if month:
|
||||
filters["timestamp__month"] = month
|
||||
if week:
|
||||
filters["timestamp__week"] = week
|
||||
if day:
|
||||
filters["timestamp__day"] = day
|
||||
|
||||
scrobbles = Scrobble.objects.filter(**filters)
|
||||
|
||||
bird_counts = {}
|
||||
outings = 0
|
||||
for scrobble in scrobbles.iterator():
|
||||
outings += 1
|
||||
birds_data = scrobble.log.get("birds", []) if scrobble.log else []
|
||||
for entry in birds_data:
|
||||
bird_id = entry.get("bird_id")
|
||||
quantity = entry.get("quantity", 1)
|
||||
if bird_id:
|
||||
bird_counts[bird_id] = bird_counts.get(bird_id, 0) + quantity
|
||||
|
||||
sorted_birds = sorted(bird_counts.items(), key=lambda x: x[1], reverse=True)[
|
||||
:limit
|
||||
]
|
||||
|
||||
bird_ids = [bid for bid, _ in sorted_birds]
|
||||
birds = Bird.objects.filter(id__in=bird_ids)
|
||||
bird_map = {b.id: b for b in birds}
|
||||
|
||||
top_birds = [
|
||||
{"bird": bird_map[bid], "count": count}
|
||||
for bid, count in sorted_birds
|
||||
if bid in bird_map
|
||||
]
|
||||
|
||||
return {
|
||||
"top_birds": top_birds,
|
||||
"total_outings": outings,
|
||||
"total_species": len(bird_counts),
|
||||
"total_individuals": sum(bird_counts.values()),
|
||||
}
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
user = self.request.user
|
||||
|
||||
date_param = self.request.GET.get("date")
|
||||
now = timezone.now()
|
||||
if user.is_authenticated:
|
||||
now = now_user_timezone(user.profile)
|
||||
today = now.date()
|
||||
|
||||
year = None
|
||||
month = None
|
||||
week = None
|
||||
day = None
|
||||
|
||||
if date_param:
|
||||
parts = date_param.split("-")
|
||||
year = int(parts[0])
|
||||
if len(parts) >= 2 and parts[1].startswith("W"):
|
||||
week = int(parts[1].lstrip("W"))
|
||||
elif len(parts) >= 2:
|
||||
try:
|
||||
month = int(parts[1])
|
||||
except ValueError:
|
||||
pass
|
||||
if len(parts) >= 3:
|
||||
if parts[2].startswith("W"):
|
||||
week = int(parts[2].lstrip("W"))
|
||||
else:
|
||||
day = int(parts[2])
|
||||
|
||||
bird_data = self.get_bird_data(user, year=year, month=month, week=week, day=day)
|
||||
context["birds_chart"] = bird_data["top_birds"]
|
||||
context["bird_stats"] = {
|
||||
"total_species": bird_data["total_species"],
|
||||
"total_outings": bird_data["total_outings"],
|
||||
"total_individuals": bird_data["total_individuals"],
|
||||
}
|
||||
return context
|
||||
|
||||
@ -16,6 +16,7 @@ class GeoLocationAdmin(admin.ModelAdmin):
|
||||
"altitude",
|
||||
)
|
||||
ordering = ("-created",)
|
||||
search_fields = ("title",)
|
||||
inlines = [
|
||||
ScrobbleInline,
|
||||
]
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from typing import Dict
|
||||
from typing import Dict, Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
@ -107,6 +107,12 @@ class GeoLocation(ScrobblableMixin):
|
||||
def strings(self) -> ScrobblableConstants:
|
||||
return ScrobblableConstants(verb="Going", tags="world_map", priority="low")
|
||||
|
||||
@property
|
||||
def current_weather(self) -> Optional[dict]:
|
||||
from locations.utils import fetch_current_weather
|
||||
|
||||
return fetch_current_weather(self.lat, self.lon)
|
||||
|
||||
def loc_diff(self, old_lat_lon: tuple) -> tuple:
|
||||
return (
|
||||
abs(Decimal(old_lat_lon[0]) - Decimal(self.lat)),
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
from django.utils import timezone
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
@ -196,3 +199,76 @@ def detect_movement(
|
||||
return result
|
||||
|
||||
return result
|
||||
|
||||
|
||||
NWS_URL = "https://forecast.weather.gov/MapClick.php"
|
||||
|
||||
|
||||
def fetch_current_weather(lat: float, lon: float) -> Optional[dict]:
|
||||
"""Fetch current weather from NWS DWML feed for the given lat/lon.
|
||||
|
||||
Returns dict with 'temp' and 'description' keys, or None on failure.
|
||||
"""
|
||||
params = {
|
||||
"lat": lat,
|
||||
"lon": lon,
|
||||
"unit": 0,
|
||||
"lg": "english",
|
||||
"FcstType": "dwml",
|
||||
}
|
||||
url = f"{NWS_URL}?{urlencode(params)}"
|
||||
|
||||
try:
|
||||
resp = requests.get(url, timeout=10)
|
||||
resp.raise_for_status()
|
||||
except requests.RequestException as e:
|
||||
logger.warning("Failed to fetch NWS weather data: %s", e)
|
||||
return None
|
||||
|
||||
try:
|
||||
root = ET.fromstring(resp.content)
|
||||
except ET.ParseError as e:
|
||||
logger.warning("Failed to parse NWS XML: %s", e)
|
||||
return None
|
||||
|
||||
# Find current observations data block
|
||||
data_blocks = root.findall("data")
|
||||
obs_block = None
|
||||
for block in data_blocks:
|
||||
if block.get("type") == "current observations":
|
||||
obs_block = block
|
||||
break
|
||||
|
||||
if obs_block is None:
|
||||
logger.warning("No current observations block in NWS response")
|
||||
return None
|
||||
|
||||
params_el = obs_block.find("parameters")
|
||||
if params_el is None:
|
||||
return None
|
||||
|
||||
# Temperature (apparent / feels-like)
|
||||
temp = None
|
||||
for temp_el in params_el.findall("temperature"):
|
||||
if temp_el.get("type") == "apparent":
|
||||
val_el = temp_el.find("value")
|
||||
if val_el is not None and val_el.text:
|
||||
try:
|
||||
temp = int(round(float(val_el.text)))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
break
|
||||
|
||||
# Weather description
|
||||
description = None
|
||||
weather_el = params_el.find("weather")
|
||||
if weather_el is not None:
|
||||
cond_el = weather_el.find("weather-conditions")
|
||||
if cond_el is not None:
|
||||
description = cond_el.get("weather-summary")
|
||||
|
||||
if temp is None and description is None:
|
||||
logger.warning("No weather data found in NWS response")
|
||||
return None
|
||||
|
||||
return {"temp": temp, "description": description}
|
||||
|
||||
@ -8,7 +8,7 @@ from scrobbles.views import ChartContextMixin
|
||||
|
||||
class GeoLocationListView(generic.ListView):
|
||||
model = GeoLocation
|
||||
paginate_by = 75
|
||||
paginate_by = 100
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
|
||||
@ -37,6 +37,8 @@ class MoodLogData(BaseLogData):
|
||||
mood_type: Optional[str] = None
|
||||
mood_reason_ids: Optional[list[int]] = None
|
||||
mood_quality: Optional[int] = None
|
||||
weather_temp: Optional[float] = None
|
||||
weather_description: Optional[str] = None
|
||||
|
||||
@classmethod
|
||||
def override_fields(cls) -> dict:
|
||||
|
||||
@ -66,6 +66,22 @@ def checkin(request):
|
||||
mood_reason_ids=[int(reason_id)] if reason_id else [],
|
||||
)
|
||||
|
||||
# Attempt to add current weather from most recent location
|
||||
last_loc = (
|
||||
Scrobble.objects.filter(
|
||||
user=request.user,
|
||||
media_type=Scrobble.MediaType.GEO_LOCATION,
|
||||
geo_location__isnull=False,
|
||||
)
|
||||
.order_by("-timestamp")
|
||||
.first()
|
||||
)
|
||||
if last_loc and last_loc.geo_location:
|
||||
weather = last_loc.geo_location.current_weather
|
||||
if weather:
|
||||
log_data.weather_temp = weather["temp"]
|
||||
log_data.weather_description = weather["description"]
|
||||
|
||||
scrobble = Scrobble.objects.create(
|
||||
user=request.user,
|
||||
mood=mood,
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from music.models import Artist, Album, Track
|
||||
|
||||
from music.models import Album, Artist, Track
|
||||
from scrobbles.admin import ScrobbleInline
|
||||
|
||||
|
||||
@ -11,7 +9,7 @@ class AlbumAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"name",
|
||||
"year",
|
||||
"album_artist",
|
||||
"artist",
|
||||
"theaudiodb_genre",
|
||||
"theaudiodb_mood",
|
||||
"musicbrainz_id",
|
||||
@ -54,12 +52,12 @@ class TrackAdmin(admin.ModelAdmin):
|
||||
"artist",
|
||||
"musicbrainz_id",
|
||||
)
|
||||
raw_id_fields = ("artist", "albums", "album")
|
||||
list_filter = ("album", "artist")
|
||||
raw_id_fields = ("artist_fk", "artists", "albums", "album")
|
||||
search_fields = ("title",)
|
||||
ordering = ("-created",)
|
||||
filter_horizontal = [
|
||||
"albums",
|
||||
"artists",
|
||||
]
|
||||
inlines = [
|
||||
ScrobbleInline,
|
||||
|
||||
@ -149,7 +149,7 @@ def live_charts(
|
||||
|
||||
|
||||
def artist_scrobble_count(artist_id: int, filter: str = "today") -> int:
|
||||
return Scrobble.objects.filter(track__artist=artist_id).count()
|
||||
return Scrobble.objects.filter(track__artists=artist_id).count()
|
||||
|
||||
|
||||
def live_tv_charts(
|
||||
@ -158,7 +158,7 @@ def live_tv_charts(
|
||||
limit: int = 15,
|
||||
) -> QuerySet:
|
||||
from django.db.models import OuterRef, Subquery
|
||||
from videos.models import Video, Series
|
||||
from videos.models import Series, Video
|
||||
|
||||
now = timezone.now()
|
||||
tzinfo = now.tzinfo
|
||||
@ -215,7 +215,7 @@ def live_youtube_channel_charts(
|
||||
limit: int = 15,
|
||||
) -> QuerySet:
|
||||
from django.db.models import OuterRef, Subquery
|
||||
from videos.models import Video, Channel
|
||||
from videos.models import Channel, Video
|
||||
|
||||
now = timezone.now()
|
||||
tzinfo = now.tzinfo
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
from taggit.serializers import TaggitSerializer, TagListSerializerField
|
||||
|
||||
from music.models import Album, Artist, Track
|
||||
from rest_framework import serializers
|
||||
|
||||
@ -14,7 +16,25 @@ class AlbumSerializer(serializers.HyperlinkedModelSerializer):
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class TrackSerializer(serializers.HyperlinkedModelSerializer):
|
||||
class TrackSerializer(TaggitSerializer, serializers.HyperlinkedModelSerializer):
|
||||
tags = TagListSerializerField()
|
||||
genre = TagListSerializerField()
|
||||
|
||||
class Meta:
|
||||
model = Track
|
||||
fields = "__all__"
|
||||
fields = [
|
||||
"url",
|
||||
"id",
|
||||
"uuid",
|
||||
"title",
|
||||
"artist_fk",
|
||||
"artists",
|
||||
"album",
|
||||
"albums",
|
||||
"musicbrainz_id",
|
||||
"genre",
|
||||
"tags",
|
||||
"base_run_time_seconds",
|
||||
"created",
|
||||
"modified",
|
||||
]
|
||||
|
||||
90
vrobbler/apps/music/listenbrainz.py
Normal file
90
vrobbler/apps/music/listenbrainz.py
Normal file
@ -0,0 +1,90 @@
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LB_LABS_API = "https://labs.api.listenbrainz.org"
|
||||
STANDARD_ALGORITHM = "session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30"
|
||||
MLHD_ALGORITHM = "session_based_mlhd_session_300_contribution_5_threshold_15_limit_50_skip_30"
|
||||
SIMILAR_ARTISTS_ALGORITHM = "session_based_days_1825_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30"
|
||||
|
||||
|
||||
def get_similar_recordings(recording_mbid: str) -> list[dict]:
|
||||
"""Fetch similar recordings from ListenBrainz.
|
||||
|
||||
Tries the standard dataset first, falls back to MLHD dataset.
|
||||
Returns [] on error or no data.
|
||||
"""
|
||||
if not recording_mbid:
|
||||
return []
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{LB_LABS_API}/similar-recordings/json",
|
||||
json=[
|
||||
{
|
||||
"recording_mbids": [recording_mbid],
|
||||
"algorithm": STANDARD_ALGORITHM,
|
||||
}
|
||||
],
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if data:
|
||||
return data
|
||||
|
||||
resp = requests.post(
|
||||
f"{LB_LABS_API}/mlhd-similar-recordings/json",
|
||||
json=[
|
||||
{
|
||||
"recording_mbids": [recording_mbid],
|
||||
"algorithm": MLHD_ALGORITHM,
|
||||
}
|
||||
],
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except requests.RequestException as e:
|
||||
logger.warning(
|
||||
"ListenBrainz similar recordings error",
|
||||
extra={
|
||||
"recording_mbid": recording_mbid,
|
||||
"error": str(e),
|
||||
},
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def get_similar_artists(artist_mbid: str) -> list[dict]:
|
||||
"""Fetch similar artists from ListenBrainz.
|
||||
|
||||
Returns [] on error or no data.
|
||||
"""
|
||||
if not artist_mbid:
|
||||
return []
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{LB_LABS_API}/similar-artists/json",
|
||||
json=[
|
||||
{
|
||||
"artist_mbids": [artist_mbid],
|
||||
"algorithm": SIMILAR_ARTISTS_ALGORITHM,
|
||||
}
|
||||
],
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except requests.RequestException as e:
|
||||
logger.warning(
|
||||
"ListenBrainz similar artists error",
|
||||
extra={
|
||||
"artist_mbid": artist_mbid,
|
||||
"error": str(e),
|
||||
},
|
||||
)
|
||||
return []
|
||||
0
vrobbler/apps/music/management/__init__.py
Normal file
0
vrobbler/apps/music/management/__init__.py
Normal file
0
vrobbler/apps/music/management/commands/__init__.py
Normal file
0
vrobbler/apps/music/management/commands/__init__.py
Normal file
@ -0,0 +1,249 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
import musicbrainzngs
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
|
||||
from music.musicbrainz import resolve_track, get_track_metadata_with_artist, extract_featured_artists
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
VARIOUS_ARTISTS_NAMES = ["various artists", "va", "various"]
|
||||
|
||||
|
||||
def _is_various_artists(artist_name: str) -> bool:
|
||||
return artist_name.strip().casefold() in VARIOUS_ARTISTS_NAMES
|
||||
|
||||
|
||||
def _get_artist_from_scrobble_logs(track_id: int):
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
scrobbles = Scrobble.objects.filter(track_id=track_id)[:10]
|
||||
for scrobble in scrobbles:
|
||||
raw = scrobble.log.get("raw_data", {})
|
||||
if not raw:
|
||||
continue
|
||||
|
||||
provider_mbid = raw.get("Provider_musicbrainztrack")
|
||||
if provider_mbid:
|
||||
return {"artist_name": raw.get("Artist", ""), "recording_mbid": provider_mbid}
|
||||
|
||||
artist = raw.get("Artist", "")
|
||||
if artist:
|
||||
return {"artist_name": artist, "recording_mbid": None}
|
||||
return None
|
||||
|
||||
|
||||
def _lookup_recording_by_mbid(mbid: str):
|
||||
try:
|
||||
result = musicbrainzngs.get_recording_by_id(mbid, includes=["artists"])
|
||||
recording = result["recording"]
|
||||
length_ms = recording.get("length")
|
||||
artist_credit = recording.get("artist-credit", [{}])
|
||||
primary_artist = artist_credit[0].get("artist", {}) if artist_credit else {}
|
||||
return {
|
||||
"recording_mbid": mbid,
|
||||
"length_ms": int(length_ms) if length_ms else None,
|
||||
"artist_name": primary_artist.get("name", ""),
|
||||
"artist_mbid": primary_artist.get("id", ""),
|
||||
}
|
||||
except (musicbrainzngs.WebServiceError, musicbrainzngs.ResponseError) as e:
|
||||
logger.warning(f"MusicBrainz error looking up {mbid}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _lookup_length_by_mbid(mbid: str):
|
||||
try:
|
||||
result = musicbrainzngs.get_recording_by_id(mbid)
|
||||
recording = result["recording"]
|
||||
length_ms = recording.get("length")
|
||||
if length_ms:
|
||||
return int(length_ms)
|
||||
return None
|
||||
except (musicbrainzngs.WebServiceError, musicbrainzngs.ResponseError) as e:
|
||||
logger.warning(f"MusicBrainz error looking up {mbid}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Clean up metadata on music tracks from MusicBrainz"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Commit changes to the database",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=100,
|
||||
help="Number of tracks to process per batch (default: 100)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--only-missing-mbid",
|
||||
action="store_true",
|
||||
help="Only process tracks missing musicbrainz_id",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--only-missing-length",
|
||||
action="store_true",
|
||||
help="Only process tracks missing base_run_time_seconds",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from music.models import Track
|
||||
|
||||
commit = options["commit"]
|
||||
batch_size = options["batch_size"]
|
||||
|
||||
if not commit:
|
||||
self.stdout.write("Dry run — no changes will be saved. Use --commit to apply.")
|
||||
|
||||
musicbrainzngs.set_useragent("vrobbler", "0.3.0")
|
||||
|
||||
qs = Track.objects.all()
|
||||
|
||||
if options["only_missing_mbid"]:
|
||||
qs = qs.filter(musicbrainz_id__isnull=True)
|
||||
if options["only_missing_length"]:
|
||||
qs = qs.filter(base_run_time_seconds__isnull=True)
|
||||
|
||||
total = qs.count()
|
||||
self.stdout.write(f"Processing {total} tracks")
|
||||
|
||||
missing_mbid_qs = qs.filter(musicbrainz_id__isnull=True)
|
||||
missing_length_qs = qs.filter(
|
||||
musicbrainz_id__isnull=False, base_run_time_seconds__isnull=True
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
f" {missing_mbid_qs.count()} tracks without musicbrainz_id"
|
||||
)
|
||||
self.stdout.write(
|
||||
f" {missing_length_qs.count()} tracks with mbid but no run time"
|
||||
)
|
||||
|
||||
if not commit:
|
||||
self.stdout.write("\nSkipping API lookups in dry-run mode. Use --commit to run against MusicBrainz.")
|
||||
return
|
||||
|
||||
found_count = 0
|
||||
not_found_count = 0
|
||||
length_fixed_count = 0
|
||||
va_fixed_count = 0
|
||||
track_ids = list(missing_mbid_qs.values_list("pk", flat=True))
|
||||
i = 0
|
||||
|
||||
for batch_num, offset in enumerate(range(0, len(track_ids), batch_size)):
|
||||
batch_pks = track_ids[offset : offset + batch_size]
|
||||
with transaction.atomic():
|
||||
for track in Track.objects.filter(pk__in=batch_pks).iterator():
|
||||
i += 1
|
||||
artist_name = ""
|
||||
artist_obj = track.artist
|
||||
if artist_obj:
|
||||
artist_name = artist_obj.name
|
||||
|
||||
is_va = _is_various_artists(artist_name)
|
||||
result = None
|
||||
method = ""
|
||||
|
||||
if is_va:
|
||||
scrobble_info = _get_artist_from_scrobble_logs(track.id)
|
||||
if scrobble_info:
|
||||
if scrobble_info["recording_mbid"]:
|
||||
result = _lookup_recording_by_mbid(
|
||||
scrobble_info["recording_mbid"]
|
||||
)
|
||||
if result:
|
||||
method = "provider"
|
||||
self.stdout.write(
|
||||
f" VA track '{track.title}' matched via provider MBID "
|
||||
f"{scrobble_info['recording_mbid']} (artist: {scrobble_info['artist_name']})"
|
||||
)
|
||||
else:
|
||||
result, method = resolve_track(
|
||||
track.title, scrobble_info["artist_name"]
|
||||
)
|
||||
if result:
|
||||
self.stdout.write(
|
||||
f" VA track '{track.title}' matched via log artist "
|
||||
f"'{scrobble_info['artist_name']}' as {result['recording_mbid']} ({method})"
|
||||
)
|
||||
|
||||
if not result:
|
||||
result, method = resolve_track(track.title)
|
||||
if result:
|
||||
self.stdout.write(
|
||||
f" VA track '{track.title}' matched by title alone "
|
||||
f"as {result['recording_mbid']} ({method})"
|
||||
)
|
||||
|
||||
if not result:
|
||||
result, method = resolve_track(track.title, artist_name)
|
||||
|
||||
if result and result.get("recording_mbid"):
|
||||
track.musicbrainz_id = result["recording_mbid"]
|
||||
length_ms = result.get("length_ms")
|
||||
if length_ms and not track.base_run_time_seconds:
|
||||
track.base_run_time_seconds = int(int(length_ms) / 1000)
|
||||
track.save(update_fields=["musicbrainz_id", "base_run_time_seconds"])
|
||||
method_tag = f"musicbrainz-{method}" if method else "musicbrainz-enriched"
|
||||
track.tags.add(method_tag, "musicbrainz-enriched")
|
||||
from music.models import Artist
|
||||
cleaned_title, featured_names = extract_featured_artists(track.title)
|
||||
for feat_name in featured_names:
|
||||
artist = Artist.find_or_create(feat_name, track_name=track.title)
|
||||
if artist:
|
||||
track.artists.add(artist)
|
||||
self.stdout.write(
|
||||
f" [{i}] FOUND {track} — mbid={track.musicbrainz_id} ({method})"
|
||||
)
|
||||
found_count += 1
|
||||
else:
|
||||
track.tags.add("musicbrainz-notfound")
|
||||
self.stdout.write(
|
||||
f" [{i}] NOTFOUND {track}"
|
||||
)
|
||||
not_found_count += 1
|
||||
|
||||
if is_va and result:
|
||||
va_fixed_count += 1
|
||||
|
||||
self.stdout.write(
|
||||
f" Batch {batch_num + 1}: {offset + len(batch_pks)}/{total} processed, "
|
||||
f"found: {found_count}, not found: {not_found_count}"
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
length_ids = list(missing_length_qs.values_list("pk", flat=True))
|
||||
j = 0
|
||||
for batch_num, offset in enumerate(range(0, len(length_ids), batch_size)):
|
||||
batch_pks = length_ids[offset : offset + batch_size]
|
||||
with transaction.atomic():
|
||||
for track in Track.objects.filter(pk__in=batch_pks).iterator():
|
||||
j += 1
|
||||
length_ms = _lookup_length_by_mbid(track.musicbrainz_id)
|
||||
if length_ms:
|
||||
track.base_run_time_seconds = int(int(length_ms) / 1000)
|
||||
track.tags.add("musicbrainz-enriched")
|
||||
track.save(update_fields=["base_run_time_seconds"])
|
||||
self.stdout.write(
|
||||
f" [{j}] LENGTH {track} — {track.base_run_time_seconds}s"
|
||||
)
|
||||
length_fixed_count += 1
|
||||
|
||||
self.stdout.write(
|
||||
f" Batch {batch_num + 1}: {j}/{len(length_ids)} length lookups"
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
self.stdout.write(
|
||||
f"\nResults (commit={commit}):\n"
|
||||
f" Found on MusicBrainz: {found_count}\n"
|
||||
f" Not found / tagged musicbrainz-notfound: {not_found_count}\n"
|
||||
f" Run times filled from MBID: {length_fixed_count}\n"
|
||||
f" Various Artists tracks resolved: {va_fixed_count}"
|
||||
)
|
||||
@ -0,0 +1,123 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from music.listenbrainz import get_similar_artists
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Fetch similar artists from ListenBrainz for artists with a musicbrainz_id"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Commit changes to the database",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=100,
|
||||
help="Number of artists to process per batch (default: 100)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--artist-id",
|
||||
type=str,
|
||||
default="",
|
||||
help="Only process the artist with this musicbrainz_id",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--overwrite",
|
||||
action="store_true",
|
||||
help="Re-fetch similar artists even if already populated",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from music.models import Artist
|
||||
|
||||
commit = options["commit"]
|
||||
batch_size = options["batch_size"]
|
||||
artist_id = options["artist_id"]
|
||||
overwrite = options["overwrite"]
|
||||
|
||||
if not commit:
|
||||
self.stdout.write(
|
||||
"Dry run — no changes will be saved. Use --commit to apply."
|
||||
)
|
||||
|
||||
qs = Artist.objects.exclude(musicbrainz_id__isnull=True).exclude(
|
||||
musicbrainz_id=""
|
||||
)
|
||||
|
||||
if artist_id:
|
||||
qs = qs.filter(musicbrainz_id=artist_id)
|
||||
self.stdout.write(f"Filtering to artist with musicbrainz_id: {artist_id}")
|
||||
elif not overwrite:
|
||||
qs = qs.filter(similar_artists__isnull=True)
|
||||
else:
|
||||
qs = qs.all()
|
||||
|
||||
total = qs.count()
|
||||
if total == 0:
|
||||
self.stdout.write("No artists to process.")
|
||||
return
|
||||
|
||||
self.stdout.write(
|
||||
f"Found {total} artists with musicbrainz_id"
|
||||
+ (" (overwrite mode)" if overwrite else "")
|
||||
+ (" (--artist-id filter)" if artist_id else "")
|
||||
)
|
||||
|
||||
if not commit:
|
||||
self.stdout.write(
|
||||
"\nSkipping API lookups in dry-run mode. Use --commit to run against ListenBrainz."
|
||||
)
|
||||
return
|
||||
|
||||
found_count = 0
|
||||
empty_count = 0
|
||||
error_count = 0
|
||||
artist_ids = list(qs.values_list("pk", flat=True))
|
||||
i = 0
|
||||
|
||||
for batch_num, offset in enumerate(
|
||||
range(0, len(artist_ids), batch_size)
|
||||
):
|
||||
batch_pks = artist_ids[offset : offset + batch_size]
|
||||
for artist in Artist.objects.filter(pk__in=batch_pks).iterator():
|
||||
i += 1
|
||||
self.stdout.write(
|
||||
f" [{i}/{total}] Fetching similar artists for {artist}...",
|
||||
ending="",
|
||||
)
|
||||
try:
|
||||
similar = get_similar_artists(artist.musicbrainz_id)
|
||||
if similar:
|
||||
artist.similar_artists = similar
|
||||
artist.save(update_fields=["similar_artists"])
|
||||
self.stdout.write(f" {len(similar)} similar artists found")
|
||||
found_count += 1
|
||||
else:
|
||||
artist.similar_artists = []
|
||||
artist.save(update_fields=["similar_artists"])
|
||||
self.stdout.write(" none found")
|
||||
empty_count += 1
|
||||
except Exception as e:
|
||||
self.stdout.write(f" error: {e}")
|
||||
error_count += 1
|
||||
|
||||
self.stdout.write(
|
||||
f" Batch {batch_num + 1}: {offset + len(batch_pks)}/{total} processed, "
|
||||
f"found: {found_count}, empty: {empty_count}, errors: {error_count}"
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
self.stdout.write(
|
||||
f"\nResults (commit={commit}):\n"
|
||||
f" Similar artists found: {found_count}\n"
|
||||
f" No similar artists: {empty_count}\n"
|
||||
f" Errors: {error_count}"
|
||||
)
|
||||
@ -0,0 +1,125 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from music.listenbrainz import get_similar_recordings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Fetch similar recordings from ListenBrainz for tracks with a musicbrainz_id"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Commit changes to the database",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=100,
|
||||
help="Number of tracks to process per batch (default: 100)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--track-id",
|
||||
type=str,
|
||||
default="",
|
||||
help="Only process the track with this musicbrainz_id",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--overwrite",
|
||||
action="store_true",
|
||||
help="Re-fetch similar recordings even if already populated",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from music.models import Track
|
||||
|
||||
commit = options["commit"]
|
||||
batch_size = options["batch_size"]
|
||||
track_id = options["track_id"]
|
||||
overwrite = options["overwrite"]
|
||||
|
||||
if not commit:
|
||||
self.stdout.write(
|
||||
"Dry run — no changes will be saved. Use --commit to apply."
|
||||
)
|
||||
|
||||
qs = Track.objects.exclude(musicbrainz_id__isnull=True).exclude(
|
||||
musicbrainz_id=""
|
||||
)
|
||||
|
||||
if track_id:
|
||||
qs = qs.filter(musicbrainz_id=track_id)
|
||||
self.stdout.write(f"Filtering to track with musicbrainz_id: {track_id}")
|
||||
elif not overwrite:
|
||||
qs = qs.filter(similar_recordings__isnull=True)
|
||||
else:
|
||||
qs = qs.all()
|
||||
|
||||
total = qs.count()
|
||||
if total == 0:
|
||||
self.stdout.write("No tracks to process.")
|
||||
return
|
||||
|
||||
self.stdout.write(
|
||||
f"Found {total} tracks with musicbrainz_id"
|
||||
+ (" (overwrite mode)" if overwrite else "")
|
||||
+ (" (--track-id filter)" if track_id else "")
|
||||
)
|
||||
|
||||
if not commit:
|
||||
self.stdout.write(
|
||||
"\nSkipping API lookups in dry-run mode. Use --commit to run against ListenBrainz."
|
||||
)
|
||||
return
|
||||
|
||||
found_count = 0
|
||||
empty_count = 0
|
||||
error_count = 0
|
||||
track_ids = list(qs.values_list("pk", flat=True))
|
||||
i = 0
|
||||
|
||||
for batch_num, offset in enumerate(
|
||||
range(0, len(track_ids), batch_size)
|
||||
):
|
||||
batch_pks = track_ids[offset : offset + batch_size]
|
||||
for track in Track.objects.filter(pk__in=batch_pks).iterator():
|
||||
i += 1
|
||||
self.stdout.write(
|
||||
f" [{i}/{total}] Fetching similar recordings for {track}...",
|
||||
ending="",
|
||||
)
|
||||
try:
|
||||
similar = get_similar_recordings(track.musicbrainz_id)
|
||||
if similar:
|
||||
track.similar_recordings = similar
|
||||
track.save(update_fields=["similar_recordings"])
|
||||
self.stdout.write(
|
||||
f" {len(similar)} similar recordings found"
|
||||
)
|
||||
found_count += 1
|
||||
else:
|
||||
track.similar_recordings = []
|
||||
track.save(update_fields=["similar_recordings"])
|
||||
self.stdout.write(" none found")
|
||||
empty_count += 1
|
||||
except Exception as e:
|
||||
self.stdout.write(f" error: {e}")
|
||||
error_count += 1
|
||||
|
||||
self.stdout.write(
|
||||
f" Batch {batch_num + 1}: {offset + len(batch_pks)}/{total} processed, "
|
||||
f"found: {found_count}, empty: {empty_count}, errors: {error_count}"
|
||||
)
|
||||
time.sleep(1)
|
||||
|
||||
self.stdout.write(
|
||||
f"\nResults (commit={commit}):\n"
|
||||
f" Similar recordings found: {found_count}\n"
|
||||
f" No similar recordings: {empty_count}\n"
|
||||
f" Errors: {error_count}"
|
||||
)
|
||||
@ -0,0 +1,127 @@
|
||||
import csv
|
||||
import logging
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_source(raw_data):
|
||||
if "Artist" in raw_data:
|
||||
return "Jellyfin"
|
||||
if "artist" in raw_data:
|
||||
return "Mopidy"
|
||||
return None
|
||||
|
||||
|
||||
def _get_raw_values(raw_data, source):
|
||||
if source == "Jellyfin":
|
||||
return raw_data.get("Artist", ""), raw_data.get("Album", "")
|
||||
return raw_data.get("artist", ""), raw_data.get("album", "")
|
||||
|
||||
|
||||
def _normalize(name):
|
||||
return name.strip().casefold()
|
||||
|
||||
|
||||
def _artist_mismatch(raw_artist, track_artist_names):
|
||||
if not raw_artist or not track_artist_names:
|
||||
return False
|
||||
track_names = [_normalize(n) for n in track_artist_names.split(" / ")]
|
||||
raw = _normalize(raw_artist)
|
||||
if raw in track_names:
|
||||
return False
|
||||
if raw == _normalize(track_artist_names):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _album_mismatch(raw_album, track_album_name):
|
||||
if not raw_album or not track_album_name:
|
||||
return False
|
||||
return _normalize(raw_album) != _normalize(track_album_name)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
"Outputs a CSV of track IDs where raw metadata from scrobble logs "
|
||||
"does not match the track's stored artists or album"
|
||||
)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--file-path",
|
||||
type=str,
|
||||
default="/tmp/metadata-report.csv",
|
||||
help="Output CSV file path (default: /tmp/metadata-report.csv)",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
file_path = options["file_path"]
|
||||
|
||||
qs = (
|
||||
Scrobble.objects.filter(media_type=Scrobble.MediaType.TRACK)
|
||||
.exclude(log__isnull=True)
|
||||
.exclude(log={})
|
||||
.select_related("track__album")
|
||||
.prefetch_related("track__artists")
|
||||
.iterator()
|
||||
)
|
||||
|
||||
rows = []
|
||||
for scrobble in qs:
|
||||
track = scrobble.track
|
||||
if not track:
|
||||
continue
|
||||
|
||||
raw_data = scrobble.log.get("raw_data")
|
||||
if not raw_data:
|
||||
continue
|
||||
|
||||
source = _get_source(raw_data)
|
||||
if not source:
|
||||
continue
|
||||
|
||||
raw_artist, raw_album = _get_raw_values(raw_data, source)
|
||||
if not raw_artist and not raw_album:
|
||||
continue
|
||||
|
||||
track_artist_names = " / ".join(
|
||||
track.artists.all().values_list("name", flat=True)
|
||||
)
|
||||
track_album_name = track.album.name if track.album else ""
|
||||
|
||||
if _artist_mismatch(raw_artist, track_artist_names) or _album_mismatch(
|
||||
raw_album, track_album_name
|
||||
):
|
||||
rows.append(
|
||||
{
|
||||
"track_id": track.id,
|
||||
"track_artist_name": track_artist_names,
|
||||
"track_album_name": track_album_name,
|
||||
"raw_artist": raw_artist,
|
||||
"raw_album": raw_album,
|
||||
"source": source,
|
||||
}
|
||||
)
|
||||
|
||||
fieldnames = [
|
||||
"track_id",
|
||||
"track_artist_name",
|
||||
"track_album_name",
|
||||
"raw_artist",
|
||||
"raw_album",
|
||||
"source",
|
||||
]
|
||||
with open(file_path, "w", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Wrote {len(rows)} mismatched track(s) to {file_path}"
|
||||
)
|
||||
)
|
||||
@ -0,0 +1,32 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("music", "0031_alter_track_genre"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name="track",
|
||||
old_name="artist",
|
||||
new_name="artist_fk",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="track",
|
||||
name="artist_fk",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.DO_NOTHING,
|
||||
related_name="+",
|
||||
to="music.artist",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="track",
|
||||
name="artists",
|
||||
field=models.ManyToManyField(to="music.artist"),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,27 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def backfill_artist_fk_to_artists(apps, schema_editor):
|
||||
Track = apps.get_model("music", "Track")
|
||||
TrackArtist = Track.artists.through
|
||||
TrackArtist.objects.bulk_create(
|
||||
[
|
||||
TrackArtist(track_id=r["id"], artist_id=r["artist_fk_id"])
|
||||
for r in Track.objects.filter(artist_fk__isnull=False).values("id", "artist_fk_id")
|
||||
],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("music", "0032_track_artist_fk_and_artists"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
backfill_artist_fk_to_artists,
|
||||
reverse_code=migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,29 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def backfill_album_artist_to_artists(apps, schema_editor):
|
||||
Album = apps.get_model("music", "Album")
|
||||
AlbumArtist = Album.artists.through
|
||||
AlbumArtist.objects.bulk_create(
|
||||
[
|
||||
AlbumArtist(album_id=r["id"], artist_id=r["album_artist_id"])
|
||||
for r in Album.objects.filter(album_artist__isnull=False).values(
|
||||
"id", "album_artist_id"
|
||||
)
|
||||
],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("music", "0033_backfill_track_artists"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
backfill_album_artist_to_artists,
|
||||
reverse_code=migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.30 on 2026-05-29 23:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("music", "0034_backfill_album_artists"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="track",
|
||||
name="similar_recordings",
|
||||
field=models.JSONField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.30 on 2026-05-30 00:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("music", "0035_track_similar_recordings"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="artist",
|
||||
name="similar_artists",
|
||||
field=models.JSONField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -16,9 +16,11 @@ from imagekit.processors import ResizeToFit
|
||||
from music.allmusic import get_allmusic_slug, scrape_data_from_allmusic
|
||||
from music.bandcamp import get_bandcamp_slug
|
||||
from music.musicbrainz import (
|
||||
extract_featured_artists,
|
||||
get_album_metadata_with_artist,
|
||||
get_recording_mbid_exact,
|
||||
get_track_metadata_with_artist,
|
||||
resolve_track,
|
||||
)
|
||||
from music.theaudiodb import lookup_album_from_tadb, lookup_artist_from_tadb
|
||||
from music.utils import clean_artist_name
|
||||
@ -54,6 +56,7 @@ class Artist(TimeStampedModel):
|
||||
theaudiodb_genre = models.CharField(max_length=255, **BNULL)
|
||||
theaudiodb_mood = models.CharField(max_length=255, **BNULL)
|
||||
musicbrainz_id = models.CharField(max_length=255, **BNULL)
|
||||
similar_artists = models.JSONField(**BNULL)
|
||||
allmusic_id = models.CharField(max_length=100, **BNULL)
|
||||
bandcamp_id = models.CharField(max_length=100, **BNULL)
|
||||
thumbnail = models.ImageField(upload_to="artist/", **BNULL)
|
||||
@ -124,7 +127,7 @@ class Artist(TimeStampedModel):
|
||||
def charts(self):
|
||||
from scrobbles.models import ChartRecord
|
||||
|
||||
return ChartRecord.objects.filter(track__artist=self).order_by("-year")
|
||||
return ChartRecord.objects.filter(track__artists=self).order_by("-year")
|
||||
|
||||
def scrape_allmusic(self, force=False) -> None:
|
||||
if not self.allmusic_id or force:
|
||||
@ -294,7 +297,7 @@ class Album(TimeStampedModel):
|
||||
alt_names = models.TextField(**BNULL)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return "{} by {}".format(self.name, self.album_artist)
|
||||
return "{} by {}".format(self.name, self.artist)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("music:album_detail", kwargs={"slug": self.uuid})
|
||||
@ -320,6 +323,10 @@ class Album(TimeStampedModel):
|
||||
.order_by("-scrobble_count")
|
||||
)
|
||||
|
||||
@property
|
||||
def artist(self):
|
||||
return self.artists.first() or self.album_artist
|
||||
|
||||
def fix_album_artist(self):
|
||||
from music.utils import get_or_create_various_artists
|
||||
|
||||
@ -338,10 +345,10 @@ class Album(TimeStampedModel):
|
||||
)
|
||||
return
|
||||
|
||||
if self.album_artist and (not self.allmusic_id or force):
|
||||
slug = get_allmusic_slug(self.album_artist.name, self.name)
|
||||
if self.artist and (not self.allmusic_id or force):
|
||||
slug = get_allmusic_slug(self.artist.name, self.name)
|
||||
if not slug:
|
||||
logger.info(f"No allmsuic link for {self} by {self.album_artist}")
|
||||
logger.info(f"No allmsuic link for {self} by {self.artist}")
|
||||
return
|
||||
self.allmusic_id = slug
|
||||
self.save(update_fields=["allmusic_id"])
|
||||
@ -351,7 +358,7 @@ class Album(TimeStampedModel):
|
||||
allmusic_data = scrape_data_from_allmusic(self.allmusic_link)
|
||||
|
||||
if not allmusic_data:
|
||||
logger.info(f"No allmsuic data for {self} by {self.album_artist}")
|
||||
logger.info(f"No allmsuic data for {self} by {self.artist}")
|
||||
return
|
||||
|
||||
self.allmusic_review = allmusic_data["review"]
|
||||
@ -360,8 +367,8 @@ class Album(TimeStampedModel):
|
||||
|
||||
def scrape_theaudiodb(self) -> None:
|
||||
artist = "Various Artists"
|
||||
if self.album_artist:
|
||||
artist = self.album_artist.name
|
||||
if self.artist:
|
||||
artist = self.artist.name
|
||||
album_data = lookup_album_from_tadb(self.name, artist)
|
||||
if not album_data.get("theaudiodb_id"):
|
||||
logger.info(f"No data for {self} found in TheAudioDB")
|
||||
@ -374,7 +381,7 @@ class Album(TimeStampedModel):
|
||||
|
||||
def scrape_bandcamp(self, force=False) -> None:
|
||||
if not self.bandcamp_id or force:
|
||||
slug = get_bandcamp_slug(self.album_artist.name, self.name)
|
||||
slug = get_bandcamp_slug(self.artist.name, self.name)
|
||||
if not slug:
|
||||
logger.info(f"No bandcamp link for {self}")
|
||||
return
|
||||
@ -422,7 +429,8 @@ class Album(TimeStampedModel):
|
||||
self.artists.add(new_artist)
|
||||
if not new_artist:
|
||||
for t in self.track_set.all():
|
||||
self.artists.add(t.artist)
|
||||
for a in t.artists.all():
|
||||
self.artists.add(a)
|
||||
if not self.cover_image or self.cover_image == "default-image-replace-me":
|
||||
self.fetch_artwork()
|
||||
self.fix_album_artist()
|
||||
@ -484,19 +492,19 @@ class Album(TimeStampedModel):
|
||||
|
||||
@property
|
||||
def rym_link(self):
|
||||
artist_slug = self.album_artist.name.lower().replace(" ", "-")
|
||||
artist_slug = self.artist.name.lower().replace(" ", "-")
|
||||
album_slug = self.name.lower().replace(" ", "-")
|
||||
return f"https://rateyourmusic.com/release/album/{artist_slug}/{album_slug}/"
|
||||
|
||||
@property
|
||||
def bandcamp_link(self):
|
||||
if self.bandcamp_id and self.album_artist.bandcamp_id:
|
||||
return f"https://{self.album_artist.bandcamp_id}.bandcamp.com/album/{self.bandcamp_id}"
|
||||
if self.bandcamp_id and self.artist.bandcamp_id:
|
||||
return f"https://{self.artist.bandcamp_id}.bandcamp.com/album/{self.bandcamp_id}"
|
||||
return ""
|
||||
|
||||
@property
|
||||
def bandcamp_search_link(self):
|
||||
artist = self.album_artist.name.lower()
|
||||
artist = self.artist.name.lower()
|
||||
album = self.name.lower()
|
||||
return f"https://bandcamp.com/search?q={album} {artist}&item_type=a"
|
||||
|
||||
@ -564,7 +572,7 @@ class Album(TimeStampedModel):
|
||||
album_artist=artist,
|
||||
alt_names=alt_name,
|
||||
)
|
||||
album.artists.add(*extra_artists)
|
||||
album.artists.add(artist, *extra_artists)
|
||||
album.fetch_artwork()
|
||||
|
||||
return album
|
||||
@ -573,14 +581,22 @@ class Album(TimeStampedModel):
|
||||
class Track(ScrobblableMixin):
|
||||
COMPLETION_PERCENT = getattr(settings, "MUSIC_COMPLETION_PERCENT", 100)
|
||||
|
||||
artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING)
|
||||
artist_fk = models.ForeignKey(
|
||||
Artist, on_delete=models.DO_NOTHING, **BNULL, related_name="+"
|
||||
)
|
||||
artists = models.ManyToManyField(Artist)
|
||||
albums = models.ManyToManyField(Album, related_name="tracks")
|
||||
album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL)
|
||||
musicbrainz_id = models.CharField(max_length=255, **BNULL)
|
||||
similar_recordings = models.JSONField(**BNULL)
|
||||
|
||||
class Meta:
|
||||
unique_together = [["album", "musicbrainz_id"]]
|
||||
|
||||
@property
|
||||
def artist(self):
|
||||
return self.artists.first() or self.artist_fk
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} by {self.artist}"
|
||||
|
||||
@ -615,8 +631,9 @@ class Track(ScrobblableMixin):
|
||||
@property
|
||||
def primary_image_url(self) -> str:
|
||||
url = ""
|
||||
if self.artist.thumbnail:
|
||||
url = self.artist.thumbnail_medium.url
|
||||
primary_artist = self.artist
|
||||
if primary_artist and primary_artist.thumbnail:
|
||||
url = primary_artist.thumbnail_medium.url
|
||||
if self.primary_album and self.primary_album.cover_image:
|
||||
url = self.primary_album.cover_image_medium.url
|
||||
return url
|
||||
@ -625,9 +642,11 @@ class Track(ScrobblableMixin):
|
||||
def find_or_create(
|
||||
cls,
|
||||
title: str = "",
|
||||
artist_names: list[str] | None = None,
|
||||
artist_name: str = "",
|
||||
album_name: str = "",
|
||||
run_time_seconds: int | None = None,
|
||||
mbid: str | None = None,
|
||||
enrich: bool = False,
|
||||
commit: bool = True,
|
||||
) -> "Track":
|
||||
@ -637,61 +656,79 @@ class Track(ScrobblableMixin):
|
||||
name
|
||||
|
||||
Optionally, we can update any found artists with overwrite."""
|
||||
from music.utils import parse_artist_names
|
||||
|
||||
if artist_names is None and artist_name:
|
||||
artist_names = parse_artist_names(artist_name)
|
||||
|
||||
if not artist_names:
|
||||
artist_names = []
|
||||
|
||||
album = None
|
||||
if album_name:
|
||||
logger.info("Looking up album for: {album_name}")
|
||||
album = Album.find_or_create(name=album_name, artist_name=artist_name)
|
||||
artist = album.album_artist
|
||||
else:
|
||||
artist = Artist.find_or_create(artist_name, track_name=title)
|
||||
if not artist:
|
||||
artist = Artist.find_or_create(artist_name)
|
||||
logger.info(f"Looking up album for: {album_name}")
|
||||
first_name = artist_names[0] if artist_names else ""
|
||||
album = Album.find_or_create(name=album_name, artist_name=first_name)
|
||||
|
||||
lookup_keys = {"title": title, "artist": artist}
|
||||
if run_time_seconds:
|
||||
lookup_keys["base_run_time_seconds"] = run_time_seconds
|
||||
logger.info(f"Looking up track using: {lookup_keys}")
|
||||
track = cls.objects.filter(**lookup_keys).first()
|
||||
if track:
|
||||
logger.info(
|
||||
"Found match for track by name and artist, not going to musicbrainz ",
|
||||
extra={
|
||||
"track_id": track.id,
|
||||
"title": title,
|
||||
"artist_name": artist_name,
|
||||
"run_time_seconds": run_time_seconds,
|
||||
},
|
||||
)
|
||||
return track
|
||||
artist_objs = []
|
||||
for name in artist_names:
|
||||
artist = Artist.find_or_create(name, track_name=title)
|
||||
if artist:
|
||||
artist_objs.append(artist)
|
||||
|
||||
track = None
|
||||
if artist_objs:
|
||||
track = cls.objects.filter(title=title, artists__in=artist_objs).first()
|
||||
|
||||
track = cls.objects.filter(title=title, artist=artist).first()
|
||||
if not track:
|
||||
track, _ = cls.objects.get_or_create(title=title, artist=artist)
|
||||
track = cls.objects.filter(title=title).first()
|
||||
|
||||
if not track:
|
||||
track = cls(title=title)
|
||||
track.save()
|
||||
track.refresh_from_db()
|
||||
|
||||
if artist_objs:
|
||||
track.artists.add(*artist_objs)
|
||||
|
||||
if album:
|
||||
track.albums.add(album)
|
||||
|
||||
if enrich or not track.base_run_time_seconds:
|
||||
logger.info(
|
||||
f"Enriching track {track}",
|
||||
extra={
|
||||
"title": title,
|
||||
"artist_name": artist_name,
|
||||
"track_id": track.id,
|
||||
},
|
||||
)
|
||||
try:
|
||||
mbid, length = get_recording_mbid_exact(title, artist_name, album_name)
|
||||
except Exception:
|
||||
print("No musicbrainz result found, cannot enrich")
|
||||
return track
|
||||
track.base_run_time_seconds = run_time_seconds or int(length / 1000)
|
||||
track.musicbrainz_id = mbid
|
||||
if mbid and run_time_seconds:
|
||||
track.base_run_time_seconds = run_time_seconds
|
||||
track.musicbrainz_id = mbid
|
||||
track.tags.add("musicbrainz-provider", "musicbrainz-enriched")
|
||||
else:
|
||||
artist_name_str = " & ".join(artist_names) if artist_names else ""
|
||||
logger.info(
|
||||
f"Enriching track {track}",
|
||||
extra={
|
||||
"title": title,
|
||||
"artist_name": artist_name_str,
|
||||
"track_id": track.id,
|
||||
},
|
||||
)
|
||||
result, method = resolve_track(title, artist_name_str, album_name)
|
||||
if result and result.get("recording_mbid"):
|
||||
track.musicbrainz_id = result["recording_mbid"]
|
||||
length_ms = result.get("length_ms")
|
||||
if length_ms and not track.base_run_time_seconds:
|
||||
track.base_run_time_seconds = int(int(length_ms) / 1000)
|
||||
method_tag = f"musicbrainz-{method}" if method else ""
|
||||
track.tags.add(method_tag, "musicbrainz-enriched")
|
||||
cleaned_title, featured_names = extract_featured_artists(title)
|
||||
for feat_name in featured_names:
|
||||
artist = Artist.find_or_create(feat_name, track_name=title)
|
||||
if artist:
|
||||
track.artists.add(artist)
|
||||
else:
|
||||
print("No musicbrainz result found, cannot enrich")
|
||||
track.tags.add("musicbrainz-notfound")
|
||||
return track
|
||||
if commit:
|
||||
track.save()
|
||||
|
||||
return track
|
||||
|
||||
def fix_metadata(self, force_update=False):
|
||||
|
||||
...
|
||||
def fix_metadata(self, force_update=False): ...
|
||||
|
||||
@ -1,11 +1,155 @@
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
import musicbrainzngs
|
||||
from dateutil.parser import parse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TRACK_SUFFIXES = [
|
||||
r" - Bonus Track",
|
||||
r" - Bonus",
|
||||
r" - Remix",
|
||||
r" - Live",
|
||||
r" - Radio Edit",
|
||||
r" - Extended Mix",
|
||||
r" - Instrumental",
|
||||
r" - Acoustic",
|
||||
r" - Mix",
|
||||
r" - Edit",
|
||||
r" \[Explicit\]",
|
||||
]
|
||||
|
||||
FEATURED_PATTERNS = [
|
||||
(r"\(feat\. (.+?)\)", re.IGNORECASE),
|
||||
(r"\(ft\. (.+?)\)", re.IGNORECASE),
|
||||
(r"featuring (.+)", re.IGNORECASE),
|
||||
(r"feat\. (.+)", re.IGNORECASE),
|
||||
(r"ft\. (.+)", re.IGNORECASE),
|
||||
]
|
||||
|
||||
|
||||
def strip_track_suffixes(title: str) -> str:
|
||||
for suffix in TRACK_SUFFIXES:
|
||||
title = re.sub(suffix, "", title, flags=re.IGNORECASE)
|
||||
return title.strip()
|
||||
|
||||
|
||||
def extract_featured_artists(title: str) -> tuple[str, list[str]]:
|
||||
featured = []
|
||||
cleaned = title
|
||||
for pattern, flags in FEATURED_PATTERNS:
|
||||
m = re.search(pattern, cleaned, flags)
|
||||
if m:
|
||||
featured.append(m.group(1).strip())
|
||||
cleaned = re.sub(pattern, "", cleaned, flags=flags).strip()
|
||||
return cleaned, featured
|
||||
|
||||
|
||||
def search_recordings(title: str, artist: str = "") -> list[dict]:
|
||||
kwargs: dict = {"recording": title, "limit": 10}
|
||||
if artist:
|
||||
kwargs["artist"] = artist
|
||||
try:
|
||||
return musicbrainzngs.search_recordings(**kwargs).get("recording-list", [])
|
||||
except (musicbrainzngs.WebServiceError, musicbrainzngs.ResponseError) as e:
|
||||
logger.warning(f"MusicBrainz search error: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def pick_best_recording(
|
||||
recordings: list[dict], title: str, artist: str = ""
|
||||
) -> tuple[dict | None, str]:
|
||||
query_title = title.strip().casefold()
|
||||
query_artist = artist.strip().casefold() if artist else ""
|
||||
best_score: tuple[dict | None, str] = (None, "")
|
||||
|
||||
for rec in recordings:
|
||||
rec_title = rec.get("title", "").strip()
|
||||
score = int(rec.get("ext:score", 0))
|
||||
|
||||
if rec_title.casefold() != query_title:
|
||||
if score >= 85 and not best_score[0]:
|
||||
best_score = (rec, "score")
|
||||
continue
|
||||
|
||||
if query_artist:
|
||||
artist_credit = rec.get("artist-credit", [])
|
||||
rec_artist = (
|
||||
artist_credit[0]["artist"]["name"].strip()
|
||||
if artist_credit
|
||||
else ""
|
||||
)
|
||||
if rec_artist.casefold() != query_artist:
|
||||
if score >= 85 and not best_score[0]:
|
||||
best_score = (rec, "score")
|
||||
continue
|
||||
|
||||
return (rec, "exact")
|
||||
|
||||
return best_score
|
||||
|
||||
|
||||
def resolve_track(
|
||||
title: str, artist: str = "", album: str = ""
|
||||
) -> tuple[dict | None, str]:
|
||||
if not title:
|
||||
return (None, "")
|
||||
|
||||
titles_to_try: list[tuple[str, str]] = [(title, "")]
|
||||
stripped = strip_track_suffixes(title)
|
||||
if stripped != title:
|
||||
titles_to_try.append((stripped, "stripped-"))
|
||||
|
||||
for attempt_title, prefix in titles_to_try:
|
||||
if album and not prefix:
|
||||
try:
|
||||
mbid, length = get_recording_mbid_exact(
|
||||
attempt_title, artist, album
|
||||
)
|
||||
return (
|
||||
{
|
||||
"recording_mbid": mbid,
|
||||
"length_ms": length,
|
||||
},
|
||||
"exact",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if artist:
|
||||
result = get_track_metadata_with_artist(attempt_title, artist)
|
||||
if result and result.get("recording_mbid"):
|
||||
return (result, f"{prefix}exact")
|
||||
|
||||
recordings = search_recordings(attempt_title, artist)
|
||||
result, tag = pick_best_recording(recordings, attempt_title, artist)
|
||||
if result:
|
||||
length_ms = result.get("length")
|
||||
return (
|
||||
{
|
||||
"recording_mbid": result["id"],
|
||||
"length_ms": int(length_ms) if length_ms else None,
|
||||
},
|
||||
f"{prefix}{tag}",
|
||||
)
|
||||
|
||||
recordings = search_recordings(attempt_title)
|
||||
result, tag = pick_best_recording(recordings, attempt_title)
|
||||
if result:
|
||||
tag = "title-only" if not prefix else f"{prefix}title-only"
|
||||
length_ms = result.get("length")
|
||||
return (
|
||||
{
|
||||
"recording_mbid": result["id"],
|
||||
"length_ms": int(length_ms) if length_ms else None,
|
||||
},
|
||||
tag,
|
||||
)
|
||||
|
||||
return (None, "")
|
||||
|
||||
musicbrainzngs.set_useragent("Vrobbler", "1.0", "help@unbl.ink")
|
||||
|
||||
|
||||
|
||||
@ -15,12 +15,26 @@ def clean_artist_name(name: str) -> str:
|
||||
name = re.split(" w. ", name, flags=re.IGNORECASE)[0].strip()
|
||||
if " featuring " in name.lower():
|
||||
name = re.split(" featuring ", name, flags=re.IGNORECASE)[0].strip()
|
||||
# if " & " in name.lower() and "of the wand" not in name.lower():
|
||||
# name = re.split("&", name, flags=re.IGNORECASE)[0].strip()
|
||||
|
||||
return name
|
||||
|
||||
|
||||
def parse_artist_names(name: str) -> list[str]:
|
||||
"""Split a combined artist string (e.g. 'A & B') into individual names.
|
||||
|
||||
First strips feat./featuring/w. prefixes, then splits on ' & ' to
|
||||
support collaboration-style credits like 'Matt Sweeney & Bonnie Prince Billy'.
|
||||
"""
|
||||
name = clean_artist_name(name)
|
||||
if " & " in name.lower():
|
||||
return [
|
||||
part.strip()
|
||||
for part in re.split(r"\s+&\s+", name, flags=re.IGNORECASE)
|
||||
if part.strip()
|
||||
]
|
||||
return [name]
|
||||
|
||||
|
||||
def get_or_create_various_artists() -> "Artist":
|
||||
from music.models import Artist
|
||||
|
||||
@ -35,19 +49,19 @@ def deduplicate_tracks(commit=False) -> int:
|
||||
from music.models import Track
|
||||
|
||||
duplicates = (
|
||||
Track.objects.values("artist", "title")
|
||||
Track.objects.values("title")
|
||||
.annotate(dup_count=models.Count("id"))
|
||||
.filter(dup_count__gt=1)
|
||||
)
|
||||
|
||||
query = models.Q()
|
||||
for dup in duplicates:
|
||||
query |= models.Q(artist=dup["artist"], title=dup["title"])
|
||||
query |= models.Q(title=dup["title"])
|
||||
|
||||
duplicate_tracks = Track.objects.filter(query)
|
||||
|
||||
for b in duplicate_tracks:
|
||||
tracks = Track.objects.filter(artist=b.artist, title=b.title)
|
||||
tracks = Track.objects.filter(title=b.title)
|
||||
first = tracks.first()
|
||||
for other in tracks.exclude(id=first.id):
|
||||
print("Moving scrobbles for", other.id, " to ", first.id)
|
||||
@ -74,7 +88,7 @@ def condense_albums(commit: bool = False):
|
||||
for track in Track.objects.all():
|
||||
albums_to_add = []
|
||||
duplicates = (
|
||||
Track.objects.filter(title=track.title, artist=track.artist)
|
||||
Track.objects.filter(title=track.title)
|
||||
.exclude(id=track.id)
|
||||
.exclude(id__in=processed_ids)
|
||||
)
|
||||
@ -88,7 +102,7 @@ def condense_albums(commit: bool = False):
|
||||
track.albums.add(dup_track.album)
|
||||
|
||||
# Find out if this track appears more than once
|
||||
duplicates = Track.objects.filter(title=track.title, artist=track.artist)
|
||||
duplicates = Track.objects.filter(title=track.title)
|
||||
if duplicates.count() > 1:
|
||||
logger.info(f"Track appears more than once, condensing: {track}")
|
||||
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
from charts.models import ChartRecord
|
||||
from django.db.models import Count
|
||||
from django.views import generic
|
||||
from music.models import Album, Artist, Track
|
||||
from charts.models import ChartRecord
|
||||
from scrobbles.stats import get_scrobble_count_qs
|
||||
|
||||
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
|
||||
from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView
|
||||
|
||||
|
||||
class TrackListView(ScrobbleableListView):
|
||||
@ -14,29 +13,30 @@ class TrackListView(ScrobbleableListView):
|
||||
class TrackDetailView(ScrobbleableDetailView):
|
||||
model = Track
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context_data = super().get_context_data(**kwargs)
|
||||
context_data["charts"] = ChartRecord.objects.filter(
|
||||
track=self.object, rank__in=[1, 2, 3]
|
||||
)
|
||||
return context_data
|
||||
|
||||
|
||||
class ArtistListView(generic.ListView):
|
||||
model = Artist
|
||||
paginate_by = 100
|
||||
|
||||
def get_queryset(self):
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.annotate(scrobble_count=Count("track__scrobble"))
|
||||
.order_by("-scrobble_count")
|
||||
)
|
||||
qs = super().get_queryset().annotate(scrobble_count=Count("track__scrobble"))
|
||||
genre = self.request.GET.get("genre")
|
||||
if genre:
|
||||
qs = qs.filter(theaudiodb_genre=genre)
|
||||
mood = self.request.GET.get("mood")
|
||||
if mood:
|
||||
qs = qs.filter(theaudiodb_mood=mood)
|
||||
return qs.order_by("-scrobble_count")
|
||||
|
||||
def get_context_data(self, *, object_list=None, **kwargs):
|
||||
context_data = super().get_context_data(object_list=object_list, **kwargs)
|
||||
context_data["view"] = self.request.GET.get("view")
|
||||
genre = self.request.GET.get("genre")
|
||||
if genre:
|
||||
context_data["active_filter"] = f"genre: {genre}"
|
||||
mood = self.request.GET.get("mood")
|
||||
if mood:
|
||||
context_data["active_filter"] = f"mood: {mood}"
|
||||
return context_data
|
||||
|
||||
|
||||
@ -57,18 +57,52 @@ class ArtistDetailView(generic.DetailView):
|
||||
]
|
||||
|
||||
context_data["tracks_ranked"] = tracks_ranked
|
||||
context_data["charts"] = ChartRecord.objects.filter(
|
||||
|
||||
charts_qs = ChartRecord.objects.filter(
|
||||
artist=self.object, rank__in=[1, 2, 3]
|
||||
)
|
||||
).exclude(day__isnull=False)
|
||||
from collections import OrderedDict
|
||||
|
||||
grouped = OrderedDict()
|
||||
for chart in charts_qs:
|
||||
grouped.setdefault(chart.period_type, []).append(chart)
|
||||
context_data["charts"] = grouped
|
||||
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
context_data["recent_scrobbles"] = (
|
||||
Scrobble.objects.filter(track__artist=artist)
|
||||
Scrobble.objects.filter(track__artists=artist)
|
||||
.select_related("track", "track__album")
|
||||
.order_by("-timestamp")[:100]
|
||||
)
|
||||
|
||||
similar = artist.similar_artists or []
|
||||
if similar:
|
||||
top = similar[:10]
|
||||
mbids = [sa["artist_mbid"] for sa in top if sa.get("artist_mbid")]
|
||||
local_artists = {
|
||||
a.musicbrainz_id: a
|
||||
for a in Artist.objects.filter(musicbrainz_id__in=mbids)
|
||||
}
|
||||
for sa in top:
|
||||
local = local_artists.get(sa.get("artist_mbid"))
|
||||
sa["local_url"] = local.get_absolute_url() if local else None
|
||||
sa["musicbrainz_url"] = (
|
||||
f"https://musicbrainz.org/artist/{sa['artist_mbid']}"
|
||||
if sa.get("artist_mbid")
|
||||
else None
|
||||
)
|
||||
context_data["similar_artists"] = top
|
||||
|
||||
if artist.theaudiodb_genre:
|
||||
context_data["genre_count"] = Artist.objects.filter(
|
||||
theaudiodb_genre=artist.theaudiodb_genre
|
||||
).count()
|
||||
if artist.theaudiodb_mood:
|
||||
context_data["mood_count"] = Artist.objects.filter(
|
||||
theaudiodb_mood=artist.theaudiodb_mood
|
||||
).count()
|
||||
|
||||
return context_data
|
||||
|
||||
|
||||
|
||||
@ -31,10 +31,14 @@ class UserProfileForm(forms.ModelForm):
|
||||
"webdav_auto_import",
|
||||
"ntfy_url",
|
||||
"ntfy_enabled",
|
||||
"mopidy_api_url",
|
||||
"favorites_mopidy_playlist",
|
||||
"monthly_mopidy_playlist_pattern",
|
||||
"redirect_to_webpage",
|
||||
"enable_public_widgets",
|
||||
"widget_custom_css",
|
||||
"home_scrobble_limit",
|
||||
"weigh_in_units",
|
||||
]
|
||||
widgets = {
|
||||
"lastfm_password": forms.PasswordInput(render_value=True),
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-21 13:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import profiles.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("profiles", "0031_add_home_scrobble_limit"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="weigh_in_units",
|
||||
field=models.CharField(
|
||||
choices=profiles.models.WeighUnit.choices,
|
||||
default=profiles.models.WeighUnit.METRIC,
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.30 on 2026-06-04 16:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("profiles", "0032_userprofile_weigh_in_units"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="mopidy_api_url",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.30 on 2026-06-05 14:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("profiles", "0033_userprofile_mopidy_api_url"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="favorites_mopidy_playlist",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Playlist name (e.g. 'Favorites'). Will map to m3u:Favorites.m3u8",
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.30 on 2026-06-05 17:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("profiles", "0034_userprofile_favorites_mopidy_playlist"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="monthly_mopidy_playlist_pattern",
|
||||
field=models.CharField(
|
||||
blank=True,
|
||||
help_text="Django date format pattern for monthly playlists (e.g. 'Y F')",
|
||||
max_length=255,
|
||||
null=True,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-09 15:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("profiles", "0035_userprofile_monthly_mopidy_playlist_pattern"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userprofile",
|
||||
name="default_scrobble_visibility",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("shared", "Shared"),
|
||||
("private", "Private"),
|
||||
],
|
||||
default="shared",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,26 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-09 16:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("profiles", "0036_userprofile_default_scrobble_visibility"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="userprofile",
|
||||
name="default_scrobble_visibility",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("shared", "Shared"),
|
||||
("private", "Private"),
|
||||
],
|
||||
default="private",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -9,6 +9,11 @@ from django.utils.functional import cached_property
|
||||
from django_extensions.db.models import TimeStampedModel
|
||||
from encrypted_field import EncryptedField
|
||||
from profiles.constants import PRETTY_TIMEZONE_CHOICES
|
||||
VISIBILITY_CHOICES = (
|
||||
("public", "Public"),
|
||||
("shared", "Shared"),
|
||||
("private", "Private"),
|
||||
)
|
||||
|
||||
User = get_user_model()
|
||||
BNULL = {"blank": True, "null": True}
|
||||
@ -16,6 +21,11 @@ BNULL = {"blank": True, "null": True}
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WeighUnit(models.TextChoices):
|
||||
METRIC = "metric", "Metric (kg, cm)"
|
||||
IMPERIAL = "imperial", "Imperial (lbs, in)"
|
||||
|
||||
|
||||
class UserProfile(TimeStampedModel):
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
||||
timezone = models.CharField(
|
||||
@ -59,13 +69,35 @@ class UserProfile(TimeStampedModel):
|
||||
ntfy_url = models.CharField(max_length=255, **BNULL)
|
||||
ntfy_enabled = models.BooleanField(default=False)
|
||||
|
||||
mopidy_api_url = models.CharField(max_length=255, **BNULL)
|
||||
favorites_mopidy_playlist = models.CharField(
|
||||
max_length=255, **BNULL,
|
||||
help_text="Playlist name (e.g. 'Favorites'). Will map to m3u:Favorites.m3u8",
|
||||
)
|
||||
monthly_mopidy_playlist_pattern = models.CharField(
|
||||
max_length=255, **BNULL,
|
||||
help_text="Django date format pattern for monthly playlists (e.g. 'Y F')",
|
||||
)
|
||||
|
||||
redirect_to_webpage = models.BooleanField(default=True)
|
||||
|
||||
enable_public_widgets = models.BooleanField(default=False)
|
||||
widget_custom_css = models.TextField(**BNULL)
|
||||
|
||||
default_scrobble_visibility = models.CharField(
|
||||
max_length=10,
|
||||
choices=VISIBILITY_CHOICES,
|
||||
default="private",
|
||||
)
|
||||
|
||||
home_scrobble_limit = models.IntegerField(default=20)
|
||||
|
||||
weigh_in_units = models.CharField(
|
||||
max_length=16,
|
||||
choices=WeighUnit.choices,
|
||||
default=WeighUnit.METRIC,
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return f"User profile for {self.user}"
|
||||
|
||||
|
||||
@ -2,10 +2,16 @@ from django.contrib import admin
|
||||
|
||||
from scrobbles.models import (
|
||||
AudioScrobblerTSVImport,
|
||||
BGStatsImport,
|
||||
EBirdCSVImport,
|
||||
FavoriteMedia,
|
||||
KoReaderImport,
|
||||
LastFmImport,
|
||||
RetroarchImport,
|
||||
ScaleCSVImport,
|
||||
Scrobble,
|
||||
ShareViewLog,
|
||||
TrailGPXImport,
|
||||
)
|
||||
from scrobbles.mixins import Genre
|
||||
|
||||
@ -15,6 +21,7 @@ class ScrobbleInline(admin.TabularInline):
|
||||
extra = 0
|
||||
raw_id_fields = (
|
||||
"video",
|
||||
"channel",
|
||||
"podcast_episode",
|
||||
"track",
|
||||
"video_game",
|
||||
@ -25,6 +32,7 @@ class ScrobbleInline(admin.TabularInline):
|
||||
"board_game",
|
||||
"geo_location",
|
||||
"task",
|
||||
"puzzle",
|
||||
"mood",
|
||||
"brick_set",
|
||||
"trail",
|
||||
@ -49,32 +57,44 @@ class ImportBaseAdmin(admin.ModelAdmin):
|
||||
"process_count",
|
||||
"processed_finished",
|
||||
"processing_started",
|
||||
"error_log",
|
||||
)
|
||||
ordering = ("-created",)
|
||||
|
||||
|
||||
@admin.register(AudioScrobblerTSVImport)
|
||||
class AudioScrobblerTSVImportAdmin(ImportBaseAdmin):
|
||||
...
|
||||
class AudioScrobblerTSVImportAdmin(ImportBaseAdmin): ...
|
||||
|
||||
|
||||
@admin.register(LastFmImport)
|
||||
class LastFmImportAdmin(ImportBaseAdmin):
|
||||
...
|
||||
class LastFmImportAdmin(ImportBaseAdmin): ...
|
||||
|
||||
|
||||
@admin.register(KoReaderImport)
|
||||
class KoReaderImportAdmin(ImportBaseAdmin):
|
||||
...
|
||||
class KoReaderImportAdmin(ImportBaseAdmin): ...
|
||||
|
||||
|
||||
@admin.register(RetroarchImport)
|
||||
class RetroarchImportAdmin(ImportBaseAdmin):
|
||||
...
|
||||
class RetroarchImportAdmin(ImportBaseAdmin): ...
|
||||
|
||||
|
||||
class RetroarchImportAdmin(ImportBaseAdmin):
|
||||
...
|
||||
class RetroarchImportAdmin(ImportBaseAdmin): ...
|
||||
|
||||
|
||||
@admin.register(BGStatsImport)
|
||||
class BGStatsImportAdmin(ImportBaseAdmin): ...
|
||||
|
||||
|
||||
@admin.register(EBirdCSVImport)
|
||||
class EBirdCSVImportAdmin(ImportBaseAdmin): ...
|
||||
|
||||
|
||||
@admin.register(ScaleCSVImport)
|
||||
class ScaleCSVImportAdmin(ImportBaseAdmin): ...
|
||||
|
||||
|
||||
@admin.register(TrailGPXImport)
|
||||
class TrailGPXImportAdmin(ImportBaseAdmin): ...
|
||||
|
||||
|
||||
@admin.register(Genre)
|
||||
@ -97,6 +117,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
||||
"in_progress",
|
||||
"is_paused",
|
||||
"played_to_completion",
|
||||
"visibility",
|
||||
"user",
|
||||
)
|
||||
raw_id_fields = (
|
||||
@ -124,6 +145,7 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
||||
"is_paused",
|
||||
"in_progress",
|
||||
"media_type",
|
||||
"visibility",
|
||||
"long_play_complete",
|
||||
"source",
|
||||
"timezone",
|
||||
@ -140,3 +162,36 @@ class ScrobbleAdmin(admin.ModelAdmin):
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request).exclude(timestamp__year=None)
|
||||
return qs
|
||||
|
||||
|
||||
@admin.register(ShareViewLog)
|
||||
class ShareViewLogAdmin(admin.ModelAdmin):
|
||||
list_display = ("scrobble", "ip_address", "created")
|
||||
list_filter = ("created",)
|
||||
date_hierarchy = "created"
|
||||
raw_id_fields = ("scrobble",)
|
||||
|
||||
|
||||
@admin.register(FavoriteMedia)
|
||||
class FavoriteMediaAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "media_type", "sent_to_mopidy", "created")
|
||||
list_filter = ("media_type", "sent_to_mopidy", "user")
|
||||
date_hierarchy = "created"
|
||||
raw_id_fields = (
|
||||
"video",
|
||||
"track",
|
||||
"podcast_episode",
|
||||
"sport_event",
|
||||
"book",
|
||||
"video_game",
|
||||
"board_game",
|
||||
"geo_location",
|
||||
"task",
|
||||
"mood",
|
||||
"brick_set",
|
||||
"trail",
|
||||
"beer",
|
||||
"web_page",
|
||||
"life_event",
|
||||
"birding_location",
|
||||
)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import re
|
||||
|
||||
from rest_framework import serializers
|
||||
from scrobbles.constants import Visibility
|
||||
from scrobbles.models import (
|
||||
AudioScrobblerTSVImport,
|
||||
KoReaderImport,
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
from logging import getLogger
|
||||
|
||||
from rest_framework import permissions, viewsets
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from scrobbles.api.serializers import (
|
||||
AudioScrobblerTSVImportSerializer,
|
||||
KoReaderImportSerializer,
|
||||
@ -26,6 +28,12 @@ class ScrobbleViewSet(viewsets.ModelViewSet):
|
||||
def get_queryset(self):
|
||||
return super().get_queryset().filter(user=self.request.user)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def regenerate_share_token(self, request, uuid=None):
|
||||
scrobble = self.get_object()
|
||||
scrobble.regenerate_share_token()
|
||||
return Response({"share_url": scrobble.get_share_url()})
|
||||
|
||||
|
||||
class KoReaderImportViewSet(viewsets.ModelViewSet):
|
||||
queryset = KoReaderImport.objects.all().order_by("-created")
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
from django.db import models
|
||||
from enum import Enum
|
||||
|
||||
JELLYFIN_VIDEO_ITEM_TYPES = ["Episode", "Movie"]
|
||||
|
||||
class Visibility(models.TextChoices):
|
||||
PUBLIC = "public", "Public"
|
||||
SHARED = "shared", "Shared"
|
||||
PRIVATE = "private", "Private"
|
||||
JELLYFIN_AUDIO_ITEM_TYPES = ["Audio"]
|
||||
|
||||
LONG_PLAY_MEDIA = {
|
||||
|
||||
@ -4,6 +4,26 @@ from django.utils import timezone
|
||||
from scrobbles.constants import EXCLUDE_FROM_NOW_PLAYING
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
MONTH_COLORS = [
|
||||
"#db7a7a", # Jan
|
||||
"#db847a", # Feb
|
||||
"#b0db7a", # Mar
|
||||
"#7adb82", # Apr
|
||||
"#7adbb3", # May
|
||||
"#7ab6db", # Jun
|
||||
"#7a8edb", # Jul
|
||||
"#977adb", # Aug
|
||||
"#c47adb", # Sep
|
||||
"#db7ac5", # Oct
|
||||
"#db7a90", # Nov
|
||||
"#db7a7a", # Dec
|
||||
]
|
||||
|
||||
|
||||
def month_color(request):
|
||||
from datetime import date
|
||||
return {"month_color": MONTH_COLORS[(date.today().month - 1) % 12]}
|
||||
|
||||
|
||||
def now_playing(request):
|
||||
user = request.user
|
||||
|
||||
@ -57,38 +57,152 @@ class BaseLogData(JSONDataclass):
|
||||
if not self.notes:
|
||||
return ""
|
||||
|
||||
if isinstance(self.notes, dict):
|
||||
lines = []
|
||||
for ts, text in self.notes.items():
|
||||
note_text = " ".join(text.strip().split())
|
||||
lines.append(f"{ts}: {note_text}")
|
||||
return separator.join(lines)
|
||||
|
||||
if isinstance(self.notes, str):
|
||||
return html.escape(re.sub(r"\s*\[start:\d+\|end:\d+\]$", "", self.notes))
|
||||
|
||||
if isinstance(self.notes, list):
|
||||
cleaned_notes = []
|
||||
for note in self.notes:
|
||||
cleaned = html.escape(re.sub(r"\s*\[start:\d+\|end:\d+\]$", "", note))
|
||||
cleaned_notes.append(cleaned)
|
||||
if isinstance(note, dict):
|
||||
timestamp, note_text = next(iter(note.items()))
|
||||
note_text = " ".join(note_text.strip().split())
|
||||
cleaned_notes.append(f"{timestamp}: {note_text}")
|
||||
elif isinstance(note, str):
|
||||
cleaned = html.escape(
|
||||
re.sub(r"\s*\[start:\d+\|end:\d+\]$", "", note)
|
||||
)
|
||||
cleaned_notes.append(cleaned)
|
||||
return separator.join(cleaned_notes)
|
||||
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _format_timestamp(ts: str) -> str:
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
|
||||
cleaned = ts.strip().strip("[]")
|
||||
dt = None
|
||||
|
||||
if cleaned.isdigit():
|
||||
try:
|
||||
seconds = int(cleaned)
|
||||
dt = datetime.fromtimestamp(seconds, tz=timezone.utc)
|
||||
except (ValueError, OSError):
|
||||
pass
|
||||
|
||||
if dt is None:
|
||||
for fmt in [
|
||||
"%Y-%m-%dT%H:%M:%S.%fZ",
|
||||
"%Y-%m-%dT%H:%M:%SZ",
|
||||
"%Y-%m-%dT%H:%M:%S.%f",
|
||||
"%Y-%m-%dT%H:%M:%S",
|
||||
]:
|
||||
try:
|
||||
dt = datetime.strptime(cleaned, fmt)
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if dt is None:
|
||||
m = re.match(r"(\d{4}-\d{2}-\d{2})\s+\w{3}\s+(\d{2}:\d{2})", cleaned)
|
||||
if m:
|
||||
try:
|
||||
dt = datetime.strptime(
|
||||
f"{m.group(1)} {m.group(2)}", "%Y-%m-%d %H:%M"
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if dt is None:
|
||||
try:
|
||||
dt = datetime.fromisoformat(cleaned)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if dt:
|
||||
return dt.strftime("%b %-d, %Y %-I:%M %p")
|
||||
return ts
|
||||
|
||||
def notes_as_html(self) -> str:
|
||||
import html
|
||||
import bleach
|
||||
import markdown
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
if not self.notes:
|
||||
return ""
|
||||
|
||||
notes_list = []
|
||||
if isinstance(self.notes, str):
|
||||
notes_list = [self.notes]
|
||||
elif isinstance(self.notes, list):
|
||||
notes_list = self.notes
|
||||
md = markdown.Markdown(extensions=["extra"])
|
||||
allowed_tags = [
|
||||
"p",
|
||||
"br",
|
||||
"strong",
|
||||
"em",
|
||||
"a",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"code",
|
||||
"pre",
|
||||
"blockquote",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"hr",
|
||||
"img",
|
||||
]
|
||||
|
||||
html_notes = []
|
||||
for note in notes_list:
|
||||
for line in note.split("\n"):
|
||||
note_items = []
|
||||
|
||||
if isinstance(self.notes, dict):
|
||||
for ts, text in sorted(self.notes.items(), reverse=True):
|
||||
note_items.append((ts, text.strip()))
|
||||
elif isinstance(self.notes, str):
|
||||
for line in self.notes.split("\n"):
|
||||
if line.strip():
|
||||
escaped_line = html.escape(line)
|
||||
html_notes.append(f'<div class="sticky-note">{escaped_line}</div>')
|
||||
return mark_safe("".join(html_notes))
|
||||
note_items.append((None, line.strip()))
|
||||
elif isinstance(self.notes, list):
|
||||
for note in self.notes:
|
||||
if isinstance(note, dict):
|
||||
timestamp, note_text = next(iter(note.items()))
|
||||
note_items.append((timestamp, note_text.strip()))
|
||||
elif isinstance(note, str):
|
||||
for line in note.split("\n"):
|
||||
if line.strip():
|
||||
note_items.append((None, line.strip()))
|
||||
|
||||
html_parts = []
|
||||
for i, (ts, text) in enumerate(note_items):
|
||||
if i > 0:
|
||||
html_parts.append('<hr class="note-divider">')
|
||||
|
||||
ts_html = ""
|
||||
if ts:
|
||||
ts_html = (
|
||||
f'<h5 class="note-timestamp">{self._format_timestamp(ts)}</h5>'
|
||||
)
|
||||
|
||||
content_html = bleach.clean(
|
||||
md.convert(text),
|
||||
tags=allowed_tags,
|
||||
strip=True,
|
||||
)
|
||||
|
||||
html_parts.append(
|
||||
f'<div class="note-item">{ts_html}<div class="note-content">{content_html}</div></div>'
|
||||
)
|
||||
|
||||
return mark_safe("".join(html_parts))
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -96,6 +210,14 @@ class LongPlayLogData(JSONDataclass):
|
||||
long_play_complete: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class SportEventLogData(BaseLogData):
|
||||
thesportsdb_id: Optional[str] = None
|
||||
start: Optional[str] = None
|
||||
round_name: Optional[str] = None
|
||||
season_name: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class WithPeopleLogData(JSONDataclass):
|
||||
with_people_ids: Optional[list[int]] = None
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import csv
|
||||
import tempfile
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
from django.db.models import Q
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
|
||||
def export_scrobbles(start_date=None, end_date=None, format="AS"):
|
||||
@ -50,7 +50,7 @@ def export_scrobbles(start_date=None, end_date=None, format="AS"):
|
||||
track = scrobble.track
|
||||
track_number = 0 # TODO Add track number
|
||||
track_rating = "S" # TODO implement ratings?
|
||||
track_artist = track.artist or track.album.album_artist
|
||||
track_artist = track.artist or track.album.artist
|
||||
row = [
|
||||
track_artist,
|
||||
track.album.name,
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import json
|
||||
from dataclasses import fields
|
||||
from typing import Union, get_args, get_origin
|
||||
|
||||
@ -89,7 +90,7 @@ def form_from_dataclass(dataclass):
|
||||
|
||||
form_cls = type(f"{dataclass.__name__}Form", (forms.Form,), form_fields)
|
||||
|
||||
if "notes" in form_cls.base_fields:
|
||||
if "notes" in form_cls.base_fields and "notes" not in override_fields:
|
||||
form_cls.base_fields["notes"] = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.Textarea(attrs={"rows": 4}),
|
||||
@ -101,3 +102,54 @@ def form_from_dataclass(dataclass):
|
||||
|
||||
form_cls.clean_notes = clean_notes
|
||||
return form_cls
|
||||
|
||||
|
||||
class NotesDictWidget(forms.Widget):
|
||||
template_name = "tasks/task_notes_widget.html"
|
||||
|
||||
class Media:
|
||||
js = ("tasks/task_notes.js",)
|
||||
|
||||
def value_from_datadict(self, data, files, name):
|
||||
timestamps = data.getlist(f"{name}_timestamps")
|
||||
if timestamps:
|
||||
contents = data.getlist(f"{name}_contents")
|
||||
result = {}
|
||||
for i, ts in enumerate(timestamps):
|
||||
if i < len(contents) and ts:
|
||||
result[ts] = contents[i]
|
||||
return result if result else ""
|
||||
return data.get(name, "")
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
context = super().get_context(name, value, attrs)
|
||||
notes = {}
|
||||
if value:
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
notes = json.loads(value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
notes = {}
|
||||
elif isinstance(value, dict):
|
||||
notes = value
|
||||
context["widget"]["notes"] = notes
|
||||
return context
|
||||
|
||||
|
||||
class NotesDictField(forms.Field):
|
||||
widget = NotesDictWidget
|
||||
|
||||
def clean(self, value):
|
||||
if not value:
|
||||
return {}
|
||||
|
||||
if isinstance(value, str):
|
||||
if value.strip():
|
||||
from scrobbles.utils import make_note_timestamp
|
||||
return {make_note_timestamp(): value.strip()}
|
||||
return {}
|
||||
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
|
||||
return {}
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
import calendar
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pylast
|
||||
import pytz
|
||||
from celery import shared_task
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from music.models import Track
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -22,6 +27,8 @@ PYLAST_ERRORS = tuple(
|
||||
|
||||
class LastFM:
|
||||
def __init__(self, user):
|
||||
self.user = None
|
||||
self.vrobbler_user = user
|
||||
try:
|
||||
self.client = pylast.LastFMNetwork(
|
||||
api_key=getattr(settings, "LASTFM_API_KEY"),
|
||||
@ -30,24 +37,28 @@ class LastFM:
|
||||
password_hash=pylast.md5(user.profile.lastfm_password),
|
||||
)
|
||||
self.user = self.client.get_user(user.profile.lastfm_username)
|
||||
self.vrobbler_user = user
|
||||
except PYLAST_ERRORS as e:
|
||||
logger.error(f"Error during Last.fm setup: {e}")
|
||||
raise
|
||||
|
||||
def import_from_lastfm(self, last_processed=None):
|
||||
def import_from_lastfm(self, last_processed=None, time_to=None):
|
||||
"""Given a last processed time, import all scrobbles from LastFM since then"""
|
||||
from scrobbles.models import Scrobble
|
||||
|
||||
new_scrobbles = []
|
||||
source = "Last.fm"
|
||||
lastfm_scrobbles = self.get_last_scrobbles(time_from=last_processed)
|
||||
lastfm_scrobbles = self.get_last_scrobbles(
|
||||
time_from=last_processed, time_to=time_to
|
||||
)
|
||||
|
||||
for lfm_scrobble in lastfm_scrobbles:
|
||||
track = Track.find_or_create(
|
||||
title=lfm_scrobble.get("title"),
|
||||
artist_name=lfm_scrobble.get("artist"),
|
||||
album_name=lfm_scrobble.get("album"),
|
||||
enrich=True,
|
||||
album_name=lfm_scrobble.get("album", ""),
|
||||
run_time_seconds=lfm_scrobble.get("run_time_seconds"),
|
||||
mbid=lfm_scrobble.get("mbid"),
|
||||
enrich=False,
|
||||
)
|
||||
|
||||
tz_timestamp = self.vrobbler_user.profile.get_timestamp_with_tz(
|
||||
@ -65,6 +76,7 @@ class LastFM:
|
||||
in_progress=False,
|
||||
media_type=Scrobble.MediaType.TRACK,
|
||||
timezone=tz_timestamp.tzinfo.name,
|
||||
visibility="private",
|
||||
)
|
||||
# Vrobbler scrobbles on finish, LastFM scrobbles on start
|
||||
seconds_eariler = timestamp - timedelta(seconds=20)
|
||||
@ -92,6 +104,20 @@ class LastFM:
|
||||
)
|
||||
return created
|
||||
|
||||
def get_earliest_scrobble_timestamp(self) -> datetime | None:
|
||||
"""Return the user's Last.fm registration date as the earliest
|
||||
possible scrobble timestamp.
|
||||
|
||||
Uses ``get_unixtime_registered()`` which is a single API call.
|
||||
Returns ``None`` if the user has no scrobbles at all.
|
||||
"""
|
||||
if not self.get_last_scrobbles(check=True):
|
||||
return None
|
||||
registered_ts = self.user.get_unixtime_registered()
|
||||
earliest = datetime.fromtimestamp(registered_ts, tz=pytz.utc)
|
||||
logger.info("Last.fm user %s registered on %s", self.user.name, earliest)
|
||||
return earliest
|
||||
|
||||
def get_last_scrobbles(self, time_from=None, time_to=None, check=False):
|
||||
"""Given a user, Last.fm api key, and secret key, grab a list of scrobbled
|
||||
tracks"""
|
||||
@ -102,14 +128,12 @@ class LastFM:
|
||||
if time_to:
|
||||
lfm_params["time_to"] = int(time_to.timestamp())
|
||||
|
||||
# if not time_from and not time_to:
|
||||
lfm_params["limit"] = None
|
||||
lfm_params["limit"] = 1 if check else None
|
||||
|
||||
found_scrobbles = self.user.get_recent_tracks(**lfm_params)
|
||||
# TOOD spin this out into a celery task over certain threshold of found scrobbles?
|
||||
|
||||
if check and found_scrobbles:
|
||||
return True
|
||||
if check:
|
||||
return bool(found_scrobbles)
|
||||
|
||||
for scrobble in found_scrobbles:
|
||||
logger.info(f"Processing {scrobble}")
|
||||
@ -163,3 +187,112 @@ class LastFM:
|
||||
}
|
||||
)
|
||||
return scrobbles
|
||||
|
||||
|
||||
@shared_task
|
||||
def dispatch_historical_imports(user_id):
|
||||
"""Find every un-imported month and dispatch a sub-task for each.
|
||||
|
||||
Walks backward month by month from the user's earliest completed
|
||||
``LastFmImport`` (or the current month if none exists) to their
|
||||
first Last.fm scrobble, creating a ``LastFmImport`` record and
|
||||
dispatching ``process_lastfm_import`` for each month that has
|
||||
scrobbles.
|
||||
"""
|
||||
from scrobbles.models import LastFmImport
|
||||
from scrobbles.tasks import process_lastfm_import
|
||||
|
||||
user = get_user_model().objects.filter(id=user_id).first()
|
||||
if not user:
|
||||
logger.error("User %s not found", user_id)
|
||||
return 0
|
||||
|
||||
try:
|
||||
lastfm = LastFM(user)
|
||||
except Exception:
|
||||
logger.exception("Failed to set up LastFM client for user %s", user_id)
|
||||
return 0
|
||||
|
||||
logger.info("Looking up earliest Last.fm scrobble for user %s", user_id)
|
||||
earliest_scrobble = lastfm.get_earliest_scrobble_timestamp()
|
||||
if earliest_scrobble is None:
|
||||
logger.info("User %s has no Last.fm scrobbles", user_id)
|
||||
return 0
|
||||
logger.info("Earliest Last.fm scrobble for user %s: %s", user_id, earliest_scrobble)
|
||||
|
||||
in_progress = LastFmImport.objects.filter(
|
||||
user_id=user_id, processed_finished__isnull=True
|
||||
).exists()
|
||||
if in_progress:
|
||||
logger.info(
|
||||
"User %s has an import still in progress, skipping dispatch",
|
||||
user_id,
|
||||
)
|
||||
return 0
|
||||
|
||||
def _first_of_month(dt):
|
||||
return dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
def _last_of_month(dt):
|
||||
_, last_day = calendar.monthrange(dt.year, dt.month)
|
||||
return dt.replace(
|
||||
day=last_day, hour=23, minute=59, second=59, microsecond=999999
|
||||
)
|
||||
|
||||
earliest = (
|
||||
LastFmImport.objects.filter(user_id=user_id, processed_finished__isnull=False)
|
||||
.order_by("processed_finished")
|
||||
.first()
|
||||
)
|
||||
if earliest:
|
||||
earliest_log_scrobble = earliest.scrobbles().order_by("timestamp").first()
|
||||
cursor = (
|
||||
_first_of_month(earliest_log_scrobble.timestamp)
|
||||
if earliest_log_scrobble
|
||||
else earliest.processed_finished
|
||||
)
|
||||
logger.info(
|
||||
"Found existing import; earliest scrobble %s, cursor set to %s",
|
||||
earliest_log_scrobble.timestamp if earliest_log_scrobble else None,
|
||||
cursor,
|
||||
)
|
||||
else:
|
||||
cursor = timezone.now()
|
||||
logger.info("No existing imports; cursor set to %s", cursor)
|
||||
|
||||
dispatched = 0
|
||||
while True:
|
||||
cursor = _first_of_month(cursor) - relativedelta(days=1)
|
||||
month_start = _first_of_month(cursor)
|
||||
month_end = _last_of_month(cursor)
|
||||
|
||||
if month_end < earliest_scrobble:
|
||||
logger.info("Reached earliest scrobble at %s, stopping", earliest_scrobble)
|
||||
break
|
||||
|
||||
logger.info(
|
||||
"Checking for Last.fm scrobbles in range %s to %s",
|
||||
month_start.date(),
|
||||
month_end.date(),
|
||||
)
|
||||
|
||||
has = lastfm.get_last_scrobbles(
|
||||
time_from=month_start, time_to=month_end, check=True
|
||||
)
|
||||
if not has:
|
||||
continue
|
||||
|
||||
lfm_import = LastFmImport.objects.create(user_id=user_id)
|
||||
process_lastfm_import.delay(
|
||||
lfm_import.id, time_from=month_start, time_to=month_end
|
||||
)
|
||||
logger.info(
|
||||
"%s – %s dispatched (import %s)",
|
||||
month_start.date(),
|
||||
month_end.date(),
|
||||
lfm_import.id,
|
||||
)
|
||||
dispatched += 1
|
||||
|
||||
logger.info("Dispatched %d monthly batch(es) for user %s", dispatched, user_id)
|
||||
return dispatched
|
||||
|
||||
122
vrobbler/apps/scrobbles/importers/scale.py
Normal file
122
vrobbler/apps/scrobbles/importers/scale.py
Normal file
@ -0,0 +1,122 @@
|
||||
import csv
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.notifications import ScrobbleNtfyNotification
|
||||
from tasks.models import Task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FLOAT_COLUMNS = {
|
||||
"WEIGHT": "weight",
|
||||
"BMI": "bmi",
|
||||
"BODY_FAT": "body_fat",
|
||||
"BONE": "bone",
|
||||
"MUSCLE": "muscle",
|
||||
"WATER": "water",
|
||||
"VISCERAL_FAT": "visceral_fat",
|
||||
"WAIST": "waist",
|
||||
"CALORIES": "calories",
|
||||
"BMR": "bmr",
|
||||
"TDEE": "tdee",
|
||||
"HEART_RATE": "heart_rate",
|
||||
"CHEST": "chest",
|
||||
"BICEPS": "biceps",
|
||||
"NECK": "neck",
|
||||
"THIGH": "thigh",
|
||||
"HIPS": "hips",
|
||||
"CALIPER": "caliper",
|
||||
"CALIPER_1": "caliper_1",
|
||||
"CALIPER_2": "caliper_2",
|
||||
"CALIPER_3": "caliper_3",
|
||||
"LBM": "lbm",
|
||||
"WHR": "whr",
|
||||
"WHTR": "whtr",
|
||||
}
|
||||
|
||||
|
||||
def parse_float(value):
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return round(float(value), 2)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def import_scale_csv(file_path, user_id):
|
||||
user = get_user_model().objects.get(id=user_id)
|
||||
weigh_in = Task.find_or_create("Weigh-in")
|
||||
new_scrobbles = []
|
||||
|
||||
with open(file_path, newline="", encoding="utf-8-sig") as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
date_str = (row.get("DATE") or "").strip()
|
||||
time_str = (row.get("TIME") or "").strip()
|
||||
if not date_str or not time_str:
|
||||
logger.warning("Skipping row with no DATE or TIME")
|
||||
continue
|
||||
|
||||
try:
|
||||
dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M:%S.%f")
|
||||
except ValueError:
|
||||
try:
|
||||
dt = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %H:%M:%S")
|
||||
except ValueError:
|
||||
logger.warning(f"Could not parse date/time: {date_str} {time_str}")
|
||||
continue
|
||||
|
||||
dt = dt.replace(microsecond=0)
|
||||
stop = user.profile.get_timestamp_with_tz(dt)
|
||||
start = stop - timedelta(minutes=5)
|
||||
|
||||
log_dict = {}
|
||||
for csv_col, log_key in FLOAT_COLUMNS.items():
|
||||
val = parse_float(row.get(csv_col))
|
||||
if val is not None:
|
||||
log_dict[log_key] = val
|
||||
|
||||
comment = (row.get("COMMENT") or "").strip()
|
||||
if comment:
|
||||
log_dict["comment"] = comment
|
||||
|
||||
log_dict["unit_type"] = user.profile.weigh_in_units
|
||||
|
||||
weight_val = log_dict.get("weight")
|
||||
if weight_val is not None:
|
||||
unit = "kg" if user.profile.weigh_in_units == "metric" else "lbs"
|
||||
log_dict["title"] = f"{weight_val} {unit}"
|
||||
|
||||
existing = Scrobble.objects.filter(
|
||||
timestamp=start,
|
||||
task=weigh_in,
|
||||
user=user,
|
||||
).first()
|
||||
if existing:
|
||||
logger.debug(f"Skipping existing scrobble for {start}")
|
||||
continue
|
||||
|
||||
new_scrobble = Scrobble(
|
||||
user=user,
|
||||
timestamp=start,
|
||||
stop_timestamp=stop,
|
||||
timezone=stop.tzinfo.name,
|
||||
source="openScale CSV Import",
|
||||
task=weigh_in,
|
||||
log=log_dict,
|
||||
played_to_completion=True,
|
||||
in_progress=False,
|
||||
media_type=Scrobble.MediaType.TASK,
|
||||
visibility="private",
|
||||
)
|
||||
new_scrobbles.append(new_scrobble)
|
||||
|
||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||
logger.info(f"Created {len(created)} weigh-in scrobbles")
|
||||
for scrobble in created:
|
||||
ScrobbleNtfyNotification(scrobble).send()
|
||||
return created
|
||||
335
vrobbler/apps/scrobbles/importers/trail_gpx.py
Normal file
335
vrobbler/apps/scrobbles/importers/trail_gpx.py
Normal file
@ -0,0 +1,335 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import timezone as dt_timezone
|
||||
from math import asin, cos, radians, sin, sqrt
|
||||
from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.files import File
|
||||
|
||||
from locations.models import GeoLocation
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.notifications import ScrobbleNtfyNotification
|
||||
from trails.models import Trail, TrailLogData
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def haversine(lat1, lon1, lat2, lon2):
|
||||
R = 6371000
|
||||
dlat = radians(lat2 - lat1)
|
||||
dlon = radians(lon2 - lon1)
|
||||
a = sin(dlat / 2) ** 2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon / 2) ** 2
|
||||
return R * 2 * asin(sqrt(a))
|
||||
|
||||
|
||||
def find_route_waypoint(points):
|
||||
if not points:
|
||||
return None
|
||||
total = 0.0
|
||||
for i in range(1, len(points)):
|
||||
lat1, lon1 = points[i - 1][0], points[i - 1][1]
|
||||
lat2, lon2 = points[i][0], points[i][1]
|
||||
if lat1 is not None and lon1 is not None and lat2 is not None and lon2 is not None:
|
||||
total += haversine(lat1, lon1, lat2, lon2)
|
||||
halfway = total / 2.0
|
||||
cumulative = 0.0
|
||||
for i in range(1, len(points)):
|
||||
lat1, lon1 = points[i - 1][0], points[i - 1][1]
|
||||
lat2, lon2 = points[i][0], points[i][1]
|
||||
if lat1 is not None and lon1 is not None and lat2 is not None and lon2 is not None:
|
||||
cumulative += haversine(lat1, lon1, lat2, lon2)
|
||||
if cumulative >= halfway:
|
||||
return (lat2, lon2)
|
||||
return (points[-1][0], points[-1][1])
|
||||
|
||||
|
||||
def compute_trail_stats(points):
|
||||
distance_m = 0.0
|
||||
elevation_gain = 0.0
|
||||
first_time = None
|
||||
last_time = None
|
||||
|
||||
for i in range(1, len(points)):
|
||||
lat1, lon1, ele1, t1 = points[i - 1]
|
||||
lat2, lon2, ele2, t2 = points[i]
|
||||
|
||||
if lat1 is not None and lon1 is not None and lat2 is not None and lon2 is not None:
|
||||
distance_m += haversine(lat1, lon1, lat2, lon2)
|
||||
|
||||
if ele1 is not None and ele2 is not None and ele2 > ele1:
|
||||
elevation_gain += ele2 - ele1
|
||||
|
||||
if first_time is None and t1 is not None:
|
||||
first_time = t1
|
||||
if t2 is not None:
|
||||
last_time = t2
|
||||
|
||||
moving_time = (
|
||||
(last_time - first_time).total_seconds()
|
||||
if first_time and last_time
|
||||
else 0
|
||||
)
|
||||
avg_speed_kmh = (distance_m / 1000) / (moving_time / 3600) if moving_time > 0 else 0.0
|
||||
|
||||
return {
|
||||
"distance_km": round(distance_m / 1000, 2),
|
||||
"elevation_gain_m": round(elevation_gain, 1),
|
||||
"moving_time_seconds": int(moving_time),
|
||||
"avg_speed_kmh": round(avg_speed_kmh, 2),
|
||||
}
|
||||
|
||||
|
||||
def parse_trackpoints(file_path):
|
||||
_, ext = os.path.splitext(file_path)
|
||||
ext = ext.lower()
|
||||
|
||||
if ext == ".gpx":
|
||||
return _parse_gpx(file_path)
|
||||
elif ext == ".fit":
|
||||
return _parse_fit(file_path)
|
||||
else:
|
||||
raise ValueError(f"Unsupported file type: {ext}")
|
||||
|
||||
|
||||
def _parse_gpx(file_path):
|
||||
import gpxpy
|
||||
|
||||
with open(file_path) as f:
|
||||
gpx = gpxpy.parse(f)
|
||||
|
||||
points = []
|
||||
track_name = None
|
||||
description = None
|
||||
extra = {}
|
||||
|
||||
for track in gpx.tracks:
|
||||
if track.name and not track_name:
|
||||
track_name = track.name
|
||||
if track.description and not description:
|
||||
description = track.description
|
||||
if track.comment and not extra:
|
||||
try:
|
||||
cmt_data = json.loads(track.comment)
|
||||
data = cmt_data.get("activity_dict") or cmt_data
|
||||
hr_avg = data.get("average_heartrate")
|
||||
if hr_avg is not None:
|
||||
extra["avg_heartrate"] = int(round(hr_avg))
|
||||
hr_max = data.get("max_heartrate")
|
||||
if hr_max is not None:
|
||||
extra["max_heartrate"] = int(round(hr_max))
|
||||
extra["calories"] = data.get("calories")
|
||||
extra["moving_time_seconds"] = data.get("moving_time")
|
||||
extra["total_elevation_gain_m"] = data.get("total_elevation_gain")
|
||||
avg_speed = data.get("average_speed")
|
||||
if avg_speed is not None:
|
||||
extra["avg_speed_kmh"] = round(avg_speed * 3.6, 2)
|
||||
extra["activity_type"] = cmt_data.get("type") or data.get("type")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
for seg in track.segments:
|
||||
for pt in seg.points:
|
||||
points.append((pt.latitude, pt.longitude, pt.elevation, pt.time))
|
||||
|
||||
return {
|
||||
"points": points,
|
||||
"name": track_name or os.path.splitext(os.path.basename(file_path))[0],
|
||||
"description": description,
|
||||
"extra": extra,
|
||||
}
|
||||
|
||||
|
||||
def _parse_fit(file_path):
|
||||
import fitparse
|
||||
|
||||
fitfile = fitparse.FitFile(file_path)
|
||||
messages = list(fitfile.get_messages("record"))
|
||||
|
||||
points = []
|
||||
track_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
extra = {}
|
||||
|
||||
session_msgs = list(fitfile.get_messages("session"))
|
||||
for msg in session_msgs:
|
||||
for field in msg:
|
||||
if field.name == "sport" and field.value:
|
||||
track_name = str(field.value)
|
||||
elif field.name == "total_distance" and field.value:
|
||||
extra["distance_km"] = round(field.value / 1000, 2)
|
||||
elif field.name == "total_timer_time" and field.value:
|
||||
extra["moving_time_seconds"] = int(field.value)
|
||||
elif field.name == "total_elapsed_time" and field.value:
|
||||
extra.setdefault("moving_time_seconds", int(field.value))
|
||||
elif field.name == "total_ascent" and field.value:
|
||||
extra["total_elevation_gain_m"] = field.value
|
||||
elif field.name == "avg_heart_rate" and field.value:
|
||||
extra["avg_heartrate"] = int(round(field.value))
|
||||
elif field.name == "max_heart_rate" and field.value:
|
||||
extra["max_heartrate"] = int(round(field.value))
|
||||
elif field.name == "avg_speed" and field.value:
|
||||
extra["avg_speed_kmh"] = round(field.value * 3.6, 2)
|
||||
elif field.name == "total_calories" and field.value:
|
||||
extra["calories"] = int(field.value)
|
||||
|
||||
for msg in messages:
|
||||
lat = None
|
||||
lon = None
|
||||
ele = None
|
||||
t = None
|
||||
for field in msg:
|
||||
if field.name == "position_lat":
|
||||
lat = field.value * (180.0 / (2**31)) if field.value else None
|
||||
elif field.name == "position_long":
|
||||
lon = field.value * (180.0 / (2**31)) if field.value else None
|
||||
elif field.name == "altitude":
|
||||
ele = field.value
|
||||
elif field.name == "timestamp":
|
||||
t = field.value.replace(tzinfo=dt_timezone.utc) if field.value else None
|
||||
if lat is not None and lon is not None:
|
||||
points.append((lat, lon, ele, t))
|
||||
|
||||
return {
|
||||
"points": points,
|
||||
"name": track_name,
|
||||
"description": None,
|
||||
"extra": extra,
|
||||
}
|
||||
|
||||
|
||||
def convert_fit_to_gpx(file_path):
|
||||
import gpxpy.gpx
|
||||
|
||||
result = _parse_fit(file_path)
|
||||
points = result["points"]
|
||||
track_name = result["name"]
|
||||
gpx = gpxpy.gpx.GPX()
|
||||
gpx_track = gpxpy.gpx.GPXTrack(name=track_name)
|
||||
gpx.tracks.append(gpx_track)
|
||||
gpx_seg = gpxpy.gpx.GPXTrackSegment()
|
||||
gpx_track.segments.append(gpx_seg)
|
||||
|
||||
for lat, lon, ele, t in points:
|
||||
gpx_seg.points.append(gpxpy.gpx.GPXTrackPoint(lat, lon, elevation=ele, time=t))
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".gpx", delete=False)
|
||||
tmp.write(gpx.to_xml())
|
||||
tmp.close()
|
||||
return tmp.name
|
||||
|
||||
|
||||
def import_trail_gpx(file_path, user_id, original_filename=None):
|
||||
user = User.objects.get(id=user_id)
|
||||
new_scrobbles = []
|
||||
|
||||
result = parse_trackpoints(file_path)
|
||||
points = result["points"]
|
||||
track_name = result["name"]
|
||||
description = result.get("description")
|
||||
extra = result.get("extra", {})
|
||||
|
||||
if not points:
|
||||
logger.warning(f"No trackpoints found in {file_path}")
|
||||
return []
|
||||
|
||||
first_lat, first_lon, _, first_time = points[0]
|
||||
_, _, _, last_time = points[-1]
|
||||
|
||||
if first_time is None:
|
||||
logger.warning(f"No timestamps in {file_path}")
|
||||
return []
|
||||
|
||||
route_pt = find_route_waypoint(points)
|
||||
|
||||
geo, _ = GeoLocation.objects.get_or_create(
|
||||
lat=round(first_lat, 6),
|
||||
lon=round(first_lon, 6),
|
||||
defaults={"altitude": None},
|
||||
)
|
||||
|
||||
trail = Trail.find_by_trailhead(
|
||||
first_lat, first_lon,
|
||||
route_lat=route_pt[0] if route_pt else None,
|
||||
route_lon=route_pt[1] if route_pt else None,
|
||||
tolerance_m=100,
|
||||
)
|
||||
if not trail:
|
||||
trail = Trail.objects.create(
|
||||
title=track_name,
|
||||
trailhead_location=geo,
|
||||
route_lat=route_pt[0] if route_pt else None,
|
||||
route_lon=route_pt[1] if route_pt else None,
|
||||
)
|
||||
|
||||
timestamp = first_time
|
||||
stop_timestamp = last_time
|
||||
|
||||
existing = Scrobble.objects.filter(
|
||||
timestamp=timestamp,
|
||||
trail=trail,
|
||||
user=user,
|
||||
).first()
|
||||
if existing:
|
||||
logger.debug(f"Skipping existing scrobble for trail {trail}")
|
||||
return []
|
||||
|
||||
computed = compute_trail_stats(points)
|
||||
|
||||
logdata = TrailLogData(
|
||||
description=description,
|
||||
distance_km=computed["distance_km"],
|
||||
elevation_gain_m=computed["elevation_gain_m"],
|
||||
moving_time_seconds=computed["moving_time_seconds"],
|
||||
activity_type=extra.get("activity_type"),
|
||||
avg_speed_kmh=computed["avg_speed_kmh"],
|
||||
avg_heartrate=extra.get("avg_heartrate"),
|
||||
max_heartrate=extra.get("max_heartrate"),
|
||||
calories=extra.get("calories"),
|
||||
)
|
||||
|
||||
duration = int((stop_timestamp - timestamp).total_seconds()) if stop_timestamp else 0
|
||||
|
||||
scrobble = Scrobble(
|
||||
user=user,
|
||||
timestamp=timestamp,
|
||||
stop_timestamp=stop_timestamp,
|
||||
playback_position_seconds=duration,
|
||||
source="GPX Import",
|
||||
trail=trail,
|
||||
log=logdata.asdict,
|
||||
timezone=user.profile.timezone or settings.TIME_ZONE,
|
||||
played_to_completion=True,
|
||||
in_progress=False,
|
||||
media_type=Scrobble.MediaType.TRAIL,
|
||||
visibility="private",
|
||||
)
|
||||
|
||||
_, ext = os.path.splitext(file_path)
|
||||
if ext.lower() == ".fit":
|
||||
gpx_path = convert_fit_to_gpx(file_path)
|
||||
with open(gpx_path, "rb") as f:
|
||||
scrobble.gpx_file.save(
|
||||
f"{original_filename or 'trail'}.gpx",
|
||||
File(f),
|
||||
save=False,
|
||||
)
|
||||
os.unlink(gpx_path)
|
||||
else:
|
||||
with open(file_path, "rb") as f:
|
||||
scrobble.gpx_file.save(
|
||||
original_filename or os.path.basename(file_path),
|
||||
File(f),
|
||||
save=False,
|
||||
)
|
||||
|
||||
new_scrobbles.append(scrobble)
|
||||
|
||||
created = Scrobble.objects.bulk_create(new_scrobbles)
|
||||
logger.info(f"Created {len(created)} trail scrobbles")
|
||||
for scrobble in created:
|
||||
ScrobbleNtfyNotification(scrobble).send()
|
||||
return created
|
||||
@ -80,6 +80,7 @@ def import_audioscrobbler_tsv_file(file_path, user_id):
|
||||
in_progress=False,
|
||||
media_type=Scrobble.MediaType.TRACK,
|
||||
timezone=timestamp.tzinfo.name,
|
||||
visibility="private",
|
||||
)
|
||||
existing = Scrobble.objects.filter(
|
||||
timestamp=timestamp, track=track, user=user
|
||||
|
||||
@ -1,81 +1,813 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
|
||||
from books.koreader import fetch_file_from_webdav
|
||||
from profiles.models import UserProfile
|
||||
from scrobbles.models import KoReaderImport
|
||||
from scrobbles.tasks import process_koreader_import
|
||||
from scrobbles.models import KoReaderImport, TrailGPXImport
|
||||
from scrobbles.tasks import process_koreader_import, process_trail_gpx_import
|
||||
from scrobbles.utils import get_file_md5_hash
|
||||
from webdav.client import get_webdav_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_GPX_PATH = "var/gpx/"
|
||||
DEFAULT_KOREADER_PATH = "var/koreader/"
|
||||
DEFAULT_RETROARCH_PATH = "var/retroarch/"
|
||||
DEFAULT_BGSTATS_PATH = "var/bgstats/"
|
||||
DEFAULT_EBIRD_PATH = "var/ebird/"
|
||||
DEFAULT_SCALE_PATH = "var/scale/"
|
||||
|
||||
def import_from_webdav_for_all_users(restart=False):
|
||||
"""Grab a list of all users with WebDAV enabled and kickoff imports for them"""
|
||||
|
||||
# WebDavImport = apps.get_model("scrobbles", "WebDavImport")
|
||||
def import_from_webdav_for_all_users(
|
||||
restart=False,
|
||||
update_retroarch_hash=False,
|
||||
update_koreader_etag=False,
|
||||
include_processed=False,
|
||||
):
|
||||
"""Iterate all WebDAV-enabled users, scanning each media-type directory."""
|
||||
webdav_enabled_user_ids = UserProfile.objects.filter(
|
||||
webdav_url__isnull=False,
|
||||
webdav_user__isnull=False,
|
||||
webdav_pass__isnull=False,
|
||||
webdav_auto_import=True,
|
||||
).values_list("user_id", flat=True)
|
||||
if include_processed:
|
||||
logger.warning(
|
||||
"Re-importing previously-processed files from processed/ subdirectories "
|
||||
"— this may create duplicate scrobbles."
|
||||
)
|
||||
|
||||
logger.info(f"Start import of {webdav_enabled_user_ids.count()} webdav accounts")
|
||||
|
||||
koreader_import_count = 0
|
||||
ko_count = 0
|
||||
gpx_count = 0
|
||||
retro_count = 0
|
||||
bgstats_count = 0
|
||||
ebird_count = 0
|
||||
scale_count = 0
|
||||
|
||||
for user_id in webdav_enabled_user_ids:
|
||||
webdav_client = get_webdav_client(user_id)
|
||||
client = get_webdav_client(user_id)
|
||||
logger.info("Scanning WebDAV for user %s", user_id)
|
||||
|
||||
try:
|
||||
webdav_client.info("var/koreader/statistics.sqlite3")
|
||||
koreader_found = True
|
||||
except:
|
||||
koreader_found = False
|
||||
logger.info("Scanning WebDAV koreader for user %s", user_id)
|
||||
ko_count += scan_webdav_for_koreader(
|
||||
client, user_id, restart, update_etag_only=update_koreader_etag
|
||||
)
|
||||
logger.info("Scanning WebDAV gpx for user %s", user_id)
|
||||
gpx_count += scan_webdav_for_gpx(
|
||||
client, user_id, include_processed=include_processed
|
||||
)
|
||||
logger.info("Scanning WebDAV retroarch for user %s", user_id)
|
||||
retro_count += scan_webdav_for_retroarch(
|
||||
client,
|
||||
user_id,
|
||||
update_hash_only=update_retroarch_hash,
|
||||
include_processed=include_processed,
|
||||
)
|
||||
logger.info("Scanning WebDAV bgstats for user %s", user_id)
|
||||
bgstats_count += scan_webdav_for_bgstats(
|
||||
client, user_id, include_processed=include_processed
|
||||
)
|
||||
logger.info("Scanning WebDAV ebird for user %s", user_id)
|
||||
ebird_count += scan_webdav_for_ebird(
|
||||
client, user_id, include_processed=include_processed
|
||||
)
|
||||
logger.info("Scanning WebDAV scale for user %s", user_id)
|
||||
scale_count += scan_webdav_for_scale(client, user_id)
|
||||
|
||||
logger.info(
|
||||
"Started %d KOReader, %d Trail GPX, %d Retroarch, %d BGStats, %d eBird, %d Scale WebDAV imports",
|
||||
ko_count,
|
||||
gpx_count,
|
||||
retro_count,
|
||||
bgstats_count,
|
||||
ebird_count,
|
||||
scale_count,
|
||||
extra={
|
||||
"koreader": ko_count,
|
||||
"trail_gpx": gpx_count,
|
||||
"retroarch": retro_count,
|
||||
"bgstats": bgstats_count,
|
||||
"ebird": ebird_count,
|
||||
"scale": scale_count,
|
||||
},
|
||||
)
|
||||
return ko_count, gpx_count, retro_count, bgstats_count, ebird_count, scale_count
|
||||
|
||||
|
||||
def scan_webdav_for_koreader(
|
||||
webdav_client, user_id, restart=False, update_etag_only=False
|
||||
):
|
||||
"""Check for koreader statistics.sqlite3 and queue import if changed.
|
||||
|
||||
Uses the remote file's ETag (from a single PROPFIND) to detect changes
|
||||
without downloading the file, making the common no-change case fast.
|
||||
|
||||
When *update_etag_only* is True, stores the remote ETag on the last
|
||||
completed import without re-downloading — useful when migrating to the
|
||||
ETag-based check to avoid a one-time re-import.
|
||||
"""
|
||||
if not update_etag_only:
|
||||
# Don't queue if one is still being processed
|
||||
if KoReaderImport.objects.filter(
|
||||
user_id=user_id, processed_finished__isnull=True
|
||||
).exists():
|
||||
logger.info(
|
||||
"No koreader stats file found on webdav",
|
||||
"KoReader import already pending for user",
|
||||
extra={"user_id": user_id},
|
||||
)
|
||||
return 0
|
||||
|
||||
try:
|
||||
files = webdav_client.list(DEFAULT_KOREADER_PATH, get_info=True)
|
||||
except:
|
||||
logger.info(
|
||||
"No var/koreader/ directory on webdav",
|
||||
extra={"user_id": user_id},
|
||||
)
|
||||
return 0
|
||||
|
||||
sqlite_info = None
|
||||
for f in files:
|
||||
if "statistics.sqlite3" in f.get("path"):
|
||||
sqlite_info = f
|
||||
break
|
||||
|
||||
if not sqlite_info:
|
||||
logger.info(
|
||||
"No statistics.sqlite3 found in var/koreader/",
|
||||
extra={"user_id": user_id},
|
||||
)
|
||||
return 0
|
||||
|
||||
remote_etag = sqlite_info.get("etag") or ""
|
||||
|
||||
last_import = (
|
||||
KoReaderImport.objects.filter(user_id=user_id, processed_finished__isnull=False)
|
||||
.order_by("processed_finished")
|
||||
.last()
|
||||
)
|
||||
|
||||
if update_etag_only:
|
||||
if last_import and remote_etag:
|
||||
last_import.webdav_etag = remote_etag
|
||||
last_import.save(update_fields=["webdav_etag"])
|
||||
logger.info(
|
||||
"Updated KoReader webdav_etag (%s) without re-import",
|
||||
remote_etag[:16],
|
||||
extra={"user_id": user_id, "last_import_id": last_import.id},
|
||||
)
|
||||
return 0
|
||||
|
||||
if last_import and last_import.webdav_etag and remote_etag:
|
||||
if last_import.webdav_etag == remote_etag:
|
||||
logger.info(
|
||||
"koreader stats file unchanged (ETag match)",
|
||||
extra={
|
||||
"user_id": user_id,
|
||||
"etag": remote_etag,
|
||||
"last_import_id": last_import.id,
|
||||
},
|
||||
)
|
||||
return 0
|
||||
|
||||
file_path = fetch_file_from_webdav(user_id)
|
||||
if not file_path:
|
||||
logger.warning(
|
||||
"Could not fetch koreader file from webdav",
|
||||
extra={"user_id": user_id},
|
||||
)
|
||||
return 0
|
||||
|
||||
new_hash = get_file_md5_hash(file_path)
|
||||
old_hash = last_import.file_md5_hash() if last_import else None
|
||||
|
||||
if last_import and not remote_etag and new_hash == old_hash:
|
||||
logger.info(
|
||||
"koreader stats file has not changed (content hash match)",
|
||||
extra={
|
||||
"user_id": user_id,
|
||||
"new_hash": new_hash,
|
||||
"old_hash": old_hash,
|
||||
"last_import_id": last_import.id,
|
||||
},
|
||||
)
|
||||
os.unlink(file_path)
|
||||
return 0
|
||||
|
||||
koreader_import, created = KoReaderImport.objects.get_or_create(
|
||||
user_id=user_id, processed_finished__isnull=True
|
||||
)
|
||||
|
||||
if not created and not restart:
|
||||
logger.info(
|
||||
f"Not resuming failed KoReader import {koreader_import.id} "
|
||||
f"for user {user_id}, use restart=True to restart"
|
||||
)
|
||||
os.unlink(file_path)
|
||||
return 0
|
||||
|
||||
koreader_import.webdav_etag = remote_etag
|
||||
koreader_import.save_sqlite_file_to_self(file_path)
|
||||
process_koreader_import.delay(koreader_import.id)
|
||||
os.unlink(file_path)
|
||||
return 1
|
||||
|
||||
|
||||
def scan_webdav_for_gpx(webdav_client, user_id, include_processed=False):
|
||||
gpx_path = DEFAULT_GPX_PATH # TODO allow this to be configured in user settings
|
||||
try:
|
||||
webdav_client.info(DEFAULT_GPX_PATH)
|
||||
logger.info("var/gpx/ found, listing files")
|
||||
except:
|
||||
logger.info("No var/gpx/ directory on webdav", extra={"user_id": user_id})
|
||||
return 0
|
||||
|
||||
try:
|
||||
files = webdav_client.list(gpx_path)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not list var/gpx/", extra={"user_id": user_id, "error": str(e)}
|
||||
)
|
||||
return 0
|
||||
|
||||
gpx_extensions = (".gpx", ".fit")
|
||||
processed_dir = f"{gpx_path}processed/"
|
||||
try:
|
||||
webdav_client.mkdir(processed_dir, recursive=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
new_imports = 0
|
||||
|
||||
for fname in files:
|
||||
fname = os.path.basename(fname)
|
||||
if not fname.lower().endswith(gpx_extensions):
|
||||
continue
|
||||
if fname == "processed":
|
||||
continue
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname)
|
||||
try:
|
||||
webdav_client.download_sync(
|
||||
remote_path=f"{gpx_path}{fname}", local_path=tmp.name
|
||||
)
|
||||
imp = TrailGPXImport.objects.create(
|
||||
user_id=user_id,
|
||||
original_filename=fname,
|
||||
)
|
||||
with open(tmp.name, "rb") as f:
|
||||
imp.gpx_file.save(fname, f, save=True)
|
||||
|
||||
stem, ext = os.path.splitext(fname)
|
||||
ts = datetime.now().strftime("%Y%m%dT%H%M%S")
|
||||
webdav_client.move(
|
||||
f"{gpx_path}{fname}",
|
||||
f"{processed_dir}{stem}_{ts}{ext}",
|
||||
)
|
||||
|
||||
process_trail_gpx_import.delay(imp.id)
|
||||
new_imports += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to import {fname}: {e}")
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
|
||||
if include_processed:
|
||||
try:
|
||||
processed_files = webdav_client.list(processed_dir)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not list var/gpx/processed/",
|
||||
extra={"user_id": user_id, "error": str(e)},
|
||||
)
|
||||
return new_imports
|
||||
|
||||
for fname in processed_files:
|
||||
fname = os.path.basename(fname)
|
||||
if not fname.lower().endswith(gpx_extensions):
|
||||
continue
|
||||
|
||||
remote_path = f"{processed_dir}{fname}"
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname)
|
||||
try:
|
||||
webdav_client.download_sync(
|
||||
remote_path=remote_path, local_path=tmp.name
|
||||
)
|
||||
imp = TrailGPXImport.objects.create(
|
||||
user_id=user_id,
|
||||
original_filename=fname,
|
||||
)
|
||||
with open(tmp.name, "rb") as f:
|
||||
imp.gpx_file.save(fname, f, save=True)
|
||||
|
||||
process_trail_gpx_import.delay(imp.id)
|
||||
new_imports += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to import processed GPX file {fname}: {e}")
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
|
||||
return new_imports
|
||||
|
||||
|
||||
def scan_webdav_for_retroarch(
|
||||
webdav_client, user_id, update_hash_only=False, include_processed=False
|
||||
):
|
||||
"""Check for new .lrtl files on WebDAV, download+import if changed.
|
||||
|
||||
Uses ETags from a single PROPFIND to detect changes without downloading
|
||||
any file content, making the common no-change case very fast.
|
||||
|
||||
When *update_hash_only* is True, mismatched hashes update the last import's
|
||||
stored hash without re-downloading — useful when migrating to a new hash
|
||||
scheme to avoid a one-time re-import.
|
||||
|
||||
When *include_processed* is True, also imports files from the processed/
|
||||
subdirectory. These are sorted chronologically by their YYYYMMDDHHMM-
|
||||
timestamp prefix so that the importer always sees the earliest snapshot
|
||||
first, which is critical for correct incremental scrobbling since
|
||||
retroarch overwrites the same .lrtl filename.
|
||||
"""
|
||||
import hashlib
|
||||
import shutil
|
||||
import re
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
|
||||
from scrobbles.models import RetroarchImport
|
||||
from scrobbles.tasks import process_retroarch_import
|
||||
|
||||
retroarch_path = DEFAULT_RETROARCH_PATH
|
||||
processed_dir = f"{retroarch_path}processed/"
|
||||
try:
|
||||
webdav_client.info(retroarch_path)
|
||||
except:
|
||||
logger.info("No var/retroarch/ directory on webdav", extra={"user_id": user_id})
|
||||
return 0
|
||||
|
||||
try:
|
||||
webdav_client.mkdir(processed_dir, recursive=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Collect files from root directory
|
||||
try:
|
||||
root_files = webdav_client.list(retroarch_path, get_info=True)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not list var/retroarch/",
|
||||
extra={"user_id": user_id, "error": str(e)},
|
||||
)
|
||||
return 0
|
||||
|
||||
# Extract basename from path since the webdav adapter returns name=None
|
||||
for f in root_files:
|
||||
if f.get("path") and not f.get("name"):
|
||||
f["name"] = os.path.basename(f["path"])
|
||||
|
||||
lrtl_files = [
|
||||
f for f in root_files
|
||||
if (f.get("name") or "").lower().endswith(".lrtl")
|
||||
]
|
||||
|
||||
# Optionally include historical files from processed/
|
||||
if include_processed:
|
||||
try:
|
||||
processed_files = webdav_client.list(processed_dir, get_info=True)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not list var/retroarch/processed/",
|
||||
extra={"user_id": user_id, "error": str(e)},
|
||||
)
|
||||
else:
|
||||
for f in processed_files:
|
||||
if f.get("path") and not f.get("name"):
|
||||
f["name"] = os.path.basename(f["path"])
|
||||
lrtl_files.extend([
|
||||
f for f in processed_files
|
||||
if (f.get("name") or "").lower().endswith(".lrtl")
|
||||
])
|
||||
|
||||
if not lrtl_files:
|
||||
logger.info("No .lrtl files found on webdav", extra={"user_id": user_id})
|
||||
return 0
|
||||
|
||||
# Sort chronologically: processed files (timestamp prefixed) first,
|
||||
# current root files last.
|
||||
ts_pattern = re.compile(r"^(\d{12})-")
|
||||
def sort_key(f):
|
||||
name = f.get("name") or ""
|
||||
m = ts_pattern.match(name)
|
||||
if m:
|
||||
return m.group(1)
|
||||
# Root files (no timestamp) go after all historical snapshots
|
||||
return "999999999999"
|
||||
|
||||
lrtl_files.sort(key=sort_key)
|
||||
|
||||
# Don't queue if one is still being processed
|
||||
if RetroarchImport.objects.filter(
|
||||
user_id=user_id, processed_finished__isnull=True
|
||||
).exists():
|
||||
logger.info(
|
||||
"Retroarch import already pending for user",
|
||||
extra={"user_id": user_id},
|
||||
)
|
||||
return 0
|
||||
|
||||
# Compute hash from filenames + ETags (no downloads needed)
|
||||
hasher = hashlib.md5()
|
||||
for f in lrtl_files:
|
||||
hasher.update((f.get("name") or "").encode())
|
||||
hasher.update((f.get("etag") or f.get("modified") or "").encode())
|
||||
content_hash = hasher.hexdigest()
|
||||
|
||||
last_import = (
|
||||
RetroarchImport.objects.filter(
|
||||
user_id=user_id, processed_finished__isnull=False
|
||||
)
|
||||
.order_by("-processed_finished")
|
||||
.first()
|
||||
)
|
||||
|
||||
# Skip unchanged root files only, never skip when re-processing history
|
||||
if not include_processed and last_import and last_import.files_hash == content_hash:
|
||||
logger.info(
|
||||
"Retroarch lrtl files unchanged for user, skipping",
|
||||
extra={"user_id": user_id},
|
||||
)
|
||||
return 0
|
||||
|
||||
if update_hash_only and last_import:
|
||||
logger.info(
|
||||
"Updating retroarch files_hash (%s) without re-import",
|
||||
content_hash[:12],
|
||||
extra={"user_id": user_id},
|
||||
)
|
||||
last_import.files_hash = content_hash
|
||||
last_import.save(update_fields=["files_hash"])
|
||||
return 0
|
||||
|
||||
# Something changed — download everything
|
||||
download_dir = tempfile.mkdtemp()
|
||||
try:
|
||||
downloaded = []
|
||||
imported_filenames = []
|
||||
for f in lrtl_files:
|
||||
basename = f.get("name")
|
||||
# Determine source directory: processed files already live under processed/
|
||||
is_processed = ts_pattern.match(basename)
|
||||
source_dir = processed_dir if is_processed else retroarch_path
|
||||
|
||||
remote_path = f"{source_dir}{basename}"
|
||||
dst = os.path.join(download_dir, basename)
|
||||
try:
|
||||
webdav_client.download_sync(
|
||||
remote_path=remote_path,
|
||||
local_path=dst,
|
||||
)
|
||||
downloaded.append(basename)
|
||||
|
||||
if is_processed:
|
||||
imported_filenames.append(basename)
|
||||
else:
|
||||
ts = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
processed_name = f"{ts}-{basename}"
|
||||
imported_filenames.append(processed_name)
|
||||
webdav_client.move(
|
||||
remote_path,
|
||||
f"{processed_dir}{processed_name}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to download retroarch %s: %s", basename, e)
|
||||
|
||||
if not downloaded:
|
||||
return 0
|
||||
|
||||
zip_path = os.path.join(
|
||||
download_dir,
|
||||
f"retroarch-batch-{datetime.now().strftime('%Y%m%d%H%M%S')}.zip",
|
||||
)
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for basename in downloaded:
|
||||
zf.write(os.path.join(download_dir, basename), basename)
|
||||
|
||||
imp = RetroarchImport.objects.create(
|
||||
user_id=user_id,
|
||||
original_filename=", ".join(imported_filenames),
|
||||
files_hash=content_hash,
|
||||
)
|
||||
with open(zip_path, "rb") as f:
|
||||
imp.lrtl_file.save(f"retroarch-batch-{imp.uuid}.zip", f, save=True)
|
||||
process_retroarch_import.delay(imp.id)
|
||||
logger.info(
|
||||
"Queued retroarch import %s with %d file(s) (hash=%s)",
|
||||
imp.uuid,
|
||||
len(downloaded),
|
||||
content_hash[:12],
|
||||
)
|
||||
return 1
|
||||
finally:
|
||||
shutil.rmtree(download_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def scan_webdav_for_bgstats(webdav_client, user_id, include_processed=False):
|
||||
"""Download .bgsplay files from WebDAV and queue imports for new files.
|
||||
|
||||
After importing, files are moved to var/bgstats/processed/ so they are
|
||||
not re-imported on subsequent scans unless *include_processed* is True.
|
||||
"""
|
||||
from scrobbles.models import BGStatsImport
|
||||
from scrobbles.tasks import process_bgstats_import
|
||||
|
||||
bgstats_path = DEFAULT_BGSTATS_PATH
|
||||
try:
|
||||
webdav_client.info(bgstats_path)
|
||||
except:
|
||||
logger.info("No var/bgstats/ directory on webdav", extra={"user_id": user_id})
|
||||
return 0
|
||||
|
||||
try:
|
||||
files = webdav_client.list(bgstats_path)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not list var/bgstats/",
|
||||
extra={"user_id": user_id, "error": str(e)},
|
||||
)
|
||||
return 0
|
||||
|
||||
processed_dir = f"{bgstats_path}processed/"
|
||||
try:
|
||||
webdav_client.mkdir(processed_dir, recursive=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
new_imports = 0
|
||||
already_imported = set(
|
||||
BGStatsImport.objects.filter(user_id=user_id).values_list(
|
||||
"original_filename", flat=True
|
||||
)
|
||||
)
|
||||
|
||||
for fname in files:
|
||||
fname = os.path.basename(fname)
|
||||
if not fname.lower().endswith(".bgsplay"):
|
||||
continue
|
||||
if fname == "processed":
|
||||
continue
|
||||
if fname in already_imported:
|
||||
logger.debug(f"Skipping already-imported {fname}")
|
||||
continue
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname)
|
||||
try:
|
||||
webdav_client.download_sync(
|
||||
remote_path=f"{bgstats_path}{fname}", local_path=tmp.name
|
||||
)
|
||||
imp = BGStatsImport.objects.create(
|
||||
user_id=user_id,
|
||||
original_filename=fname,
|
||||
)
|
||||
with open(tmp.name, "rb") as f:
|
||||
imp.bgsplay_file.save(fname, f, save=True)
|
||||
|
||||
stem, ext = os.path.splitext(fname)
|
||||
ts = datetime.now().strftime("%Y%m%dT%H%M%S")
|
||||
webdav_client.move(
|
||||
f"{bgstats_path}{fname}",
|
||||
f"{processed_dir}{stem}_{ts}{ext}",
|
||||
)
|
||||
|
||||
process_bgstats_import.delay(imp.id)
|
||||
new_imports += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to import BG Stats file {fname}: {e}")
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
|
||||
if include_processed:
|
||||
try:
|
||||
processed_files = webdav_client.list(processed_dir)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not list var/bgstats/processed/",
|
||||
extra={"user_id": user_id, "error": str(e)},
|
||||
)
|
||||
return new_imports
|
||||
|
||||
for fname in processed_files:
|
||||
fname = os.path.basename(fname)
|
||||
if not fname.lower().endswith(".bgsplay"):
|
||||
continue
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname)
|
||||
try:
|
||||
webdav_client.download_sync(
|
||||
remote_path=f"{processed_dir}{fname}", local_path=tmp.name
|
||||
)
|
||||
imp = BGStatsImport.objects.create(
|
||||
user_id=user_id,
|
||||
original_filename=fname,
|
||||
)
|
||||
with open(tmp.name, "rb") as f:
|
||||
imp.bgsplay_file.save(fname, f, save=True)
|
||||
process_bgstats_import.delay(imp.id)
|
||||
new_imports += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to import processed BG Stats file {fname}: {e}")
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
|
||||
return new_imports
|
||||
|
||||
|
||||
def scan_webdav_for_ebird(webdav_client, user_id, include_processed=False):
|
||||
"""Download .csv files from WebDAV var/ebird/ and queue imports for new files.
|
||||
|
||||
After importing, files are moved to var/ebird/processed/ so they are
|
||||
not re-imported on subsequent scans unless *include_processed* is True.
|
||||
"""
|
||||
from scrobbles.models import EBirdCSVImport
|
||||
from scrobbles.tasks import process_ebird_csv_import
|
||||
|
||||
ebird_path = DEFAULT_EBIRD_PATH
|
||||
try:
|
||||
webdav_client.info(ebird_path)
|
||||
except:
|
||||
logger.info("No var/ebird/ directory on webdav", extra={"user_id": user_id})
|
||||
return 0
|
||||
|
||||
try:
|
||||
files = webdav_client.list(ebird_path)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not list var/ebird/",
|
||||
extra={"user_id": user_id, "error": str(e)},
|
||||
)
|
||||
return 0
|
||||
|
||||
processed_dir = f"{ebird_path}processed/"
|
||||
try:
|
||||
webdav_client.mkdir(processed_dir, recursive=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
new_imports = 0
|
||||
already_imported = set(
|
||||
EBirdCSVImport.objects.filter(user_id=user_id).values_list(
|
||||
"original_filename", flat=True
|
||||
)
|
||||
)
|
||||
|
||||
for fname in files:
|
||||
fname = os.path.basename(fname)
|
||||
if not fname.lower().endswith(".csv"):
|
||||
continue
|
||||
if fname == "processed":
|
||||
continue
|
||||
if fname in already_imported:
|
||||
logger.debug(f"Skipping already-imported {fname}")
|
||||
continue
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname)
|
||||
try:
|
||||
webdav_client.download_sync(
|
||||
remote_path=f"{ebird_path}{fname}", local_path=tmp.name
|
||||
)
|
||||
imp = EBirdCSVImport.objects.create(
|
||||
user_id=user_id,
|
||||
original_filename=fname,
|
||||
)
|
||||
with open(tmp.name, "rb") as f:
|
||||
imp.csv_file.save(fname, f, save=True)
|
||||
|
||||
stem, ext = os.path.splitext(fname)
|
||||
ts = datetime.now().strftime("%Y%m%dT%H%M%S")
|
||||
webdav_client.move(
|
||||
f"{ebird_path}{fname}",
|
||||
f"{processed_dir}{stem}_{ts}{ext}",
|
||||
)
|
||||
|
||||
process_ebird_csv_import.delay(imp.id)
|
||||
new_imports += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to import eBird CSV file {fname}: {e}")
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
|
||||
if include_processed:
|
||||
try:
|
||||
processed_files = webdav_client.list(processed_dir)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not list var/ebird/processed/",
|
||||
extra={"user_id": user_id, "error": str(e)},
|
||||
)
|
||||
return new_imports
|
||||
|
||||
for fname in processed_files:
|
||||
fname = os.path.basename(fname)
|
||||
if not fname.lower().endswith(".csv"):
|
||||
continue
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname)
|
||||
try:
|
||||
webdav_client.download_sync(
|
||||
remote_path=f"{processed_dir}{fname}", local_path=tmp.name
|
||||
)
|
||||
imp = EBirdCSVImport.objects.create(
|
||||
user_id=user_id,
|
||||
original_filename=fname,
|
||||
)
|
||||
with open(tmp.name, "rb") as f:
|
||||
imp.csv_file.save(fname, f, save=True)
|
||||
process_ebird_csv_import.delay(imp.id)
|
||||
new_imports += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to import processed eBird CSV file {fname}: {e}")
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
|
||||
return new_imports
|
||||
|
||||
|
||||
def scan_webdav_for_scale(webdav_client, user_id):
|
||||
"""Download .csv files from WebDAV var/scale/ and queue imports for new files.
|
||||
|
||||
Because the scale CSV always has the same filename but grows by appending
|
||||
rows, we detect changes by downloading the file and comparing its content
|
||||
hash against the last completed import, rather than checking the filename.
|
||||
"""
|
||||
from scrobbles.models import ScaleCSVImport
|
||||
from scrobbles.tasks import process_scale_csv_import
|
||||
|
||||
scale_path = (
|
||||
DEFAULT_SCALE_PATH # TODO Allow this to be configured in a user profile setting
|
||||
)
|
||||
try:
|
||||
webdav_client.info(scale_path)
|
||||
except:
|
||||
logger.info("No var/scale/ directory on webdav", extra={"user_id": user_id})
|
||||
return 0
|
||||
|
||||
try:
|
||||
files = webdav_client.list(scale_path)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Could not list var/scale/",
|
||||
extra={"user_id": user_id, "error": str(e)},
|
||||
)
|
||||
return 0
|
||||
|
||||
new_imports = 0
|
||||
|
||||
for fname in files:
|
||||
fname = os.path.basename(fname)
|
||||
if not fname.lower().endswith(".csv"):
|
||||
continue
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=fname)
|
||||
try:
|
||||
webdav_client.download_sync(
|
||||
remote_path=f"{scale_path}/{fname}", local_path=tmp.name
|
||||
)
|
||||
|
||||
new_hash = get_file_md5_hash(tmp.name)
|
||||
|
||||
if koreader_found:
|
||||
last_import = (
|
||||
KoReaderImport.objects.filter(
|
||||
user_id=user_id, processed_finished__isnull=False
|
||||
ScaleCSVImport.objects.filter(
|
||||
user_id=user_id,
|
||||
original_filename=fname,
|
||||
processed_finished__isnull=False,
|
||||
)
|
||||
.order_by("processed_finished")
|
||||
.last()
|
||||
.order_by("-processed_finished")
|
||||
.first()
|
||||
)
|
||||
|
||||
koreader_file_path = fetch_file_from_webdav(1)
|
||||
new_hash = get_file_md5_hash(koreader_file_path)
|
||||
old_hash = None
|
||||
if last_import:
|
||||
old_hash = last_import.file_md5_hash()
|
||||
|
||||
if old_hash and new_hash == old_hash:
|
||||
logger.info(
|
||||
"koreader stats file has not changed",
|
||||
extra={
|
||||
"user_id": user_id,
|
||||
"new_hash": new_hash,
|
||||
"old_hash": old_hash,
|
||||
"last_import_id": last_import.id,
|
||||
},
|
||||
if last_import and last_import.file_hash == new_hash:
|
||||
logger.debug(
|
||||
"Scale CSV %s unchanged (hash match), skipping",
|
||||
fname,
|
||||
extra={"user_id": user_id, "hash": new_hash},
|
||||
)
|
||||
continue
|
||||
|
||||
koreader_import, created = KoReaderImport.objects.get_or_create(
|
||||
user_id=user_id, processed_finished__isnull=True
|
||||
imp = ScaleCSVImport.objects.create(
|
||||
user_id=user_id,
|
||||
original_filename=fname,
|
||||
file_hash=new_hash,
|
||||
)
|
||||
with open(tmp.name, "rb") as f:
|
||||
imp.csv_file.save(fname, f, save=True)
|
||||
process_scale_csv_import.delay(imp.id)
|
||||
new_imports += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to import Scale CSV file {fname}: {e}")
|
||||
finally:
|
||||
os.unlink(tmp.name)
|
||||
|
||||
if not created and not restart:
|
||||
logger.info(
|
||||
f"Not resuming failed KoReader import {koreader_import.id} for user {user_id}, use restart=True to restart"
|
||||
)
|
||||
continue
|
||||
|
||||
koreader_import.save_sqlite_file_to_self(koreader_file_path)
|
||||
|
||||
process_koreader_import.delay(koreader_import.id)
|
||||
koreader_import_count += 1
|
||||
return koreader_import_count
|
||||
return new_imports
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import models
|
||||
|
||||
from scrobbles.models import Scrobble
|
||||
from scrobbles.utils import analyze_scrobble_sentiment
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Backfill VADER sentiment analysis for scrobbles with notes"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--commit",
|
||||
action="store_true",
|
||||
help="Actually update scrobble logs with sentiment data",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--overwrite",
|
||||
action="store_true",
|
||||
help="Re-analyze scrobbles that already have sentiment data",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
commit = options["commit"]
|
||||
overwrite = options["overwrite"]
|
||||
|
||||
qs = Scrobble.objects.filter(
|
||||
~models.Q(log__notes__isnull=True)
|
||||
& ~models.Q(log__notes=[])
|
||||
& ~models.Q(log__notes={})
|
||||
)
|
||||
if not overwrite:
|
||||
qs = qs.filter(log__sentiment__isnull=True)
|
||||
|
||||
total = qs.count()
|
||||
analyzed_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
self.stdout.write(f"Found {total} scrobbles to process")
|
||||
|
||||
for scrobble in qs.iterator():
|
||||
if commit:
|
||||
analyzed = analyze_scrobble_sentiment(scrobble, overwrite=overwrite)
|
||||
else:
|
||||
notes_str = ""
|
||||
if scrobble.logdata:
|
||||
notes_str = scrobble.logdata.notes_as_str()
|
||||
analyzed = bool(notes_str)
|
||||
|
||||
if analyzed:
|
||||
analyzed_count += 1
|
||||
if commit:
|
||||
scores = (scrobble.log or {}).get("sentiment", {})
|
||||
self.stdout.write(
|
||||
f" Updated scrobble {scrobble.id}: compound={scores.get('compound', 'N/A')}"
|
||||
)
|
||||
else:
|
||||
self.stdout.write(
|
||||
f" [DRY RUN] Would analyze scrobble {scrobble.id}"
|
||||
)
|
||||
else:
|
||||
skipped_count += 1
|
||||
|
||||
self.stdout.write(
|
||||
f"\nAnalyzed {analyzed_count} scrobbles, skipped {skipped_count}"
|
||||
)
|
||||
if not commit:
|
||||
self.stdout.write("Run with --commit to persist changes")
|
||||
@ -0,0 +1,7 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from vrobbler.apps.scrobbles.tasks import backup_database
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
def handle(self, *args, **options):
|
||||
backup_database()
|
||||
@ -1,9 +1,7 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from vrobbler.apps.tasks.utils import (
|
||||
convert_notes_to_dict,
|
||||
convert_old_boardgame_log_to_new,
|
||||
convert_old_orgmode_log_to_new,
|
||||
convert_old_todoist_log_to_new,
|
||||
convert_tasks_notes_list_to_dict,
|
||||
)
|
||||
|
||||
|
||||
@ -21,7 +19,5 @@ class Command(BaseCommand):
|
||||
commit = True
|
||||
else:
|
||||
print("No changes will be saved, use --commit to save")
|
||||
convert_old_orgmode_log_to_new(commit)
|
||||
convert_old_todoist_log_to_new(commit)
|
||||
convert_notes_to_dict(commit)
|
||||
convert_tasks_notes_list_to_dict(commit)
|
||||
convert_old_boardgame_log_to_new(commit)
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from vrobbler.apps.scrobbles.importers import webdav
|
||||
|
||||
|
||||
@ -9,10 +10,35 @@ class Command(BaseCommand):
|
||||
action="store_true",
|
||||
help="Restart failed imports",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--update-retroarch-hash",
|
||||
action="store_true",
|
||||
help="Update retroarch files_hash to new ETag-based scheme "
|
||||
"without re-importing (migration helper)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--update-koreader-etag",
|
||||
action="store_true",
|
||||
help="Store current WebDAV ETag on last KoReader import "
|
||||
"without re-importing (migration helper)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-processed",
|
||||
action="store_true",
|
||||
help="Also import files already moved to processed/ subdirectories "
|
||||
"(may produce duplicate scrobbles; use with care on production)",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
restart = False
|
||||
if options["restart"]:
|
||||
restart = True
|
||||
count = webdav.import_from_webdav_for_all_users(restart=restart)
|
||||
print(f"Started {count} WeDAV imports")
|
||||
update_hash = options.get("update_retroarch_hash", False)
|
||||
update_etag = options.get("update_koreader_etag", False)
|
||||
include_processed = options.get("include_processed", False)
|
||||
webdav.import_from_webdav_for_all_users(
|
||||
restart=restart,
|
||||
update_retroarch_hash=update_hash,
|
||||
update_koreader_etag=update_etag,
|
||||
include_processed=include_processed,
|
||||
)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@ -11,11 +11,17 @@ class Command(BaseCommand):
|
||||
type=int,
|
||||
help="User ID to import historical scrobbles for",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--async",
|
||||
action="store_true",
|
||||
dest="async",
|
||||
help="Dispatch the task to Celery instead of running inline",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from scrobbles.models import LastFmImport
|
||||
from scrobbles.importers.lastfm import dispatch_historical_imports
|
||||
|
||||
user_id = options.get("user_id")
|
||||
user_id = options["user_id"]
|
||||
user = User.objects.filter(id=user_id).first()
|
||||
|
||||
if not user:
|
||||
@ -29,17 +35,15 @@ class Command(BaseCommand):
|
||||
)
|
||||
return
|
||||
|
||||
lfm_import = LastFmImport.objects.create(
|
||||
user=user,
|
||||
earliest_timestamp=None,
|
||||
last_batch_imported=None,
|
||||
import_in_progress=False,
|
||||
)
|
||||
|
||||
lfm_import.start_historical_import()
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Started historical LastFM import for {user} (import ID: {lfm_import.id})"
|
||||
if options["async"]:
|
||||
dispatch_historical_imports.delay(user_id)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f"Dispatched historical import task for user {user}")
|
||||
)
|
||||
else:
|
||||
count = dispatch_historical_imports(user_id)
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"Dispatched {count} monthly batch(es) for user {user}"
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
70
vrobbler/apps/scrobbles/migrations/0077_scalecsvimport.py
Normal file
70
vrobbler/apps/scrobbles/migrations/0077_scalecsvimport.py
Normal file
@ -0,0 +1,70 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-21 12:55
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import scrobbles.models
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("scrobbles", "0076_scrobble_birding_location_alter_scrobble_media_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ScaleCSVImport",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
("uuid", models.UUIDField(default=uuid.uuid4, editable=False)),
|
||||
("processing_started", models.DateTimeField(blank=True, null=True)),
|
||||
("processed_finished", models.DateTimeField(blank=True, null=True)),
|
||||
("process_log", models.TextField(blank=True, null=True)),
|
||||
("process_count", models.IntegerField(blank=True, null=True)),
|
||||
(
|
||||
"csv_file",
|
||||
models.FileField(
|
||||
blank=True,
|
||||
null=True,
|
||||
upload_to=scrobbles.models.ScaleCSVImport.get_path,
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Scale CSV Import",
|
||||
},
|
||||
),
|
||||
]
|
||||
74
vrobbler/apps/scrobbles/migrations/0078_trailgpximport.py
Normal file
74
vrobbler/apps/scrobbles/migrations/0078_trailgpximport.py
Normal file
@ -0,0 +1,74 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-21 14:36
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import scrobbles.models
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("scrobbles", "0077_scalecsvimport"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="TrailGPXImport",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
("uuid", models.UUIDField(default=uuid.uuid4, editable=False)),
|
||||
("processing_started", models.DateTimeField(blank=True, null=True)),
|
||||
("processed_finished", models.DateTimeField(blank=True, null=True)),
|
||||
("process_log", models.TextField(blank=True, null=True)),
|
||||
("process_count", models.IntegerField(blank=True, null=True)),
|
||||
(
|
||||
"gpx_file",
|
||||
models.FileField(
|
||||
blank=True,
|
||||
null=True,
|
||||
upload_to=scrobbles.models.TrailGPXImport.get_path,
|
||||
),
|
||||
),
|
||||
(
|
||||
"original_filename",
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Trail GPX Import",
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,25 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-22 15:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0078_trailgpximport"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="retroarchimport",
|
||||
name="lrtl_file",
|
||||
field=models.FileField(
|
||||
blank=True, null=True, upload_to="scrobbles/lrtl_file/"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="retroarchimport",
|
||||
name="original_filename",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-22 16:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0079_retroarchimport_lrtl_file"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="retroarchimport",
|
||||
name="files_hash",
|
||||
field=models.CharField(blank=True, max_length=64, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,74 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-23 20:54
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import scrobbles.models
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("scrobbles", "0080_retroarchimport_files_hash"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="BGStatsImport",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
("uuid", models.UUIDField(default=uuid.uuid4, editable=False)),
|
||||
("processing_started", models.DateTimeField(blank=True, null=True)),
|
||||
("processed_finished", models.DateTimeField(blank=True, null=True)),
|
||||
("process_log", models.TextField(blank=True, null=True)),
|
||||
("process_count", models.IntegerField(blank=True, null=True)),
|
||||
(
|
||||
"bgsplay_file",
|
||||
models.FileField(
|
||||
blank=True,
|
||||
null=True,
|
||||
upload_to=scrobbles.models.BGStatsImport.get_path,
|
||||
),
|
||||
),
|
||||
(
|
||||
"original_filename",
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "BG Stats Import",
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,75 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-23 21:24
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import scrobbles.models
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("scrobbles", "0081_bgstats_import_model"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="EBirdCSVImport",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
("uuid", models.UUIDField(default=uuid.uuid4, editable=False)),
|
||||
("processing_started", models.DateTimeField(blank=True, null=True)),
|
||||
("processed_finished", models.DateTimeField(blank=True, null=True)),
|
||||
("process_log", models.TextField(blank=True, null=True)),
|
||||
("process_count", models.IntegerField(blank=True, null=True)),
|
||||
(
|
||||
"csv_file",
|
||||
models.FileField(
|
||||
blank=True,
|
||||
null=True,
|
||||
upload_to=scrobbles.models.EBirdCSVImport.get_path,
|
||||
),
|
||||
),
|
||||
(
|
||||
"original_filename",
|
||||
models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.DO_NOTHING,
|
||||
related_name="scrobbles_ebirdcsvimport_set",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "eBird CSV Import",
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,36 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-23 21:24
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def copy_birding_data(apps, schema_editor):
|
||||
OldImport = apps.get_model("birds", "BirdingCSVImport")
|
||||
NewImport = apps.get_model("scrobbles", "EBirdCSVImport")
|
||||
db_alias = schema_editor.connection.alias
|
||||
|
||||
for old in OldImport.objects.using(db_alias).iterator():
|
||||
NewImport.objects.using(db_alias).create(
|
||||
id=old.id,
|
||||
created=old.created,
|
||||
modified=old.modified,
|
||||
uuid=old.uuid,
|
||||
user=old.user,
|
||||
processing_started=old.processing_started,
|
||||
processed_finished=old.processed_finished,
|
||||
process_log=old.process_log,
|
||||
process_count=old.process_count,
|
||||
csv_file=old.csv_file,
|
||||
original_filename=(
|
||||
old.csv_file.name.rpartition("/")[-1] if old.csv_file else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("scrobbles", "0082_ebird_csv_import_model"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(copy_birding_data, migrations.RunPython.noop),
|
||||
]
|
||||
@ -0,0 +1,30 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def reset_sequence(apps, schema_editor):
|
||||
from django.db import connection
|
||||
|
||||
with connection.cursor() as cursor:
|
||||
vendor = connection.vendor
|
||||
if vendor == "postgresql":
|
||||
cursor.execute(
|
||||
"SELECT setval('scrobbles_ebirdcsvimport_id_seq', "
|
||||
"COALESCE((SELECT MAX(id) + 1 FROM scrobbles_ebirdcsvimport), 1), "
|
||||
"false)"
|
||||
)
|
||||
elif vendor == "sqlite":
|
||||
cursor.execute(
|
||||
"UPDATE sqlite_sequence SET seq = ("
|
||||
"SELECT COALESCE(MAX(id), 0) FROM scrobbles_ebirdcsvimport"
|
||||
") WHERE name = 'scrobbles_ebirdcsvimport'"
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("scrobbles", "0083_migrate_birding_data"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(reset_sequence, migrations.RunPython.noop),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-24 15:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0084_fix_ebird_sequence"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="scalecsvimport",
|
||||
name="original_filename",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-24 19:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0085_scale_csv_import_original_filename"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="lastfmimport",
|
||||
name="import_in_progress",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-26 13:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0086_add_import_in_progress_to_lastfmimport"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="koreaderimport",
|
||||
name="webdav_etag",
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.29 on 2026-05-28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0087_koreaderimport_webdav_etag"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="scalecsvimport",
|
||||
name="file_hash",
|
||||
field=models.CharField(blank=True, max_length=32, null=True),
|
||||
),
|
||||
]
|
||||
287
vrobbler/apps/scrobbles/migrations/0089_favoritemedia.py
Normal file
287
vrobbler/apps/scrobbles/migrations/0089_favoritemedia.py
Normal file
@ -0,0 +1,287 @@
|
||||
# Generated by Django 4.2.30 on 2026-06-05 14:55
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django_extensions.db.fields
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("boardgames", "0015_alter_boardgame_genre"),
|
||||
("trails", "0009_trail_route_waypoint"),
|
||||
("moods", "0008_alter_mood_genre"),
|
||||
("birds", "0002_birdingcsvimport"),
|
||||
("bricksets", "0005_alter_brickset_genre"),
|
||||
("music", "0036_artist_similar_artists"),
|
||||
("puzzles", "0006_alter_puzzle_genre"),
|
||||
("videogames", "0015_alter_videogame_genre"),
|
||||
("lifeevents", "0005_alter_lifeevent_genre"),
|
||||
("beers", "0008_alter_beer_genre"),
|
||||
("foods", "0007_alter_food_genre"),
|
||||
("tasks", "0007_alter_task_genre"),
|
||||
("books", "0036_alter_book_genre_alter_paper_genre"),
|
||||
("videos", "0030_alter_channel_genre_alter_series_genre_and_more"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("podcasts", "0020_alter_podcast_genre_alter_podcastepisode_genre"),
|
||||
("webpages", "0009_alter_webpage_genre"),
|
||||
("sports", "0018_alter_sportevent_genre"),
|
||||
("locations", "0010_clean_start"),
|
||||
("scrobbles", "0088_scalecsvimport_file_hash"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="FavoriteMedia",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"created",
|
||||
django_extensions.db.fields.CreationDateTimeField(
|
||||
auto_now_add=True, verbose_name="created"
|
||||
),
|
||||
),
|
||||
(
|
||||
"modified",
|
||||
django_extensions.db.fields.ModificationDateTimeField(
|
||||
auto_now=True, verbose_name="modified"
|
||||
),
|
||||
),
|
||||
(
|
||||
"uuid",
|
||||
models.UUIDField(
|
||||
blank=True, default=uuid.uuid4, editable=False, null=True
|
||||
),
|
||||
),
|
||||
(
|
||||
"media_type",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("Video", "Video"),
|
||||
("Track", "Track"),
|
||||
("PodcastEpisode", "Podcast episode"),
|
||||
("SportEvent", "Sport event"),
|
||||
("Book", "Book"),
|
||||
("Paper", "Paper"),
|
||||
("VideoGame", "Video game"),
|
||||
("BoardGame", "Board game"),
|
||||
("GeoLocation", "GeoLocation"),
|
||||
("Trail", "Trail"),
|
||||
("Beer", "Beer"),
|
||||
("Puzzle", "Puzzle"),
|
||||
("Food", "Food"),
|
||||
("Task", "Task"),
|
||||
("WebPage", "Web Page"),
|
||||
("LifeEvent", "Life event"),
|
||||
("Mood", "Mood"),
|
||||
("BrickSet", "Brick set"),
|
||||
("Channel", "Channel"),
|
||||
("BirdingLocation", "Birding location"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
("sent_to_mopidy", models.BooleanField(default=False)),
|
||||
(
|
||||
"beer",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="beers.beer",
|
||||
),
|
||||
),
|
||||
(
|
||||
"birding_location",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="birds.birdinglocation",
|
||||
),
|
||||
),
|
||||
(
|
||||
"board_game",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="boardgames.boardgame",
|
||||
),
|
||||
),
|
||||
(
|
||||
"book",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="books.book",
|
||||
),
|
||||
),
|
||||
(
|
||||
"brick_set",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="bricksets.brickset",
|
||||
),
|
||||
),
|
||||
(
|
||||
"channel",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="videos.channel",
|
||||
),
|
||||
),
|
||||
(
|
||||
"food",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="foods.food",
|
||||
),
|
||||
),
|
||||
(
|
||||
"geo_location",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="locations.geolocation",
|
||||
),
|
||||
),
|
||||
(
|
||||
"life_event",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="lifeevents.lifeevent",
|
||||
),
|
||||
),
|
||||
(
|
||||
"mood",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="moods.mood",
|
||||
),
|
||||
),
|
||||
(
|
||||
"paper",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="books.paper",
|
||||
),
|
||||
),
|
||||
(
|
||||
"podcast_episode",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="podcasts.podcastepisode",
|
||||
),
|
||||
),
|
||||
(
|
||||
"puzzle",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="puzzles.puzzle",
|
||||
),
|
||||
),
|
||||
(
|
||||
"sport_event",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="sports.sportevent",
|
||||
),
|
||||
),
|
||||
(
|
||||
"task",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="tasks.task",
|
||||
),
|
||||
),
|
||||
(
|
||||
"track",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="music.track",
|
||||
),
|
||||
),
|
||||
(
|
||||
"trail",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="trails.trail",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
(
|
||||
"video",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="videos.video",
|
||||
),
|
||||
),
|
||||
(
|
||||
"video_game",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="videogames.videogame",
|
||||
),
|
||||
),
|
||||
(
|
||||
"web_page",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="webpages.webpage",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-created"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,53 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-08 14:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0089_favoritemedia"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="audioscrobblertsvimport",
|
||||
name="error_log",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="bgstatsimport",
|
||||
name="error_log",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="ebirdcsvimport",
|
||||
name="error_log",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="koreaderimport",
|
||||
name="error_log",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="lastfmimport",
|
||||
name="error_log",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="retroarchimport",
|
||||
name="error_log",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="scalecsvimport",
|
||||
name="error_log",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="trailgpximport",
|
||||
name="error_log",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,32 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-09 15:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0090_audioscrobblertsvimport_error_log_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="scrobble",
|
||||
name="share_token",
|
||||
field=models.UUIDField(blank=True, editable=False, null=True, unique=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="scrobble",
|
||||
name="visibility",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("public", "Public"),
|
||||
("shared", "Shared"),
|
||||
("private", "Private"),
|
||||
],
|
||||
db_index=True,
|
||||
default="shared",
|
||||
max_length=10,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,29 @@
|
||||
from uuid import uuid4
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def backfill_share_token(apps, schema_editor):
|
||||
Scrobble = apps.get_model("scrobbles", "Scrobble")
|
||||
batch = []
|
||||
for scrobble in Scrobble.objects.filter(share_token__isnull=True).iterator(
|
||||
chunk_size=500
|
||||
):
|
||||
scrobble.share_token = uuid4()
|
||||
batch.append(scrobble)
|
||||
if batch:
|
||||
Scrobble.objects.bulk_update(batch, ["share_token"], batch_size=500)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0091_scrobble_share_token_scrobble_visibility"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
backfill_share_token,
|
||||
reverse_code=migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
@ -0,0 +1,22 @@
|
||||
# Generated by Django 4.2.29 on 2026-06-09 16:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("scrobbles", "0092_backfill_visibility_and_share_token"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="scrobble",
|
||||
name="share_token",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="scrobble",
|
||||
name="share_token_version",
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user